diff --git a/controller/ActionController.ts b/controller/ActionController.ts index 406962d..6fd443a 100644 --- a/controller/ActionController.ts +++ b/controller/ActionController.ts @@ -118,6 +118,12 @@ export class ActionController { isMod: isMod, data: data as ActionData, }) + + // Fully async + useSDK().events.emitAction(action, uid, targetId, data as ActionData, { + drizzle: this.db, + config: useEvent().context.config.config!, + }) } /** @@ -128,7 +134,7 @@ export class ActionController { * @param targetId */ isItemLiked = async ( - itemType: ItemType, + itemType: LikeItemType, uid: number, targetId: number ): Promise => { @@ -159,9 +165,9 @@ export class ActionController { } } -type AvailableActions = "register_user" | "login_user" | "delete_user" | "ban_user" | "unban_user" | +export type AvailableActions = "register_user" | "login_user" | "delete_user" | "ban_user" | "unban_user" | "level_upload" | "level_delete" | "level_update" | "level_rate" | "list_upload" | "list_delete" | "list_update" | "list_rate" | "like_level" | "like_comment" | "like_account_comment" | "like_list" -type ItemType = "level" | "comment" | "account_comment" | "list" \ No newline at end of file +export type LikeItemType = "level" | "comment" | "account_comment" | "list" \ No newline at end of file diff --git a/sdk/events/SDKEvents.ts b/sdk/events/SDKEvents.ts new file mode 100644 index 0000000..497ba3e --- /dev/null +++ b/sdk/events/SDKEvents.ts @@ -0,0 +1,31 @@ +import {AvailableActions} from "~~/controller/ActionController"; +import {ActionInvoker, ActionListener} from "~~/sdk/events/types"; +import {ActionData} from "~~/drizzle"; +import {ctx, Context} from "~~/sdk/events/context"; + +export class SDKEvents { + constructor() { + } + + onAction = ( + action: AvailableActions, + listener: ActionListener, + ) => { + const invoke = async (context: Context, ...data: ArgumentTypes) => { + return await ctx.callAsync(context, async () => { + return listener(...data) + }) + } + useFabric>("actions").on(action, invoke) + } + + emitAction = ( + action: AvailableActions, + uid: number, + targetId: number, + data: ActionData, + context: Context, + ) => { + useFabric>("actions").emit(action, context, uid, targetId, data) + } +} \ No newline at end of file diff --git a/sdk/events/context.ts b/sdk/events/context.ts new file mode 100644 index 0000000..b234c75 --- /dev/null +++ b/sdk/events/context.ts @@ -0,0 +1,16 @@ +import {createContext} from "unctx"; +import {AsyncLocalStorage} from "node:async_hooks"; +import {H3EventContext} from "h3"; + + +export const ctx = createContext({ + asyncContext: true, + AsyncLocalStorage +}) + +export const useEventContext = ctx.use + +export type Context = { + drizzle: Database, + config: ServerConfig +} \ No newline at end of file diff --git a/sdk/events/types.ts b/sdk/events/types.ts new file mode 100644 index 0000000..49759f2 --- /dev/null +++ b/sdk/events/types.ts @@ -0,0 +1,10 @@ +import {ActionData} from "~~/drizzle"; +import {Context} from "~~/sdk/events/context"; + +export type ActionListener = ( + uid: number, + targetId: number, + data: ActionData +) => void | Promise + +export type ActionInvoker = (context: Context, ...data: ArgumentTypes) => Promise \ No newline at end of file diff --git a/server/plugins/plugin-discord-ratebot.ts b/server/plugins/plugin-discord-ratebot.ts new file mode 100644 index 0000000..3a2dce6 --- /dev/null +++ b/server/plugins/plugin-discord-ratebot.ts @@ -0,0 +1,188 @@ +import { LevelController } from "~~/controller/LevelController"; +import type { LevelWithUser } from "~~/controller/Level"; +import type { MaybeUndefined } from "~/utils/types"; +import { ActionData } from "~~/drizzle"; + +type DiscordRateBotModuleConfig = { + webhookUrl: string, + mentionRoleId?: string, + username?: string, + avatarUrl?: string +} + +type DifficultyInfo = { + label: string, + color: number +} + +const difficultyPalette: Record = { + unrated: 0x72757a, + auto: 0x00d1ff, + easy: 0x3ee45f, + normal: 0xf7d774, + hard: 0xffa24c, + harder: 0xff6b4c, + insane: 0xc04bff, + demon: 0x8c2eff +} + +export default defineNitroPlugin(() => { + useSDK().events.onAction("level_rate", async (uid: number, targetId: number, data: ActionData) => { + try { + await dispatchDiscordRateWebhook(targetId, uid, data) + } catch (error) { + useLogger().warn(`[DiscordRateBot] ${(error as Error).message}`) + } + }) +}) + +const dispatchDiscordRateWebhook = async (targetId: number, uid: number, data: ActionData) => { + const actionType = data.type || "" + if (!actionType.startsWith("Rate:")) + return + + const actionSuffix = actionType.slice(5).toLowerCase() + if (!actionSuffix || actionSuffix === "reset") + return + + const { config: serverConfig, drizzle } = useEventContext() + + if (!serverConfig.ServerConfig.EnableModules?.["discord_ratebot"]) + return + + const moduleConfig = serverConfig.ServerConfig.ModuleConfig?.["discord_ratebot"] as MaybeUndefined + if (!moduleConfig?.webhookUrl) + return + + if (!moduleConfig.webhookUrl.startsWith("http")) + throw new Error("Webhook URL must be absolute") + + const levelController = new LevelController(drizzle) + const level = await levelController.getOneLevel(targetId) + if (!level) + return + + const webhookBody = createWebhookBody( + moduleConfig, + level.$, + { + serverId: serverConfig.ServerConfig.SrvID, + moderator: data.uname || `User #${uid}`, + actionDescriptor: actionType + } + ) + + try { + await $fetch(moduleConfig.webhookUrl, { + method: "POST" as any, + body: webhookBody + }) + } catch (error) { + useLogger().error(`[DiscordRateBot] Failed to send webhook: ${(error as Error).message}`) + } +} + +const createWebhookBody = ( + cfg: DiscordRateBotModuleConfig, + level: LevelWithUser, + meta: { + serverId?: string, + moderator: string, + actionDescriptor: string + } +) => { + const rating = Math.max(level.starsGot ?? 0, 0) + const difficulty = resolveDifficulty(rating, level.demonDifficulty ?? -1) + const featureState = level.isFeatured ? "Featured" : "Not featured" + const epicTier = resolveEpic(level.epicness ?? 0) + const creator = level.author?.username || `User #${level.ownerUid}` + const embed = { + title: `${level.name} • ${difficulty.label}`, + description: `**${meta.moderator}** rated this level ${rating ? `${rating}★` : "without stars"}.`, + color: difficulty.color, + fields: [ + { name: "Level ID", value: level.id.toString(), inline: true }, + { name: "Creator", value: creator, inline: true }, + { name: "Difficulty", value: rating ? `${rating}★ • ${difficulty.label}` : difficulty.label, inline: true }, + { name: "Feature", value: featureState, inline: true }, + { name: "Epic Tier", value: epicTier, inline: true }, + { name: "Coins", value: formatCoins(level.coins ?? 0, level.userCoins ?? 0), inline: true }, + { name: "Server", value: meta.serverId || "Unknown", inline: true }, + ], + footer: { + text: `Rated via ${meta.actionDescriptor}` + }, + timestamp: new Date().toISOString() + } + + if (!level.isFeatured && epicTier === "None") { + embed.fields = embed.fields.filter(field => field.name !== "Epic Tier") + } + + const body: Record = { + embeds: [embed] + } + + if (cfg.mentionRoleId) + body.content = `<@&${cfg.mentionRoleId}>` + if (cfg.username) + body.username = cfg.username + if (cfg.avatarUrl) + body.avatar_url = cfg.avatarUrl + + return body +} + +const resolveDifficulty = (stars: number, demonDifficulty: number): DifficultyInfo => { + if (!stars) + return { label: "Unrated", color: difficultyPalette.unrated } + if (stars === 1) + return { label: "Auto", color: difficultyPalette.auto } + if (stars === 2) + return { label: "Easy", color: difficultyPalette.easy } + if (stars === 3) + return { label: "Normal", color: difficultyPalette.normal } + if (stars === 4 || stars === 5) + return { label: "Hard", color: difficultyPalette.hard } + if (stars === 6 || stars === 7) + return { label: "Harder", color: difficultyPalette.harder } + if (stars === 8 || stars === 9) + return { label: "Insane", color: difficultyPalette.insane } + if (stars >= 10) { + const demonLabel = resolveDemon(demonDifficulty) + return { label: demonLabel, color: difficultyPalette.demon } + } + return { label: `${stars}★`, color: difficultyPalette.normal } +} + +const resolveDemon = (value: number) => { + const map: Record = { + 3: "Easy Demon", + 4: "Medium Demon", + 0: "Hard Demon", + 5: "Insane Demon", + 6: "Extreme Demon", + } + return map[value] || "Insane Demon" +} + +const resolveEpic = (value: number) => { + switch (value) { + case 1: + return "Epic" + case 2: + return "Legendary" + case 3: + return "Mythic" + default: + return "None" + } +} + +const formatCoins = (verified: number, userCoins: number) => { + if (!userCoins) + return "No user coins" + if (verified >= userCoins) + return `${verified} verified coins` + return `${verified}/${userCoins} verified` +} \ No newline at end of file diff --git a/server/plugins/plugin-telegram-ratebot.ts b/server/plugins/plugin-telegram-ratebot.ts new file mode 100644 index 0000000..04857d9 --- /dev/null +++ b/server/plugins/plugin-telegram-ratebot.ts @@ -0,0 +1,154 @@ +import { LevelController } from "~~/controller/LevelController"; +import type { LevelWithUser } from "~~/controller/Level"; +import type { MaybeUndefined } from "~/utils/types"; +import { ActionData } from "~~/drizzle"; + +type TelegramRateBotConfig = { + botToken: string, + chatId: number | string, + disableNotification?: boolean, + threadId?: number, + apiBaseUrl?: string +} + +type DifficultyDescriptor = { + name: string, + stars: number +} + +export default defineNitroPlugin(() => { + useSDK().events.onAction("level_rate", async (uid: number, targetId: number, data: ActionData) => { + try { + await sendTelegramRateNotification(targetId, uid, data) + } catch (error) { + useLogger().warn(`[TelegramRateBot] ${(error as Error).message}`) + } + }) +}) + +const sendTelegramRateNotification = async (targetId: number, uid: number, data: ActionData) => { + const actionType = data.type || "" + if (!actionType.startsWith("Rate:")) + return + + const suffix = actionType.slice(5).toLowerCase() + if (!suffix || suffix === "reset") + return + + const { config: serverConfig, drizzle } = useEventContext() + + if (!serverConfig.ServerConfig.EnableModules?.["telegram_ratebot"]) + return + + const moduleConfig = serverConfig.ServerConfig.ModuleConfig?.["telegram_ratebot"] as MaybeUndefined + if (!moduleConfig?.botToken || !moduleConfig.chatId) + return + + const telegramBase = (moduleConfig.apiBaseUrl || "https://api.telegram.org").replace(/\/$/, "") + const levelController = new LevelController(drizzle) + const level = await levelController.getOneLevel(targetId) + if (!level) + return + + const message = buildTelegramMessage(level.$, { + moderator: data.uname || `Пользователь #${uid}`, // data.uname is in ActionData + serverId: serverConfig.ServerConfig.SrvID + }) + + const body: Record = { + chat_id: moduleConfig.chatId, + text: message, + disable_notification: moduleConfig.disableNotification ?? false, + } + if (moduleConfig.threadId) + body.message_thread_id = moduleConfig.threadId + + try { + await $fetch(`${telegramBase}/bot${moduleConfig.botToken}/sendMessage`, { + method: "POST" as any, + body + }) + } catch (error) { + useLogger().error(`[TelegramRateBot] Failed to send message: ${(error as Error).message}`) + } +} + +const buildTelegramMessage = ( + level: LevelWithUser, + meta: { moderator: string, serverId?: string } +) => { + const difficulty = describeDifficulty(level.starsGot ?? 0, level.demonDifficulty ?? -1) + const creator = level.author?.username || `Пользователь #${level.ownerUid}` + const coins = formatCoins(level.coins ?? 0, level.userCoins ?? 0) + const feature = level.isFeatured ? "Да" : "Нет" + const epic = resolveEpic(level.epicness ?? 0) + + const lines = [ + `⭐ Оценка уровня от ${meta.moderator}`, + `• Название: ${level.name}`, + `• ID: ${level.id}`, + `• Автор: ${creator}`, + `• Сложность: ${difficulty.name}`, + `• Звёзды: ${difficulty.stars}`, + `• Фича: ${feature}`, + ...(epic !== "Нет" ? [`• Эпик: ${epic}`] : []), + `• Монеты: ${coins}`, + meta.serverId ? `• Сервер: ${meta.serverId}` : undefined, + ].filter(Boolean) + + return lines.join("\n") +} + +const describeDifficulty = (stars: number, demonDifficulty: number): DifficultyDescriptor => { + if (!stars) + return { name: "Unrated", stars: 0 } + if (stars === 1) + return { name: "Auto", stars } + if (stars === 2) + return { name: "Easy", stars } + if (stars === 3) + return { name: "Normal", stars } + if (stars === 4 || stars === 5) + return { name: "Hard", stars } + if (stars === 6 || stars === 7) + return { name: "Harder", stars } + if (stars === 8 || stars === 9) + return { name: "Insane", stars } + if (stars >= 10) + return { name: resolveDemonLabel(demonDifficulty), stars } + + // Fallback should be theoretically unreachable given the cases above cover 0..inf + // (ignoring negative numbers which shouldn't exist) + return { name: "Unknown", stars } +} + +const resolveDemonLabel = (value: number) => { + const map: Record = { + 0: "Easy Demon", + 1: "Medium Demon", + 2: "Hard Demon", + 4: "Extreme Demon", + } + return map[value] || "Insane Demon" +} + +const resolveEpic = (value: number) => { + switch (value) { + case 1: + return "Эпик" + case 2: + return "Легендарный" + case 3: + return "Мифический" + default: + return "Нет" + } +} + +const formatCoins = (verified: number, userCoins: number) => { + if (!userCoins) + return "Нет пользовательских монет" + if (verified >= userCoins) + return `${userCoins}/${userCoins} подтверждены` + return `${verified}/${userCoins} подтверждены` +} \ No newline at end of file diff --git a/server/utils/types.ts b/server/utils/types.ts index 8868e49..6faf2da 100644 --- a/server/utils/types.ts +++ b/server/utils/types.ts @@ -3,4 +3,6 @@ export type Maybe = T | null | undefined export type Nullable = T | null export type MaybeUndefined = T | undefined export type MakeOptional = Omit & Partial> -export type MaybePromise = T | Promise; \ No newline at end of file +export type MaybePromise = T | Promise; + +export type ArgumentTypes = F extends (...args: infer A) => unknown ? A : never; \ No newline at end of file diff --git a/server/utils/useFabric.ts b/server/utils/useFabric.ts new file mode 100644 index 0000000..7c9a599 --- /dev/null +++ b/server/utils/useFabric.ts @@ -0,0 +1,36 @@ +import EventEmitter from "eventemitter3"; + +type FabricEvents = Record any> + +const fabric: Record = { + default: new EventEmitter() +}; + +/** + * Returns a fabric instance and a terminate function + * @param name Optional fabric name, uses default fabric if not provided + * @returns Fabric instance + */ +export const useFabric = (name?: string) => { + if (!name || name === "default") + return fabric.default as unknown as EventEmitter + if (!fabric[name]) + fabric[name] = new EventEmitter() + + return fabric[name] as unknown as EventEmitter +} + +/** + * Returns temporary fabric instance and a terminate function + * @returns `[Fabric, terminateFabric]` + */ +export const useTemporalFabric = () => { + const name = crypto.randomUUID().toString() + fabric[name] = new EventEmitter() + const terminate = () => { + fabric[name].removeAllListeners() + delete fabric[name] + } + return [fabric[name] as unknown as EventEmitter, terminate] +} + diff --git a/server/utils/useSDK.ts b/server/utils/useSDK.ts index 2cd0f1b..f7ff99c 100644 --- a/server/utils/useSDK.ts +++ b/server/utils/useSDK.ts @@ -1,14 +1,17 @@ import {SDKCommands} from "~~/sdk/commands/SDKCommands"; import {SDKMusic} from "~~/sdk/music/SDKMusic"; +import {SDKEvents} from "~~/sdk/events/SDKEvents"; const sdk = { commands: new SDKCommands(), - music: new SDKMusic() + music: new SDKMusic(), + events: new SDKEvents() } export const useSDK = () => sdk export {useCommandContext} from "~~/sdk/commands/context" export {useMusicContext} from "~~/sdk/music/context" +export {useEventContext} from "~~/sdk/events/context" export type {SDKMusicProvider} from "~~/sdk/music/types" \ No newline at end of file diff --git a/server/utils/useServerConfig.ts b/server/utils/useServerConfig.ts index 22ba67b..7eca4bb 100644 --- a/server/utils/useServerConfig.ts +++ b/server/utils/useServerConfig.ts @@ -14,7 +14,7 @@ export const useServerConfig = async (serverId?: string): Promise<{ return {config, setConfig} } -type ServerConfig = { +export type ServerConfig = { ChestConfig: { ChestSmallOrbsMin: number, ChestSmallOrbsMax: number,