Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions server/plugins/plugin-discord-ratebot.ts
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")
Comment on lines +57 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Слабая валидация URL вебхука.

Проверка startsWith("http") пропустит как http://, так и https://. Discord вебхуки должны использовать HTTPS. Также стоит проверить, что URL ведёт на домен Discord.

-    if (!moduleConfig.webhookUrl.startsWith("http"))
-        throw new Error("Webhook URL must be absolute")
+    if (!moduleConfig.webhookUrl.startsWith("https://discord.com/api/webhooks/") &&
+        !moduleConfig.webhookUrl.startsWith("https://discordapp.com/api/webhooks/"))
+        throw new Error("Invalid Discord webhook URL")
🤖 Prompt for AI Agents
In server/plugins/plugin-discord-ratebot.ts around lines 57-58, the current
check only uses startsWith("http") which allows non-HTTPS and non-Discord URLs;
replace with robust validation: parse the URL using the URL constructor, ensure
protocol === "https:", validate hostname is a Discord webhook host (e.g.,
endsWith("discord.com") or endsWith("discordapp.com")), and optionally verify
the pathname matches the webhook pattern (contains "/api/webhooks/"); if any
check fails, throw a clear Error("Invalid Discord webhook URL: must be an
https:// Discord webhook URL").


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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Маппинг демонов отличается от Telegram-плагина.

Discord использует case 0 → Easy Demon, а Telegram использует 0 → Hard Demon. Это критическое расхождение, которое нужно исправить в одном из плагинов (см. комментарий в Telegram-плагине).

🤖 Prompt for AI Agents
In server/plugins/plugin-discord-ratebot.ts around lines 158–171, the demon
difficulty mapping currently uses case 0 → "Easy Demon" which differs from the
Telegram plugin (where 0 → "Hard Demon"); update the switch so the Discord
plugin uses the same numeric→label mapping as the Telegram plugin (i.e., change
the case labels/returns to match Telegram’s mapping), run/adjust any tests or
consumers that depend on these labels, and add a brief inline comment noting
that this mapping must stay consistent across Discord and Telegram plugins.


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`
}
152 changes: 152 additions & 0 deletions server/plugins/plugin-telegram-ratebot.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -20

Repository: 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.ts

Repository: RigbyHost/NitroCore

Length of output: 7525


🏁 Script executed:

rg "demonDifficulty" server/plugins/ -C 2

Repository: 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, resolveDemonLabel uses inconsistent demon difficulty mappings compared to the Discord plugin's resolveDemon:

  • Telegram: 0 → Hard Demon, 3 → Easy Demon, 4 → Medium Demon, 6 → Extreme Demon
  • Discord: 0 → Easy Demon, 1 → Medium Demon, 2 → Hard Demon, 4 → Extreme Demon

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"
+    }
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In server/plugins/plugin-telegram-ratebot.ts around lines 102 to 120, remove the
unreachable final fallback and update the demon-label mapping to match the
Discord plugin: ensure stars 0→Unrated, 1→Auto, 2→Easy, 3→Normal, 4-5→Hard,
6-7→Harder, 8-9→Insane, and for stars >=10 call resolveDemonLabel but change
resolveDemonLabel to map demonDifficulty 0→Easy Demon, 1→Medium Demon, 2→Hard
Demon, 4→Extreme Demon (matching Discord); delete the unreachable return and
ensure no star values are left unhandled.


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} подтверждены`
}