-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Add Discord and Telegram ratebot plugins to send level rating n… #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
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,192 @@ | ||
| 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) => { | ||
| 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" | ||
| } | ||
| } | ||
|
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,152 @@ | ||
| 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 } | ||
| return { name: `${stars}★`, 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> = { | ||
| 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 "Эпик" | ||
| 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