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