Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 9 additions & 3 deletions controller/ActionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
})
}

/**
Expand All @@ -128,7 +134,7 @@ export class ActionController {
* @param targetId
*/
isItemLiked = async (
itemType: ItemType,
itemType: LikeItemType,
uid: number,
targetId: number
): Promise<boolean> => {
Expand Down Expand Up @@ -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"
export type LikeItemType = "level" | "comment" | "account_comment" | "list"
31 changes: 31 additions & 0 deletions sdk/events/SDKEvents.ts
Original file line number Diff line number Diff line change
@@ -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<ActionListener>) => {
return await ctx.callAsync(context, async () => {
return listener(...data)
})
}
useFabric<Record<AvailableActions, ActionInvoker>>("actions").on(action, invoke)
}

emitAction = (
action: AvailableActions,
uid: number,
targetId: number,
data: ActionData,
context: Context,
) => {
useFabric<Record<AvailableActions, ActionInvoker>>("actions").emit(action, context, uid, targetId, data)
}
}
16 changes: 16 additions & 0 deletions sdk/events/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {createContext} from "unctx";
import {AsyncLocalStorage} from "node:async_hooks";
import {H3EventContext} from "h3";


export const ctx = createContext<Context>({
asyncContext: true,
AsyncLocalStorage
})

export const useEventContext = ctx.use

export type Context = {
drizzle: Database,
config: ServerConfig
}
10 changes: 10 additions & 0 deletions sdk/events/types.ts
Original file line number Diff line number Diff line change
@@ -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<void>

export type ActionInvoker = (context: Context, ...data: ArgumentTypes<ActionListener>) => Promise<void>
188 changes: 188 additions & 0 deletions server/plugins/plugin-discord-ratebot.ts
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
Copy link

@coderabbitai coderabbitai bot Dec 10, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Несогласованное значение по умолчанию для демонов.

Согласно LevelController.countDemonStats, значение по умолчанию (когда demonDifficulty не соответствует 3, 4, 5 или 6) — это Hard Demon, а не "Insane Demon":

 const resolveDemon = (value: number) => {
     const map: Record<number, string> = {
     }
-    return map[value] || "Insane Demon"
+    return map[value] || "Hard Demon"
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"
}
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] || "Hard Demon"
}
🤖 Prompt for AI Agents
In server/plugins/plugin-discord-ratebot.ts around lines 158 to 167, the
resolveDemon function returns "Insane Demon" as the fallback for unknown values,
but LevelController.countDemonStats uses "Hard Demon" as the default; change the
fallback to "Hard Demon" so the function returns map[value] || "Hard Demon" (no
other logic changes needed).

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


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`
}
Loading