-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Add Discord and Telegram ratebot plugins to send level rating n… #6
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, number> = { | ||
| 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<DiscordRateBotModuleConfig> | ||
| 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<string, unknown> = { | ||
| 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<number, string> = { | ||
| 3: "Easy Demon", | ||
| 4: "Medium Demon", | ||
| 0: "Hard Demon", | ||
| 5: "Insane Demon", | ||
| 6: "Extreme Demon", | ||
| } | ||
| return map[value] || "Insane Demon" | ||
| } | ||
|
Comment on lines
+158
to
+167
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Маппинг демонов отличается от Telegram-плагина. Discord использует 🤖 Prompt for AI Agents |
||
|
|
||
| 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` | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<TelegramRateBotConfig> | ||
| 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<string, unknown> = { | ||
| 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 } | ||
| } | ||
|
Comment on lines
+102
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n server/plugins/plugin-telegram-ratebot.ts | sed -n '102,135p'Repository: RigbyHost/NitroCore Length of output: 1346 🏁 Script executed: fd -e ts -name "*discord*" -path "*/plugins/*"Repository: RigbyHost/NitroCore Length of output: 293 🏁 Script executed: fd -e ts -name "*.ts" -path "*/plugins/*" | head -20Repository: RigbyHost/NitroCore Length of output: 293 🏁 Script executed: fd -type f -name "*discord*" server/plugins/Repository: RigbyHost/NitroCore Length of output: 233 🏁 Script executed: ls -la server/plugins/Repository: RigbyHost/NitroCore Length of output: 686 🏁 Script executed: cat -n server/plugins/plugin-discord-ratebot.tsRepository: RigbyHost/NitroCore Length of output: 7525 🏁 Script executed: rg "demonDifficulty" server/plugins/ -C 2Repository: RigbyHost/NitroCore Length of output: 2615 Remove unreachable code and align demon difficulty mapping with Discord plugin. Line 119 is unreachable—all star values from 0 to 10+ are handled by the preceding conditions. Additionally,
This causes the same difficulty level to display differently across channels. Update the function to remove the unreachable fallback and align the mapping: 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 }
- return { name: `${stars}★`, stars }
+ return { name: resolveDemonLabel(demonDifficulty), stars }
}
const resolveDemonLabel = (value: number) => {
- const map: Record<number, string> = {
- 3: "Easy Demon",
- 4: "Medium Demon",
- 0: "Hard Demon",
- 5: "Insane Demon",
- 6: "Extreme Demon",
- }
- return map[value] || "Insane Demon"
+ switch (value) {
+ case 0:
+ return "Easy Demon"
+ case 1:
+ return "Medium Demon"
+ case 2:
+ return "Hard Demon"
+ case 4:
+ return "Extreme Demon"
+ default:
+ return "Insane Demon"
+ }
}
🤖 Prompt for AI Agents |
||
|
|
||
| const resolveDemonLabel = (value: number) => { | ||
| const map: Record<number, string> = { | ||
| 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} подтверждены` | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Слабая валидация URL вебхука.
Проверка
startsWith("http")пропустит какhttp://, так иhttps://. Discord вебхуки должны использовать HTTPS. Также стоит проверить, что URL ведёт на домен Discord.🤖 Prompt for AI Agents