diff --git a/.env.example b/.env.example index 60e478a..84108a2 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,13 @@ -NODE_ENV=development +NODE_ENV=production # Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls + # BullMQ job enqueueing for guild backups, giveaways and the scheduler). BOT_TOKEN= BOT_CLIENT_ID= BOT_CLIENT_SECRET= +# Optional: register slash commands only on this guild (instant sync for dev). +# Leave empty in production so commands are global. When set, global commands +# are cleared; when empty, leftover guild-scoped commands are cleared on startup +# so Discord does not show every command twice. BOT_GUILD_ID= DATABASE_URL=postgresql://nexumi:nexumi@postgres:5432/nexumi REDIS_URL=redis://redis:6379 @@ -15,12 +19,15 @@ BACKUP_RETENTION_DAYS=14 BACKUP_CRON=0 3 * * * BACKUP_DIR=/backups HEALTH_PORT=8080 +# Internal bot health/metrics base (Docker healthcheck). Captcha links use WEBUI_URL. PUBLIC_BASE_URL=http://localhost:8080 TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= -# WebUI (apps/webui) -WEBUI_URL=http://10.111.0.65:3000 +# WebUI (apps/webui) – public URL behind Traefik (also used for captcha verification links) +WEBUI_URL=https://dashboard.nexumi.de +# Discord Developer Portal OAuth2 redirect: +# https://dashboard.nexumi.de/api/auth/callback # Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`. SESSION_SECRET= # Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C). @@ -28,7 +35,28 @@ OWNER_USER_IDS= # Public site / bot links (Phase 8) SUPPORT_SERVER_URL= -LEGAL_OPERATOR_NAME= -LEGAL_OPERATOR_ADDRESS= -LEGAL_OPERATOR_EMAIL= -LEGAL_OPERATOR_PHONE= +LEGAL_OPERATOR_NAME=HexaHost Inh. Samuel Müller +LEGAL_OPERATOR_ADDRESS=Richard-Miller-Straße 1, 94051 Hauzenberg, Deutschland +LEGAL_OPERATOR_EMAIL=info@hexahost.de +LEGAL_OPERATOR_PHONE=+49 15566 175855 + +# --------------------------------------------------------------------------- +# Verification captcha providers (WebUI). Leave empty to disable a provider. +# MATH always works without keys. Per-guild provider is chosen in the dashboard. +# --------------------------------------------------------------------------- +# Google reCAPTCHA v2 (checkbox) – https://www.google.com/recaptcha/admin +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= +# Google reCAPTCHA v3 (score-based). Falls back to RECAPTCHA_* if unset. +RECAPTCHA_V3_SITE_KEY= +RECAPTCHA_V3_SECRET_KEY= +RECAPTCHA_V3_MIN_SCORE=0.5 +# hCaptcha – https://dashboard.hcaptcha.com +HCAPTCHA_SITE_KEY= +HCAPTCHA_SECRET_KEY= +# Cloudflare Turnstile – https://dash.cloudflare.com → Turnstile +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +# Salt for hashing client IPs used in alt-account detection (min 16 chars). +# Generate e.g. with: openssl rand -hex 16 +CAPTCHA_IP_HASH_SALT= diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5a586b3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.lint.unknownAtRules": "ignore" +} diff --git a/README.md b/README.md index f609680..b47f9ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Nexumi -Nexumi is a self-hosted public Discord bot with dashboard architecture, built as a TypeScript monorepo. +Nexumi is a public Discord bot with dashboard architecture, built as a TypeScript monorepo. ## Phase 1 scope diff --git a/apps/bot/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql b/apps/bot/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql new file mode 100644 index 0000000..e0fedb0 --- /dev/null +++ b/apps/bot/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql @@ -0,0 +1,36 @@ +-- AlterTable +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT'; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB; + +-- AlterTable +ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- AlterTable +ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "messageId" TEXT, + "payload" JSONB NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/bot/package.json b/apps/bot/package.json index 94f9719..1b57006 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -9,13 +9,15 @@ "lint": "eslint src --ext .ts", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json", - "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate deploy" + "prisma:generate": "prisma generate --schema=prisma/schema.prisma", + "prisma:migrate": "prisma migrate deploy --schema=prisma/schema.prisma", + "profile:banner": "tsx scripts/set-bot-banner.ts" }, "dependencies": { - "@nexumi/shared": "workspace:*", "@napi-rs/canvas": "^0.1.65", + "@nexumi/shared": "workspace:*", "@prisma/client": "^5.22.0", + "@sentry/node": "^10.67.0", "bullmq": "^5.34.0", "discord.js": "^14.17.3", "dotenv": "^16.4.7", diff --git a/apps/bot/prisma/migrations/20260722210000_command_global_channels/migration.sql b/apps/bot/prisma/migrations/20260722210000_command_global_channels/migration.sql new file mode 100644 index 0000000..caa3070 --- /dev/null +++ b/apps/bot/prisma/migrations/20260722210000_command_global_channels/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "GuildSettings" ADD COLUMN "commandAllowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[]; +ALTER TABLE "GuildSettings" ADD COLUMN "commandDeniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql b/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql new file mode 100644 index 0000000..e0fedb0 --- /dev/null +++ b/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql @@ -0,0 +1,36 @@ +-- AlterTable +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT'; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB; + +-- AlterTable +ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- AlterTable +ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "messageId" TEXT, + "payload" JSONB NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/bot/prisma/migrations/20260723120000_verification_captcha_alt/migration.sql b/apps/bot/prisma/migrations/20260723120000_verification_captcha_alt/migration.sql new file mode 100644 index 0000000..7ba745f --- /dev/null +++ b/apps/bot/prisma/migrations/20260723120000_verification_captcha_alt/migration.sql @@ -0,0 +1,37 @@ +-- AlterTable +ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "captchaProvider" TEXT NOT NULL DEFAULT 'MATH'; +ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altDetectionEnabled" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altBlockOnMatch" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altIpWindowDays" INTEGER NOT NULL DEFAULT 30; +ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altInviteWindowHours" INTEGER NOT NULL DEFAULT 48; +ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altMaxAccountsPerInvite" INTEGER NOT NULL DEFAULT 3; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "VerificationRecord" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "inviteCode" TEXT, + "inviterId" TEXT, + "ipHash" TEXT, + "userAgentHash" TEXT, + "captchaProvider" TEXT, + "flaggedAsAlt" BOOLEAN NOT NULL DEFAULT false, + "matchedUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "VerificationRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "VerificationRecord_guildId_userId_key" ON "VerificationRecord"("guildId", "userId"); +CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_ipHash_createdAt_idx" ON "VerificationRecord"("guildId", "ipHash", "createdAt"); +CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_inviteCode_createdAt_idx" ON "VerificationRecord"("guildId", "inviteCode", "createdAt"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "VerificationRecord" ADD CONSTRAINT "VerificationRecord_guildId_fkey" + FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index f46f7b6..1e15fe8 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -20,6 +20,7 @@ model Guild { logChannels LogChannel[] welcomeConfig WelcomeConfig? verificationConfig VerificationConfig? + verificationRecords VerificationRecord[] levelingConfig LevelingConfig? memberLevels MemberLevel[] levelRewards LevelReward[] @@ -51,6 +52,7 @@ model Guild { socialFeeds SocialFeed[] scheduledMessages ScheduledMessage[] guildBackups GuildBackup[] + componentMessageBindings ComponentMessageBinding[] dashboardAccessRules DashboardAccessRule[] dashboardAuditLogs DashboardAuditLog[] commandOverrides CommandOverride[] @@ -64,6 +66,10 @@ model GuildSettings { moderationEnabled Boolean @default(true) /// Snipe/editsnipe storage and commands (SPEC: default off for privacy). snipeEnabled Boolean @default(false) + /// Global command channel whitelist (empty = all channels unless denied). + commandAllowedChannelIds String[] @default([]) + /// Global command channel blacklist. + commandDeniedChannelIds String[] @default([]) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) } @@ -241,8 +247,11 @@ model WelcomeConfig { welcomeType String @default("TEXT") welcomeContent String? welcomeEmbed Json? + welcomeComponents Json? + leaveType String @default("TEXT") leaveContent String? leaveEmbed Json? + leaveComponents Json? welcomeDmEnabled Boolean @default(false) welcomeDmContent String? userAutoroleIds String[] @default([]) @@ -255,20 +264,47 @@ model WelcomeConfig { } model VerificationConfig { - id String @id @default(cuid()) - guildId String @unique - enabled Boolean @default(false) - channelId String? - verifiedRoleId String? - unverifiedRoleId String? - mode String @default("BUTTON") - minAccountAgeDays Int @default(0) - failAction String @default("KICK") - panelChannelId String? - panelMessageId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(false) + channelId String? + verifiedRoleId String? + unverifiedRoleId String? + mode String @default("BUTTON") + /// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE + captchaProvider String @default("MATH") + minAccountAgeDays Int @default(0) + failAction String @default("KICK") + altDetectionEnabled Boolean @default(false) + altBlockOnMatch Boolean @default(true) + altIpWindowDays Int @default(30) + altInviteWindowHours Int @default(48) + altMaxAccountsPerInvite Int @default(3) + panelChannelId String? + panelMessageId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +/// One row per successful (or blocked-as-alt) verification attempt fingerprint. +model VerificationRecord { + id String @id @default(cuid()) + guildId String + userId String + inviteCode String? + inviterId String? + ipHash String? + userAgentHash String? + captchaProvider String? + flaggedAsAlt Boolean @default(false) + matchedUserId String? + createdAt DateTime @default(now()) + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, userId]) + @@index([guildId, ipHash, createdAt]) + @@index([guildId, inviteCode, createdAt]) } model LevelingConfig { @@ -551,6 +587,7 @@ model Tag { name String content String? embed Json? + components Json? responseType String @default("TEXT") triggerWord String? allowedRoleIds String[] @default([]) @@ -770,6 +807,7 @@ model ScheduledMessage { creatorId String content String? embed Json? + components Json? rolePingId String? cron String? runAt DateTime? @@ -782,6 +820,20 @@ model ScheduledMessage { @@index([guildId, enabled]) } +model ComponentMessageBinding { + id String @id @default(cuid()) + guildId String + channelId String + messageId String? + payload Json + createdById String + createdAt DateTime @default(now()) + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId, createdAt]) + @@index([messageId]) +} + model GuildBackup { id String @id @default(cuid()) guildId String @@ -854,7 +906,7 @@ model FeatureFlag { model BotPresenceConfig { id String @id @default("singleton") status String @default("online") - activityType String @default("Playing") + activityType String @default("Watching") activityText String @default("nexumi.de") rotatingMessages Json? maintenanceMode Boolean @default(false) diff --git a/apps/bot/scripts/set-bot-banner.ts b/apps/bot/scripts/set-bot-banner.ts new file mode 100644 index 0000000..43a7c2d --- /dev/null +++ b/apps/bot/scripts/set-bot-banner.ts @@ -0,0 +1,60 @@ +/** + * Uploads docs/logo/nexumi-banner.png as the bot user banner (and optional bio) + * via Discord API. + * + * Usage (from repo root): + * pnpm --filter @nexumi/bot profile:banner + * + * Requires BOT_TOKEN in the environment (or root/.env / apps/bot/.env). + */ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as loadEnv } from 'dotenv'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '../../..'); + +for (const candidate of [resolve(repoRoot, '.env'), resolve(here, '../.env')]) { + if (existsSync(candidate)) { + loadEnv({ path: candidate }); + } +} + +const token = process.env.BOT_TOKEN; +if (!token) { + console.error('BOT_TOKEN is missing.'); + process.exit(1); +} + +const bannerPath = resolve(repoRoot, 'docs/logo/nexumi-banner.png'); +if (!existsSync(bannerPath)) { + console.error(`Banner not found: ${bannerPath}`); + process.exit(1); +} + +const png = readFileSync(bannerPath); +const bannerDataUri = `data:image/png;base64,${png.toString('base64')}`; + +const bio = + 'Moderation, Community & Integrationen — in einem Bot.\n' + + 'Dashboard: https://dashboard.nexumi.de\n' + + 'nexumi.de'; + +const response = await fetch('https://discord.com/api/v10/users/@me', { + method: 'PATCH', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ banner: bannerDataUri, bio }) +}); + +const body = await response.text(); +if (!response.ok) { + console.error(`Failed (${response.status}): ${body}`); + process.exit(1); +} + +console.log('Bot profile updated successfully.'); +console.log(body); diff --git a/apps/bot/src/command-gate-rules.ts b/apps/bot/src/command-gate-rules.ts new file mode 100644 index 0000000..f0377fa --- /dev/null +++ b/apps/bot/src/command-gate-rules.ts @@ -0,0 +1,38 @@ +export type CommandGateReason = + | 'disabled' + | 'channel_denied' + | 'channel_not_allowed' + | 'role_denied' + | 'role_not_allowed' + | 'cooldown'; + +export function isChannelAllowed( + channelId: string | null, + allowed: string[], + denied: string[] +): CommandGateReason | null { + if (!channelId) { + return null; + } + if (denied.includes(channelId)) { + return 'channel_denied'; + } + if (allowed.length > 0 && !allowed.includes(channelId)) { + return 'channel_not_allowed'; + } + return null; +} + +export function isRoleAllowed( + roleIds: string[], + allowed: string[], + denied: string[] +): CommandGateReason | null { + if (denied.some((id) => roleIds.includes(id))) { + return 'role_denied'; + } + if (allowed.length > 0 && !allowed.some((id) => roleIds.includes(id))) { + return 'role_not_allowed'; + } + return null; +} diff --git a/apps/bot/src/command-gates.test.ts b/apps/bot/src/command-gates.test.ts new file mode 100644 index 0000000..037866c --- /dev/null +++ b/apps/bot/src/command-gates.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { isChannelAllowed, isRoleAllowed } from './command-gate-rules.js'; + +describe('command-gates channel rules', () => { + it('allows any channel when allow and deny are empty', () => { + expect(isChannelAllowed('1', [], [])).toBeNull(); + }); + + it('blocks denied channels even when allow is empty', () => { + expect(isChannelAllowed('1', [], ['1'])).toBe('channel_denied'); + }); + + it('requires membership when allow list is set', () => { + expect(isChannelAllowed('1', ['2'], [])).toBe('channel_not_allowed'); + expect(isChannelAllowed('2', ['2'], [])).toBeNull(); + }); + + it('prefers deny over allow', () => { + expect(isChannelAllowed('1', ['1'], ['1'])).toBe('channel_denied'); + }); +}); + +describe('command-gates role rules', () => { + it('blocks when any denied role is present', () => { + expect(isRoleAllowed(['a', 'b'], [], ['b'])).toBe('role_denied'); + }); + + it('requires an allowed role when allow list is set', () => { + expect(isRoleAllowed(['a'], ['b'], [])).toBe('role_not_allowed'); + expect(isRoleAllowed(['a', 'b'], ['b'], [])).toBeNull(); + }); +}); diff --git a/apps/bot/src/command-gates.ts b/apps/bot/src/command-gates.ts new file mode 100644 index 0000000..4d9a010 --- /dev/null +++ b/apps/bot/src/command-gates.ts @@ -0,0 +1,92 @@ +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import type { BotContext } from './types.js'; +import { redis } from './redis.js'; +import { + isChannelAllowed, + isRoleAllowed, + type CommandGateReason +} from './command-gate-rules.js'; + +export type { CommandGateReason }; +export type CommandGateResult = + | { ok: true } + | { ok: false; reason: CommandGateReason; retryAfterSeconds?: number }; + +function memberRoleIds(member: GuildMember | null): string[] { + if (!member) { + return []; + } + return [...member.roles.cache.keys()]; +} + +/** + * Applies guild-wide command channel rules and per-command overrides. + * Empty allow-lists mean "no restriction"; deny-lists always block. + * Per-command allow/deny is evaluated after global channel rules. + */ +export async function evaluateCommandGate( + interaction: ChatInputCommandInteraction, + context: BotContext +): Promise { + if (!interaction.guildId) { + return { ok: true }; + } + + const [settings, override] = await Promise.all([ + context.prisma.guildSettings.findUnique({ where: { guildId: interaction.guildId } }), + context.prisma.commandOverride.findUnique({ + where: { + guildId_commandName: { + guildId: interaction.guildId, + commandName: interaction.commandName + } + } + }) + ]); + + if (override && !override.enabled) { + return { ok: false, reason: 'disabled' }; + } + + const channelId = interaction.channelId; + const globalChannelBlock = isChannelAllowed( + channelId, + settings?.commandAllowedChannelIds ?? [], + settings?.commandDeniedChannelIds ?? [] + ); + if (globalChannelBlock) { + return { ok: false, reason: globalChannelBlock }; + } + + if (override) { + const perCommandChannelBlock = isChannelAllowed( + channelId, + override.allowedChannelIds, + override.deniedChannelIds + ); + if (perCommandChannelBlock) { + return { ok: false, reason: perCommandChannelBlock }; + } + + const member = + interaction.member && 'roles' in interaction.member + ? (interaction.member as GuildMember) + : null; + const roleIds = memberRoleIds(member); + const roleBlock = isRoleAllowed(roleIds, override.allowedRoleIds, override.deniedRoleIds); + if (roleBlock) { + return { ok: false, reason: roleBlock }; + } + + if (override.cooldownSeconds && override.cooldownSeconds > 0) { + const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`; + const existing = await redis.ttl(key); + if (existing > 0) { + return { ok: false, reason: 'cooldown', retryAfterSeconds: existing }; + } + await redis.set(key, '1', 'EX', override.cooldownSeconds); + } + } + + return { ok: true }; +} diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index e221e42..e74e256 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -4,11 +4,20 @@ import { type ChatInputCommandInteraction, type MessageContextMenuCommandInteraction } from 'discord.js'; -import { t } from '@nexumi/shared'; +import { t, tf, type Locale } from '@nexumi/shared'; import { env } from './env.js'; import { logger } from './logger.js'; import { getGuildLocale } from './i18n.js'; +import { evaluateCommandGate, type CommandGateReason } from './command-gates.js'; +import { + isGuildBlacklisted, + isModuleEnabled, + isUserBlacklisted, + moduleForCommand +} from './module-gates.js'; import { isMaintenanceMode } from './presence.js'; +import { recordCommandMetric } from './metrics.js'; +import { captureException } from './sentry.js'; import { moderationCommands } from './modules/moderation/commands.js'; import { automodCommands } from './modules/automod/commands.js'; import { welcomeCommands } from './modules/welcome/commands.js'; @@ -60,67 +69,208 @@ const commands: SlashCommand[] = [ const map = new Map(commands.map((c) => [c.data.name, c])); const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c])); +function buildCommandBody(): object[] { + const seen = new Set(); + const body: object[] = []; + for (const command of [...commands, ...utilityContextMenus]) { + const json = command.data.toJSON() as { name: string; type?: number }; + const key = `${json.type ?? 1}:${json.name}`; + if (seen.has(key)) { + logger.warn({ name: json.name, type: json.type }, 'Skipping duplicate command definition'); + continue; + } + seen.add(key); + body.push(json); + } + return body; +} + +/** + * Guild-scoped leftovers + global commands both appear in Discord's picker. + * Always clear the unused scope so each command shows once. + */ +async function clearGuildCommandLeftovers(rest: REST, clientId: string): Promise { + const guilds = (await rest.get(Routes.userGuilds())) as Array<{ id: string }>; + let cleared = 0; + for (const guild of guilds) { + const existing = (await rest.get( + Routes.applicationGuildCommands(clientId, guild.id) + )) as unknown[]; + if (existing.length === 0) { + continue; + } + await rest.put(Routes.applicationGuildCommands(clientId, guild.id), { body: [] }); + cleared += 1; + } + return cleared; +} + export async function registerCommands() { const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN); - const body = [ - ...commands.map((c) => c.data.toJSON()), - ...utilityContextMenus.map((c) => c.data.toJSON()) - ]; + const body = buildCommandBody(); // Guild commands sync instantly; global can take up to ~1h. if (env.BOT_GUILD_ID) { await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), { body }); + // Remove global copies so the guild does not show every command twice. + await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body: [] }); logger.info( { scope: 'guild', guildId: env.BOT_GUILD_ID, - count: commands.length, - contextMenus: utilityContextMenus.length + count: body.length, + clearedGlobal: true }, - 'Registered guild application commands' + 'Registered guild application commands and cleared global commands' ); return; } await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body }); + + // Wipe leftover guild-scoped registrations from earlier BOT_GUILD_ID / shard runs. + let clearedGuilds = 0; + try { + clearedGuilds = await clearGuildCommandLeftovers(rest, env.BOT_CLIENT_ID); + } catch (error) { + logger.warn({ error }, 'Failed to clear leftover guild application commands'); + } + logger.info( { scope: 'global', - count: commands.length, - contextMenus: utilityContextMenus.length + count: body.length, + clearedGuilds }, - 'Registered global application commands' + 'Registered global application commands and cleared guild-scoped leftovers' ); } -export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) { - const locale = await getGuildLocale(context.prisma, interaction.guildId); - const maintenance = await isMaintenanceMode(context); - const ownerIds = env.OWNER_USER_IDS ?? []; - if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) { - await interaction.reply({ - content: maintenance.message?.trim() || t(locale, 'generic.maintenance'), - ephemeral: true - }); - return; +function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSeconds?: number): string { + switch (reason) { + case 'disabled': + return t(locale, 'generic.commandDisabled'); + case 'channel_denied': + case 'channel_not_allowed': + return t(locale, 'generic.commandChannelBlocked'); + case 'role_denied': + case 'role_not_allowed': + return t(locale, 'generic.commandRoleBlocked'); + case 'cooldown': + return tf(locale, 'generic.commandCooldown', { + seconds: retryAfterSeconds ?? 1 + }); + default: + return t(locale, 'generic.noPermission'); } +} - const command = map.get(interaction.commandName); - if (!command) { - await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); - return; +export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) { + const started = Date.now(); + let ok = true; + const locale = await getGuildLocale(context.prisma, interaction.guildId); + + try { + if (await isUserBlacklisted(context.prisma, interaction.user.id)) { + await interaction.reply({ + content: t(locale, 'generic.userBlacklisted'), + ephemeral: true + }); + return; + } + + if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) { + await interaction.reply({ + content: t(locale, 'generic.guildBlacklisted'), + ephemeral: true + }); + return; + } + + const maintenance = await isMaintenanceMode(context); + const ownerIds = env.OWNER_USER_IDS ?? []; + if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) { + await interaction.reply({ + content: maintenance.message?.trim() || t(locale, 'generic.maintenance'), + ephemeral: true + }); + return; + } + + const command = map.get(interaction.commandName); + if (!command) { + await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); + return; + } + + const moduleId = moduleForCommand(interaction.commandName); + if (moduleId && interaction.guildId) { + const enabled = await isModuleEnabled( + context.prisma, + context.redis, + interaction.guildId, + moduleId + ); + if (!enabled) { + await interaction.reply({ + content: t(locale, 'generic.moduleDisabled'), + ephemeral: true + }); + return; + } + } + + const gate = await evaluateCommandGate(interaction, context); + if (!gate.ok) { + await interaction.reply({ + content: gateMessage(locale, gate.reason, gate.retryAfterSeconds), + ephemeral: true + }); + return; + } + + await command.execute(interaction, context); + } catch (error) { + ok = false; + captureException(error, { + command: interaction.commandName, + guildId: interaction.guildId, + userId: interaction.user.id + }); + throw error; + } finally { + await recordCommandMetric(context.redis, { + ok, + durationMs: Date.now() - started + }).catch(() => undefined); } - await command.execute(interaction, context); } export async function routeContextMenu( interaction: MessageContextMenuCommandInteraction, context: BotContext ) { - const command = contextMenuMap.get(interaction.commandName); const locale = await getGuildLocale(context.prisma, interaction.guildId); + + if (await isUserBlacklisted(context.prisma, interaction.user.id)) { + await interaction.reply({ + content: t(locale, 'generic.userBlacklisted'), + ephemeral: true + }); + return; + } + + if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) { + await interaction.reply({ + content: t(locale, 'generic.guildBlacklisted'), + ephemeral: true + }); + return; + } + + const command = contextMenuMap.get(interaction.commandName); if (!command) { await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); return; diff --git a/apps/bot/src/confirmation-store.test.ts b/apps/bot/src/confirmation-store.test.ts new file mode 100644 index 0000000..a4cb2f7 --- /dev/null +++ b/apps/bot/src/confirmation-store.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { Redis } from 'ioredis'; +import { + deleteConfirmation, + getConfirmation, + setConfirmation, + takeConfirmation +} from './confirmation-store.js'; + +function createMemoryRedis(): Redis { + const store = new Map(); + return { + async set(key: string, value: string) { + store.set(key, value); + return 'OK'; + }, + async get(key: string) { + return store.get(key) ?? null; + }, + async del(key: string) { + store.delete(key); + return 1; + } + } as unknown as Redis; +} + +describe('confirmation-store', () => { + it('stores and retrieves pending confirmations', async () => { + const redis = createMemoryRedis(); + await setConfirmation(redis, 'moderation', 'tok-1', { action: 'ban', userId: '1' }, 60); + await expect(getConfirmation(redis, 'moderation', 'tok-1')).resolves.toEqual({ + action: 'ban', + userId: '1' + }); + }); + + it('takeConfirmation deletes the entry', async () => { + const redis = createMemoryRedis(); + await setConfirmation(redis, 'moderation', 'tok-2', { action: 'purge' }, 60); + await expect(takeConfirmation(redis, 'moderation', 'tok-2')).resolves.toEqual({ + action: 'purge' + }); + await expect(getConfirmation(redis, 'moderation', 'tok-2')).resolves.toBeNull(); + }); + + it('deleteConfirmation removes pending state', async () => { + const redis = createMemoryRedis(); + await setConfirmation(redis, 'guildbackup', 'tok-3', { action: 'delete' }, 60); + await deleteConfirmation(redis, 'guildbackup', 'tok-3'); + await expect(getConfirmation(redis, 'guildbackup', 'tok-3')).resolves.toBeNull(); + }); +}); diff --git a/apps/bot/src/confirmation-store.ts b/apps/bot/src/confirmation-store.ts new file mode 100644 index 0000000..c7be97e --- /dev/null +++ b/apps/bot/src/confirmation-store.ts @@ -0,0 +1,57 @@ +import type { Redis } from 'ioredis'; + +function key(namespace: string, token: string): string { + return `confirm:${namespace}:${token}`; +} + +export async function setConfirmation( + redis: Redis, + namespace: string, + token: string, + entry: T, + ttlSeconds: number +): Promise { + await redis.set(key(namespace, token), JSON.stringify(entry), 'EX', ttlSeconds); +} + +export async function getConfirmation( + redis: Redis, + namespace: string, + token: string +): Promise { + const raw = await redis.get(key(namespace, token)); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export async function deleteConfirmation( + redis: Redis, + namespace: string, + token: string +): Promise { + await redis.del(key(namespace, token)); +} + +export async function takeConfirmation( + redis: Redis, + namespace: string, + token: string +): Promise { + const k = key(namespace, token); + const raw = await redis.get(k); + if (!raw) { + return null; + } + await redis.del(k); + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index 8fd549d..f589a77 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -19,6 +19,7 @@ const EnvSchema = z.object({ BACKUP_CRON: z.string().default('0 3 * * *'), BACKUP_DIR: z.string().default('/backups'), METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()), HEALTH_PORT: z.coerce.number().int().positive().default(8080), PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'), TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()), @@ -38,7 +39,9 @@ const EnvSchema = z.object({ ) ), WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()), - SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional()) + SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional()), + /// Salt for hashing client IPs in verification alt-detection (shared with WebUI). + CAPTCHA_IP_HASH_SALT: z.preprocess(emptyToUndefined, z.string().min(16).optional()) }); export const env = EnvSchema.parse(process.env); @@ -46,7 +49,7 @@ export const env = EnvSchema.parse(process.env); export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string { const url = new URL('https://discord.com/api/oauth2/authorize'); url.searchParams.set('client_id', clientId); - url.searchParams.set('permissions', '8'); + url.searchParams.set('permissions', '4786708802825463'); url.searchParams.set('scope', 'bot applications.commands'); return url.toString(); } diff --git a/apps/bot/src/harden.test.ts b/apps/bot/src/harden.test.ts new file mode 100644 index 0000000..61fd811 --- /dev/null +++ b/apps/bot/src/harden.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { formatPrometheus, type MetricsSnapshot } from './metrics.js'; + +describe('confirmation custom_id matchers', () => { + it('detects moderation confirm/cancel prefixes', () => { + const isMod = (id: string) => id.startsWith('mod:confirm:') || id.startsWith('mod:cancel:'); + expect(isMod('mod:confirm:abc')).toBe(true); + expect(isMod('mod:cancel:abc')).toBe(true); + expect(isMod('other')).toBe(false); + }); + + it('detects guildbackup confirm/cancel prefixes', () => { + const isBackup = (id: string) => + id.startsWith('gbackup:confirm:delete:') || + id.startsWith('gbackup:confirm:restore:') || + id.startsWith('gbackup:cancel:'); + expect(isBackup('gbackup:confirm:delete:abc')).toBe(true); + expect(isBackup('gbackup:confirm:restore:abc')).toBe(true); + expect(isBackup('gbackup:cancel:abc')).toBe(true); + expect(isBackup('mod:confirm:abc')).toBe(false); + }); +}); + +describe('formatPrometheus', () => { + it('emits core counters and shard gauges', () => { + const snapshot: MetricsSnapshot = { + up: 1, + commandsTotal: 10, + commandsErrors: 2, + commandDurationSumMs: 500, + commandDurationCount: 10, + eventsTotal: 100, + shards: [{ id: 0, ping: 42, guilds: 5, updatedAt: Date.now() }], + queueWaiting: 3 + }; + const body = formatPrometheus(snapshot); + expect(body).toContain('nexumi_up 1'); + expect(body).toContain('nexumi_commands_total 10'); + expect(body).toContain('nexumi_commands_errors_total 2'); + expect(body).toContain('nexumi_guilds{shard="0"} 5'); + expect(body).toContain('nexumi_shard_ping_ms{shard="0"} 42'); + expect(body).toContain('nexumi_queue_waiting 3'); + }); +}); diff --git a/apps/bot/src/health.ts b/apps/bot/src/health.ts index e7ada71..0efb166 100644 --- a/apps/bot/src/health.ts +++ b/apps/bot/src/health.ts @@ -6,7 +6,17 @@ import { getCaptchaChallenge, hashCaptchaAnswer } from './modules/verification/service.js'; -import { verificationQueue } from './queues.js'; +import { + automodQueue, + backupQueue, + birthdayQueue, + feedsQueue, + presenceQueue, + retentionQueue, + ticketQueue, + verificationQueue +} from './queues.js'; +import { collectMetricsSnapshot, formatPrometheus } from './metrics.js'; function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { @@ -61,9 +71,17 @@ async function handleCaptchaGet(url: URL, res: ServerResponse): Promise { res.end('Captcha expired'); return; } + // External providers (reCAPTCHA / hCaptcha / Turnstile) are served by the WebUI. + if (challenge.provider !== 'MATH' && env.WEBUI_URL) { + const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`; + res.statusCode = 302; + res.setHeader('Location', target); + res.end(); + return; + } res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCaptchaPage(token, challenge.question)); + res.end(renderCaptchaPage(token, challenge.question ?? '?')); } async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise { @@ -82,16 +100,32 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro res.end('Captcha expired'); return; } - if (hashCaptchaAnswer(answer) !== challenge.answerHash) { + if (challenge.provider !== 'MATH') { + if (env.WEBUI_URL) { + const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`; + res.statusCode = 302; + res.setHeader('Location', target); + res.end(); + return; + } + res.statusCode = 400; + res.end('Use the WebUI captcha URL for this provider'); + return; + } + if (!challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) { res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.')); + res.end(renderCaptchaPage(token, challenge.question ?? '?', 'Wrong answer. Try again.')); return; } await deleteCaptchaChallenge(redis, token); await verificationQueue.add( 'verificationComplete', - { guildId: challenge.guildId, userId: challenge.userId }, + { + guildId: challenge.guildId, + userId: challenge.userId, + captchaProvider: challenge.provider + }, { removeOnComplete: 1000, removeOnFail: 500 } ); res.statusCode = 200; @@ -99,6 +133,19 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro res.end('

Verified

You can return to Discord.

'); } +async function approximateQueueWaiting(): Promise { + const counts = await Promise.all([ + automodQueue.getWaitingCount(), + backupQueue.getWaitingCount(), + birthdayQueue.getWaitingCount(), + feedsQueue.getWaitingCount(), + presenceQueue.getWaitingCount(), + retentionQueue.getWaitingCount(), + ticketQueue.getWaitingCount() + ]); + return counts.reduce((sum, n) => sum + n, 0); +} + export function startHealthServer(): void { const server = createServer(async (req, res) => { if (!req.url) { @@ -121,8 +168,16 @@ export function startHealthServer(): void { res.end('Unauthorized'); return; } - res.statusCode = 200; - res.end('# Prometheus metrics scaffold\nnexumi_up 1\n'); + try { + const snapshot = await collectMetricsSnapshot(redis); + snapshot.queueWaiting = await approximateQueueWaiting(); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.end(formatPrometheus(snapshot)); + } catch { + res.statusCode = 500; + res.end('metrics error'); + } return; } diff --git a/apps/bot/src/hierarchy.ts b/apps/bot/src/hierarchy.ts new file mode 100644 index 0000000..4fdb0f3 --- /dev/null +++ b/apps/bot/src/hierarchy.ts @@ -0,0 +1,141 @@ +import { + type ChatInputCommandInteraction, + type GuildMember, + type Interaction +} from 'discord.js'; +import type { Locale } from '@nexumi/shared'; +import { t } from '@nexumi/shared'; + +export type HierarchyAction = 'ban' | 'kick' | 'timeout' | 'nick'; + +export type HierarchyOk = { ok: true; member: GuildMember | null }; +export type HierarchyDenied = { ok: false }; + +/** + * Validates that the moderator and bot can act on the target member. + * Replies ephemerally on denial. For bans, `member` may be null if the user + * already left the guild (ban-by-id is still allowed). + */ +export async function assertModerableMember( + interaction: ChatInputCommandInteraction, + targetUserId: string, + locale: Locale, + action: HierarchyAction +): Promise { + const guild = interaction.guild; + if (!guild) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return { ok: false }; + } + + if (targetUserId === interaction.user.id) { + await interaction.reply({ content: t(locale, 'moderation.hierarchy.self'), ephemeral: true }); + return { ok: false }; + } + + if (targetUserId === guild.ownerId) { + await interaction.reply({ content: t(locale, 'moderation.hierarchy.owner'), ephemeral: true }); + return { ok: false }; + } + + let member: GuildMember | null = null; + try { + member = await guild.members.fetch(targetUserId); + } catch { + if (action === 'ban') { + return { ok: true, member: null }; + } + await interaction.reply({ content: t(locale, 'moderation.hierarchy.notMember'), ephemeral: true }); + return { ok: false }; + } + + const moderator = + interaction.member && 'roles' in interaction.member + ? (interaction.member as GuildMember) + : await guild.members.fetch(interaction.user.id); + + if (guild.ownerId !== moderator.id) { + if (member.roles.highest.position >= moderator.roles.highest.position) { + await interaction.reply({ + content: t(locale, 'moderation.hierarchy.moderator'), + ephemeral: true + }); + return { ok: false }; + } + } + + const botOk = + action === 'ban' + ? member.bannable + : action === 'kick' + ? member.kickable + : action === 'timeout' + ? member.moderatable + : member.manageable; + + if (!botOk) { + await interaction.reply({ + content: t(locale, 'moderation.hierarchy.bot'), + ephemeral: true + }); + return { ok: false }; + } + + return { ok: true, member }; +} + +async function replyHierarchyForButton(interaction: Interaction, locale: Locale, key: string): Promise { + if (!interaction.isRepliable()) { + return; + } + const content = t(locale, key); + if (interaction.isButton()) { + await interaction.update({ content, components: [] }); + return; + } + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content, ephemeral: true }); + } else { + await interaction.reply({ content, ephemeral: true }); + } +} + +export async function assertBannableForConfirm( + interaction: Interaction, + locale: Locale, + targetUserId: string +): Promise { + const guild = interaction.guild; + if (!guild) { + await replyHierarchyForButton(interaction, locale, 'generic.guildOnly'); + return false; + } + if (targetUserId === interaction.user.id) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.self'); + return false; + } + if (targetUserId === guild.ownerId) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.owner'); + return false; + } + + try { + const member = await guild.members.fetch(targetUserId); + if (!member.bannable) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.bot'); + return false; + } + const moderator = await guild.members.fetch(interaction.user.id); + if ( + guild.ownerId !== moderator.id && + member.roles.highest.position >= moderator.roles.highest.position + ) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.moderator'); + return false; + } + } catch { + // Target left the guild — ban by id is still fine. + } + + return true; +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 05dda92..25e7e1b 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -14,60 +14,30 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js' import { ensureRecurringJobs, startWorkers } from './jobs.js'; import type { BotContext } from './types.js'; import { startHealthServer } from './health.js'; -import { - handleModerationConfirmation, - isModerationConfirmation -} from './modules/moderation/confirmations.js'; import { getGuildLocale } from './i18n.js'; import { t } from '@nexumi/shared'; import { handleAutoModMessage } from './modules/automod/actions.js'; import { registerLoggingEvents } from './modules/logging/events.js'; import { registerWelcomeEvents } from './modules/welcome/events.js'; import { registerVerificationEvents } from './modules/verification/events.js'; -import { handleVerificationButton } from './modules/verification/handlers.js'; -import { isVerificationButton } from './modules/verification/service.js'; import { registerLevelingEvents } from './modules/leveling/events.js'; -import { - handleBlackjackButton, - isBlackjackButton -} from './modules/economy/blackjack-buttons.js'; -import { - handleEmbedModal, - handlePollButton, - isEmbedModal, - isPollButton, - registerUtilityEvents -} from './modules/utility/index.js'; -import { handleFunButton, isFunButton } from './modules/fun/index.js'; -import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js'; -import { - handleTicketInteraction, - isTicketInteraction, - registerTicketEvents -} from './modules/tickets/index.js'; -import { - handleSelfRoleInteraction, - isSelfRoleInteraction, - registerSelfRoleEvents -} from './modules/selfroles/index.js'; +import { registerUtilityEvents } from './modules/utility/index.js'; +import { registerTicketEvents } from './modules/tickets/index.js'; +import { registerSelfRoleEvents } from './modules/selfroles/index.js'; import { registerTagEvents } from './modules/tags/index.js'; import { registerStarboardEvents } from './modules/starboard/index.js'; -import { - handleSuggestionButton, - isSuggestionButton -} from './modules/suggestions/index.js'; -import { - handleTempVoiceInteraction, - isTempVoiceInteraction, - isTempVoiceModal, - registerTempVoiceEvents -} from './modules/tempvoice/index.js'; +import { registerTempVoiceEvents } from './modules/tempvoice/index.js'; import { registerStatsEvents } from './modules/stats/index.js'; -import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js'; +import { routeComponentInteraction } from './interaction-registry.js'; +import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js'; +import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js'; +import { publishShardMetrics, recordEventMetric } from './metrics.js'; +import { captureException, initSentry } from './sentry.js'; const isShard = process.argv.includes('--shard'); if (!isShard) { + initSentry('bot-manager'); startHealthServer(); const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, { token: env.BOT_TOKEN, @@ -75,8 +45,25 @@ if (!isShard) { shardArgs: ['--shard'] }); manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned')); + + // Register slash commands once from the manager (not once per shard). + try { + await runOncePerCluster( + 'nexumi:lock:register-commands', + 300, + async () => { + await registerCommands(); + }, + 'Command registration' + ); + } catch (error) { + logger.error({ error }, 'Failed to register application commands from manager'); + captureException(error, { phase: 'registerCommands' }); + } + await manager.spawn(); } else { + initSentry('bot-shard'); const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -99,7 +86,7 @@ if (!isShard) { const context: BotContext = { client, prisma, redis }; startWorkers(context); - await ensureRecurringJobs(); + registerLoggingEvents(context); registerWelcomeEvents(context); registerVerificationEvents(context); @@ -113,12 +100,24 @@ if (!isShard) { registerStatsEvents(context); client.on(Events.ClientReady, async () => { - logger.info({ tag: client.user?.tag }, 'Bot ready'); - try { - await registerCommands(); - } catch (error) { - logger.error({ error }, 'Failed to register application commands'); + logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready'); + + if (isPrimaryShard(client)) { + try { + await runOncePerCluster( + 'nexumi:lock:ensure-recurring-jobs', + 120, + async () => { + await ensureRecurringJobs(); + }, + 'Recurring job registration' + ); + } catch (error) { + logger.error({ error }, 'Failed to ensure recurring jobs'); + captureException(error, { phase: 'ensureRecurringJobs' }); + } } + try { const { applyBotPresence, publishBotStatus } = await import('./presence.js'); await applyBotPresence(context); @@ -126,18 +125,61 @@ if (!isShard) { } catch (error) { logger.warn({ error }, 'Failed to apply initial bot presence/status'); } + + try { + await publishShardMetrics(context.redis, client); + } catch (error) { + logger.warn({ error }, 'Failed to publish initial shard metrics'); + } }); + client.on(Events.GuildCreate, async (guild) => { + try { + if (await isGuildBlacklisted(context.prisma, guild.id)) { + logger.info({ guildId: guild.id }, 'Leaving blacklisted guild'); + await guild.leave(); + } + } catch (error) { + logger.error({ error, guildId: guild.id }, 'GuildCreate blacklist check failed'); + captureException(error, { guildId: guild.id }); + } + }); + + setInterval(() => { + publishShardMetrics(context.redis, client).catch(() => undefined); + }, 30_000); + client.on(Events.MessageCreate, async (message) => { try { + await recordEventMetric(context.redis); + if (message.author.bot) return; + if (await isUserBlacklisted(context.prisma, message.author.id)) return; + if (message.guildId && (await isGuildBlacklisted(context.prisma, message.guildId))) return; await handleAutoModMessage(context, message); } catch (error) { logger.error({ error, messageId: message.id }, 'AutoMod message handler failed'); + captureException(error, { messageId: message.id }); } }); client.on(Events.InteractionCreate, async (interaction: Interaction) => { try { + if ( + interaction.user && + (await isUserBlacklisted(context.prisma, interaction.user.id)) && + !interaction.isChatInputCommand() && + !interaction.isMessageContextMenuCommand() + ) { + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ + content: t(locale, 'generic.userBlacklisted'), + ephemeral: true + }); + } + return; + } + if (interaction.isChatInputCommand()) { await routeCommand(interaction, context); return; @@ -148,80 +190,23 @@ if (!isShard) { return; } - if (interaction.isButton() && isModerationConfirmation(interaction.customId)) { - await handleModerationConfirmation(interaction, context); - return; - } - - if (interaction.isButton() && isVerificationButton(interaction.customId)) { - await handleVerificationButton(interaction, context); - return; - } - - if (interaction.isButton() && isBlackjackButton(interaction.customId)) { - await handleBlackjackButton(interaction, context); - return; - } - - if (interaction.isButton() && isPollButton(interaction.customId)) { - await handlePollButton(interaction, context); - return; - } - - if (interaction.isButton() && isFunButton(interaction.customId)) { - await handleFunButton(interaction, context); - return; - } - - if (interaction.isButton() && isGiveawayButton(interaction.customId)) { - await handleGiveawayButton(interaction, context); - return; - } - if ( - (interaction.isButton() || - interaction.isStringSelectMenu() || - interaction.isModalSubmit()) && - isTicketInteraction(interaction.customId) + interaction.isButton() || + interaction.isStringSelectMenu() || + interaction.isUserSelectMenu() || + interaction.isRoleSelectMenu() || + interaction.isChannelSelectMenu() || + interaction.isMentionableSelectMenu() || + interaction.isModalSubmit() ) { - await handleTicketInteraction(interaction, context); - return; - } - - if ( - (interaction.isButton() || interaction.isStringSelectMenu()) && - isSelfRoleInteraction(interaction.customId) - ) { - await handleSelfRoleInteraction(interaction, context); - return; - } - - if (interaction.isButton() && isSuggestionButton(interaction.customId)) { - await handleSuggestionButton(interaction, context); - return; - } - - if (interaction.isButton() && isTempVoiceInteraction(interaction.customId)) { - await handleTempVoiceInteraction(interaction, context); - return; - } - - if (interaction.isModalSubmit() && isTempVoiceModal(interaction.customId)) { - await handleTempVoiceInteraction(interaction, context); - return; - } - - if (interaction.isButton() && isGuildBackupButton(interaction.customId)) { - await handleGuildBackupButton(interaction, context); - return; - } - - if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) { - await handleEmbedModal(interaction, context); - return; + await routeComponentInteraction(interaction, context); } } catch (error) { logger.error({ error }, 'Interaction handling failed'); + captureException(error, { + interactionType: interaction.type, + guildId: interaction.guildId + }); const locale = await getGuildLocale(context.prisma, interaction.guildId); const message = t(locale, 'generic.error'); if (interaction.isRepliable()) { diff --git a/apps/bot/src/interaction-registry.ts b/apps/bot/src/interaction-registry.ts new file mode 100644 index 0000000..9f8558f --- /dev/null +++ b/apps/bot/src/interaction-registry.ts @@ -0,0 +1,154 @@ +import type { + ButtonInteraction, + ChannelSelectMenuInteraction, + MentionableSelectMenuInteraction, + ModalSubmitInteraction, + RoleSelectMenuInteraction, + StringSelectMenuInteraction, + UserSelectMenuInteraction +} from 'discord.js'; +import type { BotContext } from './types.js'; +import { + handleModerationConfirmation, + isModerationConfirmation +} from './modules/moderation/confirmations.js'; +import { handleVerificationButton } from './modules/verification/handlers.js'; +import { isVerificationButton } from './modules/verification/service.js'; +import { + handleBlackjackButton, + isBlackjackButton +} from './modules/economy/blackjack-buttons.js'; +import { + handleEmbedModal, + handlePollButton, + isEmbedModal, + isPollButton +} from './modules/utility/index.js'; +import { handleFunButton, isFunButton } from './modules/fun/index.js'; +import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js'; +import { handleTicketInteraction, isTicketInteraction } from './modules/tickets/index.js'; +import { + handleSelfRoleInteraction, + isSelfRoleInteraction +} from './modules/selfroles/index.js'; +import { + handleSuggestionButton, + isSuggestionButton +} from './modules/suggestions/index.js'; +import { + handleTempVoiceInteraction, + isTempVoiceInteraction, + isTempVoiceModal +} from './modules/tempvoice/index.js'; +import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js'; +import { + handleComponentsV2Interaction, + isComponentsV2Interaction +} from './modules/components-v2/index.js'; + +type AnyComponentInteraction = + | ButtonInteraction + | StringSelectMenuInteraction + | UserSelectMenuInteraction + | RoleSelectMenuInteraction + | ChannelSelectMenuInteraction + | MentionableSelectMenuInteraction + | ModalSubmitInteraction; + +type InteractionHandler = { + match: (customId: string) => boolean; + handle: (interaction: AnyComponentInteraction, context: BotContext) => Promise; +}; + +function asButton(interaction: AnyComponentInteraction): ButtonInteraction { + return interaction as ButtonInteraction; +} + +function asModal(interaction: AnyComponentInteraction): ModalSubmitInteraction { + return interaction as ModalSubmitInteraction; +} + +const handlers: InteractionHandler[] = [ + { + match: isModerationConfirmation, + handle: (interaction, context) => handleModerationConfirmation(asButton(interaction), context) + }, + { + match: isVerificationButton, + handle: (interaction, context) => handleVerificationButton(asButton(interaction), context) + }, + { + match: isBlackjackButton, + handle: (interaction, context) => handleBlackjackButton(asButton(interaction), context) + }, + { + match: isPollButton, + handle: (interaction, context) => handlePollButton(asButton(interaction), context) + }, + { + match: isFunButton, + handle: (interaction, context) => handleFunButton(asButton(interaction), context) + }, + { + match: isGiveawayButton, + handle: (interaction, context) => handleGiveawayButton(asButton(interaction), context) + }, + { + match: isTicketInteraction, + handle: (interaction, context) => + handleTicketInteraction( + interaction as Parameters[0], + context + ) + }, + { + match: isSelfRoleInteraction, + handle: (interaction, context) => + handleSelfRoleInteraction( + interaction as Parameters[0], + context + ) + }, + { + match: isSuggestionButton, + handle: (interaction, context) => handleSuggestionButton(asButton(interaction), context) + }, + { + match: (customId) => isTempVoiceInteraction(customId) || isTempVoiceModal(customId), + handle: (interaction, context) => + handleTempVoiceInteraction( + interaction as Parameters[0], + context + ) + }, + { + match: isGuildBackupButton, + handle: (interaction, context) => handleGuildBackupButton(asButton(interaction), context) + }, + { + match: isEmbedModal, + handle: (interaction, context) => handleEmbedModal(asModal(interaction), context) + }, + { + match: isComponentsV2Interaction, + handle: (interaction, context) => + handleComponentsV2Interaction( + interaction as Parameters[0], + context + ) + } +]; + +export async function routeComponentInteraction( + interaction: AnyComponentInteraction, + context: BotContext +): Promise { + const customId = interaction.customId; + for (const handler of handlers) { + if (handler.match(customId)) { + await handler.handle(interaction, context); + return true; + } + } + return false; +} diff --git a/apps/bot/src/interaction-reply.ts b/apps/bot/src/interaction-reply.ts new file mode 100644 index 0000000..11120ae --- /dev/null +++ b/apps/bot/src/interaction-reply.ts @@ -0,0 +1,6 @@ +import { MessageFlags } from 'discord.js'; + +/** Discord.js v14+ prefers flags over the deprecated `ephemeral` option. */ +export function ephemeral(): { flags: typeof MessageFlags.Ephemeral } { + return { flags: MessageFlags.Ephemeral }; +} diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 8740b2b..aeb429c 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -8,16 +8,18 @@ import { logger } from './logger.js'; import type { BotContext } from './types.js'; import { env } from './env.js'; import { refreshPhishingDomains } from './modules/automod/filters.js'; +import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js'; import { completeVerification } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; import { expireSelfRole } from './modules/selfroles/service.js'; -import { endGiveaway, runGiveawayCreateJob } from './modules/giveaways/index.js'; +import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js'; import { closeInactiveTickets } from './modules/tickets/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { pollAllFeeds } from './modules/feeds/index.js'; import { sendScheduledMessage } from './modules/scheduler/index.js'; +import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js'; import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js'; import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js'; import { runPresenceRefreshJob } from './presence.js'; @@ -32,6 +34,7 @@ import { feedsQueueName, giveawayQueueName, guildBackupQueueName, + messagesQueueName, moderationQueueName, presenceQueue, presenceQueueName, @@ -63,6 +66,9 @@ type TempBanJob = { type VerificationCompleteJob = { guildId: string; userId: string; + ipHash?: string | null; + userAgentHash?: string | null; + captchaProvider?: string | null; }; type ReminderSendJob = { @@ -117,12 +123,70 @@ export function startWorkers(context: BotContext): Worker[] { return; } - const guild = await context.client.guilds.fetch(job.data.guildId); - const me = await guild.members.fetchMe(); - if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { - throw new Error('Missing BanMembers permission for temp ban expiration'); + const { guildId, userId, reason } = job.data; + const guild = context.client.guilds.cache.get(guildId); + if (!guild) { + if (!context.client.shard) { + throw new Error(`Guild ${guildId} not available for temp ban expiration`); + } + const results = await context.client.shard.broadcastEval( + async (c, payload) => { + const g = c.guilds.cache.get(payload.guildId); + if (!g) { + return false; + } + const me = await g.members.fetchMe(); + if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('Missing BanMembers permission for temp ban expiration'); + } + try { + await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired'); + } catch (error) { + const code = + typeof error === 'object' && error && 'code' in error + ? Number((error as { code: unknown }).code) + : 0; + // Unknown ban (10026) — already unbanned + if (code === 10026) { + return true; + } + throw error; + } + return true; + }, + { context: { guildId, userId, reason } } + ); + if (!results.some(Boolean)) { + throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`); + } + } else { + const me = await guild.members.fetchMe(); + if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('Missing BanMembers permission for temp ban expiration'); + } + try { + await guild.members.unban(userId, reason ?? 'Temporary ban expired'); + } catch (error) { + const code = + typeof error === 'object' && error && 'code' in error + ? Number((error as { code: unknown }).code) + : 0; + if (code !== 10026) { + throw error; + } + } } - await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired'); + + const { createCase } = await import('./modules/moderation/service.js'); + await createCase(context, { + guildId, + targetUserId: userId, + moderatorId: context.client.user?.id ?? 'system', + action: 'UNBAN', + reason: reason ?? 'Temporary ban expired', + source: 'job', + metadata: { tempBanExpire: true } + }); }, { connection: redis } ); @@ -149,11 +213,28 @@ export function startWorkers(context: BotContext): Worker[] { const automodWorker = new Worker( automodQueueName, async (job) => { - if (job.name !== 'refreshPhishingList') { + if (job.name === 'refreshPhishingList') { + const count = await refreshPhishingDomains(redis); + logger.info({ domainCount: count }, 'Phishing domain list refreshed'); return; } - const count = await refreshPhishingDomains(redis); - logger.info({ domainCount: count }, 'Phishing domain list refreshed'); + if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') { + const guildId = (job.data as { guildId?: string }).guildId; + if (!guildId) { + return; + } + const guild = await context.client.guilds.fetch(guildId).catch(() => null); + if (!guild) { + return; + } + if (job.name === 'deactivateLockdown') { + await deactivateLockdown(guild); + logger.info({ guildId }, 'Lockdown deactivated via dashboard'); + return; + } + await activateLockdown(guild); + logger.info({ guildId }, 'Lockdown activated via dashboard'); + } }, { connection: redis } ); @@ -168,7 +249,11 @@ export function startWorkers(context: BotContext): Worker[] { if (job.name !== 'verificationComplete') { return; } - await completeVerification(context, job.data.guildId, job.data.userId); + await completeVerification(context, job.data.guildId, job.data.userId, { + ipHash: job.data.ipHash, + userAgentHash: job.data.userAgentHash, + captchaProvider: job.data.captchaProvider + }); }, { connection: redis } ); @@ -218,7 +303,17 @@ export function startWorkers(context: BotContext): Worker[] { giveawayQueueName, async (job) => { if (job.name === 'giveawayEnd') { - await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); + try { + await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); + } catch (error) { + if ( + error instanceof GiveawayError && + (error.code === 'already_ended' || error.code === 'not_found') + ) { + return; + } + throw error; + } return; } if (job.name === 'giveawayCreate') { @@ -281,6 +376,20 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Schedule job failed'); }); + const messagesWorker = new Worker( + messagesQueueName, + async (job) => { + if (job.name !== 'dashboardMessageSend') { + return; + } + return sendDashboardMessage(context, job.data); + }, + { connection: redis } + ); + messagesWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Dashboard message send job failed'); + }); + const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>( guildBackupQueueName, async (job) => { @@ -349,6 +458,7 @@ export function startWorkers(context: BotContext): Worker[] { statsWorker, feedsWorker, scheduleWorker, + messagesWorker, guildBackupWorker, suggestionsWorker, presenceWorker, diff --git a/apps/bot/src/lib/components-v2-payload.ts b/apps/bot/src/lib/components-v2-payload.ts new file mode 100644 index 0000000..fee1f2f --- /dev/null +++ b/apps/bot/src/lib/components-v2-payload.ts @@ -0,0 +1,426 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelSelectMenuBuilder, + ContainerBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + MentionableSelectMenuBuilder, + MessageFlags, + RoleSelectMenuBuilder, + SectionBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + StringSelectMenuBuilder, + TextDisplayBuilder, + ThumbnailBuilder, + UserSelectMenuBuilder, + type MessageActionRowComponentBuilder, + type MessageCreateOptions +} from 'discord.js'; +import { + encodeComponentCustomId, + mapComponentsV2TextFields, + MessageComponentsV2Schema, + type ComponentButton, + type ComponentButtonStyle, + type ComponentSelect, + type ComponentV2Node, + type ComponentV2Source, + type MessageComponentsV2, + type SeparatorSpacing +} from '@nexumi/shared'; + +function isHttpUrl(value: string | undefined): value is string { + if (!value) { + return false; + } + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +const BUTTON_STYLE_MAP: Record = { + Primary: ButtonStyle.Primary, + Secondary: ButtonStyle.Secondary, + Success: ButtonStyle.Success, + Danger: ButtonStyle.Danger, + Link: ButtonStyle.Link +}; + +const SPACING_MAP: Record = { + Small: SeparatorSpacingSize.Small, + Large: SeparatorSpacingSize.Large +}; + +export interface BuildComponentsV2Options { + source: ComponentV2Source; + ref: string; + renderText?: (value: string) => string; +} + +function buildButton( + button: ComponentButton, + options: BuildComponentsV2Options +): ButtonBuilder | null { + const builder = new ButtonBuilder() + .setLabel(button.label.slice(0, 80)) + .setDisabled(Boolean(button.disabled)); + + if (button.emoji) { + builder.setEmoji(button.emoji); + } + + if (button.style === 'Link') { + if (!isHttpUrl(button.url)) { + return null; + } + builder.setStyle(ButtonStyle.Link).setURL(button.url); + return builder; + } + + if (!button.actionId) { + return null; + } + + builder + .setStyle(BUTTON_STYLE_MAP[button.style] ?? ButtonStyle.Secondary) + .setCustomId(encodeComponentCustomId(options.source, options.ref, button.actionId)); + return builder; +} + +function buildSelect( + select: ComponentSelect, + options: BuildComponentsV2Options +): MessageActionRowComponentBuilder | null { + const placeholder = select.placeholder?.slice(0, 150); + const minValues = select.minValues; + const maxValues = select.maxValues; + + if (select.type === 'string_select') { + const menu = new StringSelectMenuBuilder() + .setCustomId( + encodeComponentCustomId( + options.source, + options.ref, + select.actionId ?? select.id ?? 'select' + ) + ) + .setDisabled(Boolean(select.disabled)) + .addOptions( + select.options.slice(0, 25).map((option) => { + const entry: { + label: string; + value: string; + description?: string; + emoji?: string; + } = { + label: option.label.slice(0, 100), + value: option.value.slice(0, 100) + }; + if (option.description) { + entry.description = option.description.slice(0, 100); + } + if (option.emoji) { + entry.emoji = option.emoji; + } + return entry; + }) + ); + if (placeholder) { + menu.setPlaceholder(placeholder); + } + if (minValues !== undefined) { + menu.setMinValues(minValues); + } + if (maxValues !== undefined) { + menu.setMaxValues(maxValues); + } + return menu; + } + + const customId = encodeComponentCustomId(options.source, options.ref, select.actionId); + let menu: + | UserSelectMenuBuilder + | RoleSelectMenuBuilder + | ChannelSelectMenuBuilder + | MentionableSelectMenuBuilder; + + switch (select.type) { + case 'user_select': + menu = new UserSelectMenuBuilder().setCustomId(customId); + break; + case 'role_select': + menu = new RoleSelectMenuBuilder().setCustomId(customId); + break; + case 'channel_select': + menu = new ChannelSelectMenuBuilder().setCustomId(customId); + break; + case 'mentionable_select': + menu = new MentionableSelectMenuBuilder().setCustomId(customId); + break; + default: + return null; + } + + menu.setDisabled(Boolean(select.disabled)); + if (placeholder) { + menu.setPlaceholder(placeholder); + } + if (minValues !== undefined) { + menu.setMinValues(minValues); + } + if (maxValues !== undefined) { + menu.setMaxValues(maxValues); + } + return menu; +} + +function buildThumbnail(url: string, description?: string, spoiler?: boolean): ThumbnailBuilder | null { + if (!isHttpUrl(url)) { + return null; + } + const thumb = new ThumbnailBuilder().setURL(url); + if (description) { + thumb.setDescription(description.slice(0, 1024)); + } + if (spoiler) { + thumb.setSpoiler(true); + } + return thumb; +} + +type TopLevelBuilder = + | ContainerBuilder + | TextDisplayBuilder + | SeparatorBuilder + | MediaGalleryBuilder + | SectionBuilder + | ActionRowBuilder; + +function buildNode(node: ComponentV2Node, options: BuildComponentsV2Options): TopLevelBuilder | null { + switch (node.type) { + case 'text_display': + return new TextDisplayBuilder().setContent(node.content.slice(0, 4000)); + case 'separator': { + const sep = new SeparatorBuilder(); + if (node.divider !== undefined) { + sep.setDivider(node.divider); + } + if (node.spacing) { + sep.setSpacing(SPACING_MAP[node.spacing]); + } + return sep; + } + case 'media_gallery': { + const gallery = new MediaGalleryBuilder(); + for (const item of node.items) { + if (!isHttpUrl(item.url)) { + continue; + } + const media = new MediaGalleryItemBuilder().setURL(item.url); + if (item.description) { + media.setDescription(item.description.slice(0, 1024)); + } + if (item.spoiler) { + media.setSpoiler(true); + } + gallery.addItems(media); + } + return gallery; + } + case 'section': { + const section = new SectionBuilder(); + const texts = Array.isArray(node.text) ? node.text : [node.text]; + for (const text of texts.slice(0, 3)) { + section.addTextDisplayComponents(new TextDisplayBuilder().setContent(text.slice(0, 4000))); + } + if (node.accessory.type === 'thumbnail') { + const thumb = buildThumbnail( + node.accessory.url, + node.accessory.description, + node.accessory.spoiler + ); + if (thumb) { + section.setThumbnailAccessory(thumb); + } + } else { + const button = buildButton(node.accessory, options); + if (button) { + section.setButtonAccessory(button); + } + } + return section; + } + case 'action_row': { + const row = new ActionRowBuilder(); + for (const child of node.components) { + if (child.type === 'button') { + const button = buildButton(child, options); + if (button) { + row.addComponents(button); + } + } else { + const select = buildSelect(child, options); + if (select) { + row.addComponents(select); + } + } + } + return row.components.length > 0 ? row : null; + } + case 'button': { + const button = buildButton(node, options); + if (!button) { + return null; + } + return new ActionRowBuilder().addComponents(button); + } + case 'string_select': + case 'user_select': + case 'role_select': + case 'channel_select': + case 'mentionable_select': { + const select = buildSelect(node, options); + if (!select) { + return null; + } + return new ActionRowBuilder().addComponents(select); + } + case 'thumbnail': + // Thumbnails must be section accessories; skip top-level. + return null; + case 'container': { + const container = new ContainerBuilder(); + if (node.accentColor !== undefined) { + container.setAccentColor(node.accentColor); + } + if (node.spoiler) { + container.setSpoiler(true); + } + for (const child of node.components) { + switch (child.type) { + case 'text_display': + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(child.content.slice(0, 4000)) + ); + break; + case 'separator': { + const sep = new SeparatorBuilder(); + if (child.divider !== undefined) { + sep.setDivider(child.divider); + } + if (child.spacing) { + sep.setSpacing(SPACING_MAP[child.spacing]); + } + container.addSeparatorComponents(sep); + break; + } + case 'media_gallery': { + const gallery = new MediaGalleryBuilder(); + for (const item of child.items) { + if (!isHttpUrl(item.url)) { + continue; + } + const media = new MediaGalleryItemBuilder().setURL(item.url); + if (item.description) { + media.setDescription(item.description.slice(0, 1024)); + } + if (item.spoiler) { + media.setSpoiler(true); + } + gallery.addItems(media); + } + container.addMediaGalleryComponents(gallery); + break; + } + case 'section': { + const section = buildNode(child, options); + if (section instanceof SectionBuilder) { + container.addSectionComponents(section); + } + break; + } + case 'action_row': + case 'button': + case 'string_select': + case 'user_select': + case 'role_select': + case 'channel_select': + case 'mentionable_select': { + const row = buildNode(child, options); + if (row instanceof ActionRowBuilder) { + container.addActionRowComponents(row); + } + break; + } + case 'container': + for (const nested of child.components) { + const sibling = buildNode(nested, options); + if (sibling instanceof TextDisplayBuilder) { + container.addTextDisplayComponents(sibling); + } else if (sibling instanceof SeparatorBuilder) { + container.addSeparatorComponents(sibling); + } else if (sibling instanceof MediaGalleryBuilder) { + container.addMediaGalleryComponents(sibling); + } else if (sibling instanceof SectionBuilder) { + container.addSectionComponents(sibling); + } else if (sibling instanceof ActionRowBuilder) { + container.addActionRowComponents(sibling); + } + } + break; + default: + break; + } + } + return container; + } + default: + return null; + } +} + +/** + * Builds a discord.js Components V2 message payload from shared JSON. + * Sets MessageFlags.IsComponentsV2; never includes content/embeds. + */ +export function buildComponentsV2Payload( + raw: MessageComponentsV2 | null | undefined, + options: BuildComponentsV2Options +): MessageCreateOptions | null { + const parsed = MessageComponentsV2Schema.safeParse(raw); + if (!parsed.success) { + return null; + } + + const data = options.renderText + ? mapComponentsV2TextFields(parsed.data, options.renderText) + : parsed.data; + + const components: TopLevelBuilder[] = []; + for (const node of data.components) { + const built = buildNode(node, options); + if (built) { + components.push(built); + } + } + + if (components.length === 0) { + return null; + } + + return { + components, + flags: MessageFlags.IsComponentsV2 + }; +} + +export function parseStoredComponentsV2(raw: unknown): MessageComponentsV2 | null { + const parsed = MessageComponentsV2Schema.safeParse(raw); + return parsed.success ? parsed.data : null; +} diff --git a/apps/bot/src/lib/embed-payload.ts b/apps/bot/src/lib/embed-payload.ts new file mode 100644 index 0000000..4160743 --- /dev/null +++ b/apps/bot/src/lib/embed-payload.ts @@ -0,0 +1,89 @@ +import { EmbedBuilder } from 'discord.js'; +import { + embedHasContent, + mapEmbedTextFields, + type WelcomeEmbed +} from '@nexumi/shared'; + +function isHttpUrl(value: string | undefined): value is string { + if (!value) { + return false; + } + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +export interface ApplyEmbedOptions { + /** Called for every text/URL field before applying to the builder. */ + renderText: (value: string) => string; + /** + * Used when `thumbnailUrl` is unset (Welcome default: member avatar). + * Pass `null` to force no thumbnail. + */ + defaultThumbnailUrl?: string | null; +} + +/** + * Builds a discord.js EmbedBuilder from our shared WelcomeEmbed payload. + * Invalid/non-http URLs are skipped so bad dashboard input never crashes send. + */ +export function buildEmbedFromPayload( + raw: WelcomeEmbed | null | undefined, + options: ApplyEmbedOptions +): EmbedBuilder | null { + if (!raw || (!embedHasContent(raw) && raw.color === undefined)) { + return null; + } + + const data = mapEmbedTextFields(raw, options.renderText); + const embed = new EmbedBuilder(); + + if (data.color !== undefined) { + embed.setColor(data.color); + } + if (data.title) { + embed.setTitle(data.title); + } + if (data.description) { + embed.setDescription(data.description); + } + if (isHttpUrl(data.url)) { + embed.setURL(data.url); + } + if (data.authorName) { + embed.setAuthor({ + name: data.authorName, + iconURL: isHttpUrl(data.authorIconUrl) ? data.authorIconUrl : undefined, + url: isHttpUrl(data.authorUrl) ? data.authorUrl : undefined + }); + } + + const thumbnail = + isHttpUrl(data.thumbnailUrl) + ? data.thumbnailUrl + : options.defaultThumbnailUrl === null + ? undefined + : options.defaultThumbnailUrl; + if (isHttpUrl(thumbnail)) { + embed.setThumbnail(thumbnail); + } + + if (isHttpUrl(data.imageUrl)) { + embed.setImage(data.imageUrl); + } + if (data.footerText) { + embed.setFooter({ + text: data.footerText, + iconURL: isHttpUrl(data.footerIconUrl) ? data.footerIconUrl : undefined + }); + } + if (data.timestamp) { + embed.setTimestamp(new Date()); + } + + return embed; +} diff --git a/apps/bot/src/metrics.ts b/apps/bot/src/metrics.ts new file mode 100644 index 0000000..f060350 --- /dev/null +++ b/apps/bot/src/metrics.ts @@ -0,0 +1,129 @@ +import type { Redis } from 'ioredis'; +import type { Client } from 'discord.js'; + +const KEY_COMMANDS_TOTAL = 'nexumi:metrics:commands_total'; +const KEY_COMMANDS_ERRORS = 'nexumi:metrics:commands_errors'; +const KEY_COMMAND_DURATION_SUM = 'nexumi:metrics:command_duration_ms_sum'; +const KEY_COMMAND_DURATION_COUNT = 'nexumi:metrics:command_duration_ms_count'; +const KEY_EVENTS_TOTAL = 'nexumi:metrics:events_total'; +const shardKey = (id: number) => `nexumi:metrics:shard:${id}`; + +export async function recordCommandMetric( + redis: Redis, + opts: { ok: boolean; durationMs: number } +): Promise { + const multi = redis.multi(); + multi.incr(KEY_COMMANDS_TOTAL); + if (!opts.ok) { + multi.incr(KEY_COMMANDS_ERRORS); + } + multi.incrbyfloat(KEY_COMMAND_DURATION_SUM, opts.durationMs); + multi.incr(KEY_COMMAND_DURATION_COUNT); + await multi.exec(); +} + +export async function recordEventMetric(redis: Redis): Promise { + await redis.incr(KEY_EVENTS_TOTAL); +} + +export async function publishShardMetrics(redis: Redis, client: Client): Promise { + const ids = client.shard?.ids ?? [0]; + const ping = client.ws.ping; + const guilds = client.guilds.cache.size; + const payload = JSON.stringify({ + ping, + guilds, + updatedAt: Date.now() + }); + await Promise.all(ids.map((id) => redis.set(shardKey(id), payload, 'EX', 120))); +} + +export type MetricsSnapshot = { + up: 1; + commandsTotal: number; + commandsErrors: number; + commandDurationSumMs: number; + commandDurationCount: number; + eventsTotal: number; + shards: Array<{ id: number; ping: number; guilds: number; updatedAt: number }>; + queueWaiting?: number; +}; + +export async function collectMetricsSnapshot(redis: Redis): Promise { + const [commandsTotal, commandsErrors, durationSum, durationCount, eventsTotal, shardKeys] = + await Promise.all([ + redis.get(KEY_COMMANDS_TOTAL), + redis.get(KEY_COMMANDS_ERRORS), + redis.get(KEY_COMMAND_DURATION_SUM), + redis.get(KEY_COMMAND_DURATION_COUNT), + redis.get(KEY_EVENTS_TOTAL), + redis.keys('nexumi:metrics:shard:*') + ]); + + const shards: MetricsSnapshot['shards'] = []; + if (shardKeys.length > 0) { + const values = await redis.mget(...shardKeys); + for (let i = 0; i < shardKeys.length; i++) { + const id = Number(shardKeys[i]!.replace('nexumi:metrics:shard:', '')); + const raw = values[i]; + if (!raw || Number.isNaN(id)) continue; + try { + const parsed = JSON.parse(raw) as { ping: number; guilds: number; updatedAt: number }; + shards.push({ id, ping: parsed.ping, guilds: parsed.guilds, updatedAt: parsed.updatedAt }); + } catch { + // ignore malformed + } + } + shards.sort((a, b) => a.id - b.id); + } + + return { + up: 1, + commandsTotal: Number(commandsTotal ?? 0), + commandsErrors: Number(commandsErrors ?? 0), + commandDurationSumMs: Number(durationSum ?? 0), + commandDurationCount: Number(durationCount ?? 0), + eventsTotal: Number(eventsTotal ?? 0), + shards + }; +} + +export function formatPrometheus(snapshot: MetricsSnapshot): string { + const lines: string[] = [ + '# HELP nexumi_up 1 if the metrics endpoint is healthy', + '# TYPE nexumi_up gauge', + `nexumi_up ${snapshot.up}`, + '# HELP nexumi_commands_total Total slash commands handled', + '# TYPE nexumi_commands_total counter', + `nexumi_commands_total ${snapshot.commandsTotal}`, + '# HELP nexumi_commands_errors_total Total slash command failures', + '# TYPE nexumi_commands_errors_total counter', + `nexumi_commands_errors_total ${snapshot.commandsErrors}`, + '# HELP nexumi_command_duration_ms_sum Sum of command durations in milliseconds', + '# TYPE nexumi_command_duration_ms_sum counter', + `nexumi_command_duration_ms_sum ${snapshot.commandDurationSumMs}`, + '# HELP nexumi_command_duration_ms_count Count of timed commands', + '# TYPE nexumi_command_duration_ms_count counter', + `nexumi_command_duration_ms_count ${snapshot.commandDurationCount}`, + '# HELP nexumi_events_total Approximate event throughput counter', + '# TYPE nexumi_events_total counter', + `nexumi_events_total ${snapshot.eventsTotal}`, + '# HELP nexumi_guilds Guild count per shard', + '# TYPE nexumi_guilds gauge', + '# HELP nexumi_shard_ping_ms Websocket heartbeat latency per shard', + '# TYPE nexumi_shard_ping_ms gauge' + ]; + + for (const shard of snapshot.shards) { + lines.push(`nexumi_guilds{shard="${shard.id}"} ${shard.guilds}`); + lines.push(`nexumi_shard_ping_ms{shard="${shard.id}"} ${shard.ping}`); + } + + if (snapshot.queueWaiting !== undefined) { + lines.push('# HELP nexumi_queue_waiting Approximate waiting jobs across known queues'); + lines.push('# TYPE nexumi_queue_waiting gauge'); + lines.push(`nexumi_queue_waiting ${snapshot.queueWaiting}`); + } + + return `${lines.join('\n')}\n`; +} diff --git a/apps/bot/src/module-gates.test.ts b/apps/bot/src/module-gates.test.ts new file mode 100644 index 0000000..d68729f --- /dev/null +++ b/apps/bot/src/module-gates.test.ts @@ -0,0 +1,33 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { moduleForCommand } from './module-gates.js'; + +describe('moduleForCommand', () => { + it('maps moderation commands', () => { + expect(moduleForCommand('ban')).toBe('moderation'); + expect(moduleForCommand('warn')).toBe('moderation'); + expect(moduleForCommand('case')).toBe('moderation'); + }); + + it('maps redis-only and prisma modules', () => { + expect(moduleForCommand('giveaway')).toBe('giveaways'); + expect(moduleForCommand('tag')).toBe('tags'); + expect(moduleForCommand('backup')).toBe('guildbackup'); + expect(moduleForCommand('economy' as string)).toBeNull(); + expect(moduleForCommand('balance')).toBe('economy'); + }); + + it('leaves core commands unmapped', () => { + expect(moduleForCommand('help')).toBeNull(); + expect(moduleForCommand('info')).toBeNull(); + expect(moduleForCommand('gdpr')).toBeNull(); + }); +}); + +describe('feature flag rollout hashing', () => { + it('is stable for the same guild/key pair', () => { + const a = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100; + const b = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100; + expect(a).toBe(b); + }); +}); diff --git a/apps/bot/src/module-gates.ts b/apps/bot/src/module-gates.ts new file mode 100644 index 0000000..672d65e --- /dev/null +++ b/apps/bot/src/module-gates.ts @@ -0,0 +1,275 @@ +import type { DashboardModuleId } from '@nexumi/shared'; +import type { PrismaClient } from '@prisma/client'; +import type { Redis } from 'ioredis'; +import { createHash } from 'node:crypto'; + +const REDIS_ONLY_MODULES = new Set([ + 'giveaways', + 'tags', + 'selfroles', + 'feeds', + 'scheduler', + 'guildbackup', + 'messages' +]); + +/** Slash command name → dashboard module. Core/GDPR/utility (except gated) omit. */ +const COMMAND_MODULE_MAP: Record = { + ban: 'moderation', + unban: 'moderation', + kick: 'moderation', + timeout: 'moderation', + untimeout: 'moderation', + warn: 'moderation', + purge: 'moderation', + slowmode: 'moderation', + lock: 'moderation', + unlock: 'moderation', + nick: 'moderation', + case: 'moderation', + modnote: 'moderation', + automod: 'automod', + welcome: 'welcome', + verify: 'verification', + rank: 'leveling', + leaderboard: 'leveling', + xp: 'leveling', + balance: 'economy', + daily: 'economy', + weekly: 'economy', + work: 'economy', + pay: 'economy', + gamble: 'economy', + slots: 'economy', + blackjack: 'economy', + coinflip: 'economy', + shop: 'economy', + inventory: 'economy', + eco: 'economy', + '8ball': 'fun', + dice: 'fun', + rps: 'fun', + choose: 'fun', + trivia: 'fun', + tictactoe: 'fun', + connect4: 'fun', + hangman: 'fun', + meme: 'fun', + cat: 'fun', + dog: 'fun', + giveaway: 'giveaways', + ticket: 'tickets', + selfroles: 'selfroles', + tag: 'tags', + starboard: 'starboard', + suggest: 'suggestions', + suggestion: 'suggestions', + birthday: 'birthdays', + voice: 'tempvoice', + invites: 'stats', + stats: 'stats', + feeds: 'feeds', + schedule: 'scheduler', + announce: 'scheduler', + backup: 'guildbackup', + embed: 'messages' +}; + +function redisModulesKey(guildId: string): string { + return `dashboard:modules:${guildId}`; +} + +function funConfigKey(guildId: string): string { + return `fun:config:${guildId}`; +} + +function hashPercent(input: string): number { + const digest = createHash('sha256').update(input).digest(); + return digest.readUInt32BE(0) % 100; +} + +export function moduleForCommand(commandName: string): DashboardModuleId | null { + return COMMAND_MODULE_MAP[commandName] ?? null; +} + +export async function isFeatureFlagEnabled( + prisma: PrismaClient, + key: string, + guildId: string | null +): Promise { + const flag = await prisma.featureFlag.findUnique({ where: { key } }); + if (!flag) { + return true; + } + if (guildId && flag.whitelistGuildIds.includes(guildId)) { + return true; + } + if (!flag.enabled) { + return false; + } + if (flag.rolloutPercent >= 100) { + return true; + } + if (flag.rolloutPercent <= 0 || !guildId) { + return false; + } + return hashPercent(`${flag.key}:${guildId}`) < flag.rolloutPercent; +} + +async function isRedisModuleEnabled( + redis: Redis, + guildId: string, + moduleId: DashboardModuleId +): Promise { + const value = await redis.hget(redisModulesKey(guildId), moduleId); + if (value === null) { + return true; + } + return value === 'true'; +} + +async function isFunEnabled(redis: Redis, guildId: string): Promise { + const raw = await redis.get(funConfigKey(guildId)); + if (!raw) { + return true; + } + try { + const parsed = JSON.parse(raw) as { enabled?: boolean }; + return parsed.enabled ?? true; + } catch { + return true; + } +} + +export async function isModuleEnabled( + prisma: PrismaClient, + redis: Redis, + guildId: string, + moduleId: DashboardModuleId +): Promise { + const flagOk = await isFeatureFlagEnabled(prisma, moduleId, guildId); + if (!flagOk) { + return false; + } + + if (REDIS_ONLY_MODULES.has(moduleId)) { + return isRedisModuleEnabled(redis, guildId, moduleId); + } + + switch (moduleId) { + case 'moderation': { + const settings = await prisma.guildSettings.findUnique({ + where: { guildId }, + select: { moderationEnabled: true } + }); + return settings?.moderationEnabled ?? true; + } + case 'automod': { + const config = await prisma.autoModConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'logging': { + const config = await prisma.loggingConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'welcome': { + const config = await prisma.welcomeConfig.findUnique({ + where: { guildId }, + select: { welcomeEnabled: true, leaveEnabled: true } + }); + if (!config) { + return true; + } + return Boolean(config.welcomeEnabled || config.leaveEnabled); + } + case 'verification': { + const config = await prisma.verificationConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + case 'leveling': { + const config = await prisma.levelingConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'economy': { + const config = await prisma.economyConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'fun': + return isFunEnabled(redis, guildId); + case 'tickets': { + const config = await prisma.ticketConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'starboard': { + const config = await prisma.starboardConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + case 'suggestions': { + const config = await prisma.suggestionConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'birthdays': { + const config = await prisma.birthdayConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'tempvoice': { + const config = await prisma.tempVoiceConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + case 'stats': { + const config = await prisma.statsConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + default: + return true; + } +} + +export async function isUserBlacklisted(prisma: PrismaClient, userId: string): Promise { + const row = await prisma.globalUserBlacklist.findUnique({ + where: { userId }, + select: { id: true } + }); + return Boolean(row); +} + +export async function isGuildBlacklisted(prisma: PrismaClient, guildId: string): Promise { + const row = await prisma.guildBlacklist.findUnique({ + where: { guildId }, + select: { id: true } + }); + return Boolean(row); +} diff --git a/apps/bot/src/modules/automod/actions.ts b/apps/bot/src/modules/automod/actions.ts index 1ea2695..df2188d 100644 --- a/apps/bot/src/modules/automod/actions.ts +++ b/apps/bot/src/modules/automod/actions.ts @@ -204,6 +204,16 @@ export async function handleAntiRaidJoin( return; } + // While VERIFY raid-response is active, keep gating new joins with the unverified role. + if (config.lockdownActive && config.antiRaidAction === 'VERIFY') { + await applyRaidVerifyRole(context, member); + return; + } + + if (config.lockdownActive) { + return; + } + const key = `automod:raid:${member.guild.id}`; const count = await context.redis.incr(key); if (count === 1) { @@ -214,10 +224,6 @@ export async function handleAntiRaidJoin( return; } - if (config.lockdownActive) { - return; - } - await context.prisma.autoModConfig.update({ where: { guildId: member.guild.id }, data: { lockdownActive: true } @@ -225,16 +231,45 @@ export async function handleAntiRaidJoin( if (config.antiRaidAction === 'LOCKDOWN') { await activateLockdown(member.guild); + } else if (config.antiRaidAction === 'VERIFY') { + await applyRaidVerifyRole(context, member); } const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel; if (alertChannel && alertChannel.isTextBased()) { await (alertChannel as TextChannel).send( - `Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s.` + `Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s (${config.antiRaidAction}).` ); } } +async function applyRaidVerifyRole(context: BotContext, member: GuildMember): Promise { + const verification = await context.prisma.verificationConfig.findUnique({ + where: { guildId: member.guild.id } + }); + const roleId = verification?.unverifiedRoleId; + if (!roleId) { + logger.warn( + { guildId: member.guild.id }, + 'Anti-raid VERIFY triggered but no unverified role is configured' + ); + return; + } + const me = member.guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return; + } + const role = member.guild.roles.cache.get(roleId); + if (!role || role.position >= me.roles.highest.position) { + return; + } + if (!member.roles.cache.has(roleId)) { + await member.roles.add(roleId).catch((error) => { + logger.warn({ error, memberId: member.id }, 'Failed to apply unverified role during raid verify'); + }); + } +} + export async function handleAntiNukeAction( context: BotContext, guildId: string, diff --git a/apps/bot/src/modules/automod/service.ts b/apps/bot/src/modules/automod/service.ts index b70bf97..1600281 100644 --- a/apps/bot/src/modules/automod/service.ts +++ b/apps/bot/src/modules/automod/service.ts @@ -18,12 +18,17 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) { export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) { await ensureGuild(prisma, guildId); - const existing = await prisma.autoModRule.count({ where: { guildId } }); - if (existing > 0) { + const existing = await prisma.autoModRule.findMany({ + where: { guildId }, + select: { ruleType: true } + }); + const have = new Set(existing.map((row) => row.ruleType)); + const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType)); + if (missing.length === 0) { return; } await prisma.autoModRule.createMany({ - data: DEFAULT_AUTOMOD_RULES.map((rule) => ({ + data: missing.map((rule) => ({ guildId, ruleType: rule.ruleType, action: rule.action, diff --git a/apps/bot/src/modules/components-v2/index.ts b/apps/bot/src/modules/components-v2/index.ts new file mode 100644 index 0000000..8b8b2b7 --- /dev/null +++ b/apps/bot/src/modules/components-v2/index.ts @@ -0,0 +1,4 @@ +export { + handleComponentsV2Interaction, + isComponentsV2Interaction +} from './interactions.js'; diff --git a/apps/bot/src/modules/components-v2/interactions.ts b/apps/bot/src/modules/components-v2/interactions.ts new file mode 100644 index 0000000..6df750d --- /dev/null +++ b/apps/bot/src/modules/components-v2/interactions.ts @@ -0,0 +1,335 @@ +import { + PermissionFlagsBits, + type ButtonInteraction, + type GuildMember, + type Interaction, + type MessageComponentInteraction, + type RoleSelectMenuInteraction, + type StringSelectMenuInteraction +} from 'discord.js'; +import { + decodeComponentCustomId, + mapComponentsV2TextFields, + parseMessageComponentsV2, + t, + type ComponentAction, + type ComponentV2Source, + type Locale, + type MessageComponentsV2 +} from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { logger } from '../../logger.js'; + +export function isComponentsV2Interaction(customId: string): boolean { + return decodeComponentCustomId(customId) !== null; +} + +async function loadPayload( + context: BotContext, + source: ComponentV2Source, + ref: string, + guildId: string +): Promise { + switch (source) { + case 'w': { + const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } }); + if (!config || config.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(config.welcomeComponents); + } + case 'l': { + const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } }); + if (!config || config.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(config.leaveComponents); + } + case 't': { + const tag = await context.prisma.tag.findUnique({ where: { id: ref } }); + if (!tag || tag.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(tag.components); + } + case 's': { + const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: ref } }); + if (!schedule || schedule.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(schedule.components); + } + case 'm': { + const binding = await context.prisma.componentMessageBinding.findUnique({ where: { id: ref } }); + if (!binding || binding.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(binding.payload); + } + default: + return null; + } +} + +function resolveStringSelectAction( + payload: MessageComponentsV2, + actionId: string, + selectedValues: string[] +): ComponentAction | null { + const direct = payload.actions[actionId]; + if (direct && selectedValues.length === 0) { + return direct; + } + + // Prefer per-option actionId when present. + const findOptionAction = (nodes: MessageComponentsV2['components']): ComponentAction | null => { + for (const node of nodes) { + if (node.type === 'string_select') { + for (const option of node.options) { + if (selectedValues.includes(option.value) && option.actionId) { + return payload.actions[option.actionId] ?? null; + } + } + } + if (node.type === 'action_row') { + const nested = findOptionAction(node.components as MessageComponentsV2['components']); + if (nested) { + return nested; + } + } + if (node.type === 'container') { + const nested = findOptionAction(node.components); + if (nested) { + return nested; + } + } + } + return null; + }; + + return findOptionAction(payload.components) ?? payload.actions[actionId] ?? null; +} + +async function assertRoleAssignable(member: GuildMember, roleId: string, locale: Locale): Promise { + const me = member.guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return t(locale, 'componentsV2.error.botMissingManageRoles'); + } + const role = member.guild.roles.cache.get(roleId) ?? (await member.guild.roles.fetch(roleId).catch(() => null)); + if (!role) { + return t(locale, 'componentsV2.error.roleNotFound'); + } + if (role.managed) { + return t(locale, 'componentsV2.error.roleManaged'); + } + if (role.position >= me.roles.highest.position) { + return t(locale, 'componentsV2.error.roleHierarchy'); + } + return null; +} + +async function executeAction( + interaction: MessageComponentInteraction, + action: ComponentAction, + locale: Locale, + selectedRoleIds: string[] +): Promise { + const member = interaction.member; + if (!member || !interaction.guild || typeof member === 'string') { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return; + } + + const guildMember = + 'roles' in member && typeof (member as GuildMember).roles?.add === 'function' + ? (member as GuildMember) + : await interaction.guild.members.fetch(interaction.user.id); + + switch (action.type) { + case 'NONE': + await interaction.deferUpdate(); + return; + case 'EPHEMERAL_REPLY': + await interaction.reply({ content: action.content ?? '—', ephemeral: true }); + return; + case 'PUBLIC_REPLY': + await interaction.reply({ content: action.content ?? '—' }); + return; + case 'ASSIGN_ROLE': { + const err = await assertRoleAssignable(guildMember, action.roleId!, locale); + if (err) { + await interaction.reply({ content: err, ephemeral: true }); + return; + } + await guildMember.roles.add(action.roleId!); + logger.info( + { guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'ASSIGN_ROLE' }, + 'Components V2 role action' + ); + await interaction.reply({ + content: t(locale, 'componentsV2.role.assigned'), + ephemeral: true + }); + return; + } + case 'REMOVE_ROLE': { + const err = await assertRoleAssignable(guildMember, action.roleId!, locale); + if (err) { + await interaction.reply({ content: err, ephemeral: true }); + return; + } + await guildMember.roles.remove(action.roleId!); + logger.info( + { guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'REMOVE_ROLE' }, + 'Components V2 role action' + ); + await interaction.reply({ + content: t(locale, 'componentsV2.role.removed'), + ephemeral: true + }); + return; + } + case 'TOGGLE_ROLE': { + const err = await assertRoleAssignable(guildMember, action.roleId!, locale); + if (err) { + await interaction.reply({ content: err, ephemeral: true }); + return; + } + const hasRole = guildMember.roles.cache.has(action.roleId!); + if (hasRole) { + await guildMember.roles.remove(action.roleId!); + await interaction.reply({ + content: t(locale, 'componentsV2.role.removed'), + ephemeral: true + }); + } else { + await guildMember.roles.add(action.roleId!); + await interaction.reply({ + content: t(locale, 'componentsV2.role.assigned'), + ephemeral: true + }); + } + logger.info( + { + guildId: interaction.guildId, + userId: interaction.user.id, + roleId: action.roleId, + action: 'TOGGLE_ROLE', + removed: hasRole + }, + 'Components V2 role action' + ); + return; + } + case 'ASSIGN_SELECTED_ROLES': { + if (selectedRoleIds.length === 0) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.noRolesSelected'), + ephemeral: true + }); + return; + } + const assigned: string[] = []; + for (const roleId of selectedRoleIds) { + const err = await assertRoleAssignable(guildMember, roleId, locale); + if (err) { + continue; + } + await guildMember.roles.add(roleId); + assigned.push(roleId); + } + logger.info( + { guildId: interaction.guildId, userId: interaction.user.id, roleIds: assigned, action: 'ASSIGN_SELECTED_ROLES' }, + 'Components V2 role action' + ); + if (assigned.length === 0) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.roleNotFound'), + ephemeral: true + }); + return; + } + await interaction.reply({ + content: t(locale, 'componentsV2.role.assignedSelected'), + ephemeral: true + }); + return; + } + default: + await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true }); + } +} + +export async function handleComponentsV2Interaction( + interaction: Interaction, + context: BotContext +): Promise { + if ( + !interaction.isButton() && + !interaction.isStringSelectMenu() && + !interaction.isUserSelectMenu() && + !interaction.isRoleSelectMenu() && + !interaction.isChannelSelectMenu() && + !interaction.isMentionableSelectMenu() + ) { + return; + } + + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!interaction.inGuild() || !interaction.guildId) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return; + } + + const decoded = decodeComponentCustomId(interaction.customId); + if (!decoded) { + return; + } + + const payload = await loadPayload(context, decoded.source, decoded.ref, interaction.guildId); + if (!payload) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.notFound'), + ephemeral: true + }); + return; + } + + let action: ComponentAction | null = null; + let selectedRoleIds: string[] = []; + + if (interaction.isStringSelectMenu()) { + action = resolveStringSelectAction(payload, decoded.actionId, interaction.values); + } else if (interaction.isRoleSelectMenu()) { + action = payload.actions[decoded.actionId] ?? null; + selectedRoleIds = (interaction as RoleSelectMenuInteraction).values; + } else if (interaction.isButton()) { + action = payload.actions[decoded.actionId] ?? null; + } else { + action = payload.actions[decoded.actionId] ?? null; + } + + if (!action) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.actionMissing'), + ephemeral: true + }); + return; + } + + // Apply placeholders for reply content when possible. + const renderedActions = mapComponentsV2TextFields(payload, (value) => value).actions; + const rendered = renderedActions[decoded.actionId] ?? action; + + await executeAction( + interaction as MessageComponentInteraction, + interaction.isStringSelectMenu() ? action : rendered, + locale, + selectedRoleIds + ); +} + +// Re-export for typing convenience in index routing. +export type ComponentsV2ButtonInteraction = ButtonInteraction; +export type ComponentsV2StringSelectInteraction = StringSelectMenuInteraction; diff --git a/apps/bot/src/modules/components-v2/send.ts b/apps/bot/src/modules/components-v2/send.ts new file mode 100644 index 0000000..c82da90 --- /dev/null +++ b/apps/bot/src/modules/components-v2/send.ts @@ -0,0 +1,120 @@ +import { + PermissionFlagsBits, + type GuildTextBasedChannel, + type Message, + type TextChannel +} from 'discord.js'; +import { + DashboardMessageSendSchema, + MessageComponentsV2Schema, + WelcomeEmbedSchema, + type MessageComponentsV2 +} from '@nexumi/shared'; +import { ensureGuild } from '../../guild.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import { logger } from '../../logger.js'; +import type { BotContext } from '../../types.js'; + +export class MessageSendError extends Error { + constructor(public readonly code: string) { + super(code); + } +} + +async function assertSendableChannel( + context: BotContext, + channelId: string +): Promise { + const channel = await context.client.channels.fetch(channelId); + if (!channel?.isTextBased() || channel.isDMBased()) { + throw new MessageSendError('invalid_channel'); + } + + const me = channel.guild.members.me; + if ( + !me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) || + !me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel) + ) { + throw new MessageSendError('channel_unavailable'); + } + + return channel; +} + +export type DashboardMessageSendJob = { + guildId: string; + channelId: string; + createdById: string; + mode: 'EMBED' | 'COMPONENTS_V2'; + embed?: unknown; + components?: unknown; + bindingId?: string | null; +}; + +export type DashboardMessageSendResult = { + bindingId: string | null; + messageId: string | null; + channelId: string; +}; + +export async function sendDashboardMessage( + context: BotContext, + job: DashboardMessageSendJob +): Promise { + await ensureGuild(context.prisma, job.guildId); + const input = DashboardMessageSendSchema.parse({ + channelId: job.channelId, + mode: job.mode, + embed: job.embed ?? null, + components: job.components ?? null + }); + + const channel = await assertSendableChannel(context, input.channelId); + + if (input.mode === 'EMBED') { + const embedData = WelcomeEmbedSchema.parse(input.embed); + const embed = buildEmbedFromPayload(embedData, { renderText: (value) => value }); + if (!embed) { + throw new MessageSendError('content_required'); + } + const message = await (channel as TextChannel).send({ embeds: [embed] }); + return { bindingId: null, messageId: message.id, channelId: channel.id }; + } + + const components = MessageComponentsV2Schema.parse(input.components) as MessageComponentsV2; + let bindingId = job.bindingId ?? null; + + if (!bindingId) { + const binding = await context.prisma.componentMessageBinding.create({ + data: { + guildId: job.guildId, + channelId: input.channelId, + payload: components, + createdById: job.createdById + } + }); + bindingId = binding.id; + } + + const payload = buildComponentsV2Payload(components, { + source: 'm', + ref: bindingId + }); + if (!payload) { + throw new MessageSendError('content_required'); + } + + const message: Message = await (channel as TextChannel).send(payload); + await context.prisma.componentMessageBinding.update({ + where: { id: bindingId }, + data: { messageId: message.id } + }); + + logger.info( + { guildId: job.guildId, channelId: channel.id, messageId: message.id, bindingId }, + 'Dashboard Components V2 message sent' + ); + + return { bindingId, messageId: message.id, channelId: channel.id }; +} diff --git a/apps/bot/src/modules/giveaways/index.ts b/apps/bot/src/modules/giveaways/index.ts index a27c919..6423d4d 100644 --- a/apps/bot/src/modules/giveaways/index.ts +++ b/apps/bot/src/modules/giveaways/index.ts @@ -1,3 +1,3 @@ export { giveawayCommands } from './commands.js'; export { isGiveawayButton, handleGiveawayButton } from './buttons.js'; -export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob } from './service.js'; +export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js'; diff --git a/apps/bot/src/modules/giveaways/service.ts b/apps/bot/src/modules/giveaways/service.ts index 161a395..449bd0a 100644 --- a/apps/bot/src/modules/giveaways/service.ts +++ b/apps/bot/src/modules/giveaways/service.ts @@ -59,6 +59,11 @@ function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] { export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder { const ended = giveaway.endedAt !== null; + const endsAtUnix = Math.floor(giveaway.endsAt.getTime() / 1000); + // Discord does not parse timestamps in embed footers — only in + // description/fields/content. Keep relative + absolute for clarity. + const endsAtText = ` ()`; + const embed = new EmbedBuilder() .setTitle(t(locale, 'giveaway.embed.title')) .setColor(ended ? 0x6b7280 : 0x6366f1) @@ -86,19 +91,17 @@ export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): Emb ); embed.setFooter({ text: t(locale, 'giveaway.embed.ended') }); } else if (giveaway.paused) { - embed.setDescription(t(locale, 'giveaway.embed.paused')); - embed.setFooter({ - text: tf(locale, 'giveaway.embed.endsAt', { - time: `` - }) - }); + embed.setDescription( + `${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', { + time: endsAtText + })}` + ); } else { - embed.setDescription(t(locale, 'giveaway.embed.active')); - embed.setFooter({ - text: tf(locale, 'giveaway.embed.endsAt', { - time: `` - }) - }); + embed.setDescription( + `${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', { + time: endsAtText + })}` + ); } const requirements = requirementLines(locale, giveaway); @@ -313,10 +316,13 @@ async function dmWinners( } } -export async function endGiveaway(context: BotContext, giveawayId: string): Promise { +export async function endGiveaway(context: BotContext, giveawayId: string): Promise { const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); - if (!giveaway || giveaway.endedAt) { - return; + if (!giveaway) { + throw new GiveawayError('not_found'); + } + if (giveaway.endedAt) { + throw new GiveawayError('already_ended'); } await cancelGiveawayJob(giveawayId); @@ -338,6 +344,7 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom if (winners.length > 0) { await dmWinners(context, updated, winners, locale); } + return updated; } export async function rerollGiveaway( diff --git a/apps/bot/src/modules/guildbackup/commands.ts b/apps/bot/src/modules/guildbackup/commands.ts index 433c930..633e8e6 100644 --- a/apps/bot/src/modules/guildbackup/commands.ts +++ b/apps/bot/src/modules/guildbackup/commands.ts @@ -154,7 +154,7 @@ const backupCommand: SlashCommand = { return; } - await promptDeleteConfirmation(interaction, locale, backupId); + await promptDeleteConfirmation(interaction, context, locale, backupId); return; } @@ -195,7 +195,7 @@ const backupCommand: SlashCommand = { return; } - await promptRestoreConfirmation(interaction, locale, backupId); + await promptRestoreConfirmation(interaction, context, locale, backupId); } } }; diff --git a/apps/bot/src/modules/guildbackup/confirmations.ts b/apps/bot/src/modules/guildbackup/confirmations.ts index 1b046e2..c3a4d73 100644 --- a/apps/bot/src/modules/guildbackup/confirmations.ts +++ b/apps/bot/src/modules/guildbackup/confirmations.ts @@ -9,6 +9,12 @@ import { randomUUID } from 'node:crypto'; import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; +import { + deleteConfirmation, + getConfirmation, + setConfirmation, + takeConfirmation +} from '../../confirmation-store.js'; import { deleteGuildBackup, enqueueGuildBackupRestore, @@ -22,7 +28,8 @@ import { const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:'; const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:'; const CANCEL_PREFIX = 'gbackup:cancel:'; -const TTL_MS = 120_000; +const NAMESPACE = 'guildbackup'; +const TTL_SECONDS = 120; type PendingDelete = { action: 'delete'; @@ -30,7 +37,6 @@ type PendingDelete = { userId: string; guildId: string; locale: Locale; - expiresAt: number; }; type PendingRestore = { @@ -40,22 +46,10 @@ type PendingRestore = { userId: string; guildId: string; locale: Locale; - expiresAt: number; }; type PendingEntry = PendingDelete | PendingRestore; -const pending = new Map(); - -function pruneExpired(): void { - const now = Date.now(); - for (const [token, entry] of pending) { - if (entry.expiresAt <= now) { - pending.delete(token); - } - } -} - function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder[] { return [ new ActionRowBuilder().addComponents( @@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action export async function promptDeleteConfirmation( interaction: ChatInputCommandInteraction, + context: BotContext, locale: Locale, backupId: string ): Promise { - pruneExpired(); const token = randomUUID(); - pending.set(token, { - action: 'delete', - backupId, - userId: interaction.user.id, - guildId: interaction.guildId!, - locale, - expiresAt: Date.now() + TTL_MS - }); + await setConfirmation( + context.redis, + NAMESPACE, + token, + { + action: 'delete', + backupId, + userId: interaction.user.id, + guildId: interaction.guildId!, + locale + } satisfies PendingDelete, + TTL_SECONDS + ); await interaction.reply({ content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }), @@ -96,20 +95,25 @@ export async function promptDeleteConfirmation( export async function promptRestoreConfirmation( interaction: ChatInputCommandInteraction, + context: BotContext, locale: Locale, backupId: string ): Promise { - pruneExpired(); const token = randomUUID(); - pending.set(token, { - action: 'restore', - step: 1, - backupId, - userId: interaction.user.id, - guildId: interaction.guildId!, - locale, - expiresAt: Date.now() + TTL_MS - }); + await setConfirmation( + context.redis, + NAMESPACE, + token, + { + action: 'restore', + step: 1, + backupId, + userId: interaction.user.id, + guildId: interaction.guildId!, + locale + } satisfies PendingRestore, + TTL_SECONDS + ); await interaction.reply({ content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }), @@ -122,8 +126,6 @@ export async function handleGuildBackupButton( interaction: ButtonInteraction, context: BotContext ): Promise { - pruneExpired(); - const customId = interaction.customId; const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX); const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX); @@ -141,48 +143,57 @@ export async function handleGuildBackupButton( : CANCEL_PREFIX.length ); - const entry = pending.get(token); - if (!entry) { + const peeked = await getConfirmation(context.redis, NAMESPACE, token); + if (!peeked) { const locale = await getGuildLocale(context.prisma, interaction.guildId); await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true }); return; } - if (interaction.user.id !== entry.userId) { + if (interaction.user.id !== peeked.userId) { await interaction.reply({ - content: t(entry.locale, 'guildbackup.confirm.unauthorized'), + content: t(peeked.locale, 'guildbackup.confirm.unauthorized'), ephemeral: true }); return; } if (isCancel) { - pending.delete(token); + await deleteConfirmation(context.redis, NAMESPACE, token); await interaction.update({ - content: t(entry.locale, 'guildbackup.confirm.cancelled'), + content: t(peeked.locale, 'guildbackup.confirm.cancelled'), components: [] }); return; } - if (entry.action === 'delete') { - pending.delete(token); + if (peeked.action === 'delete') { + const entry = await takeConfirmation(context.redis, NAMESPACE, token); + if (!entry) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true }); + return; + } await executeDelete(interaction, context, entry); return; } - if (entry.step === 1) { - entry.step = 2; - entry.expiresAt = Date.now() + TTL_MS; - pending.set(token, entry); + if (peeked.step === 1) { + const next: PendingRestore = { ...peeked, step: 2 }; + await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS); await interaction.update({ - content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }), - components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX) + content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }), + components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX) }); return; } - pending.delete(token); + const entry = await takeConfirmation(context.redis, NAMESPACE, token); + if (!entry) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true }); + return; + } await executeRestore(interaction, context, entry); } diff --git a/apps/bot/src/modules/leveling/service.ts b/apps/bot/src/modules/leveling/service.ts index 960e52a..b18cf91 100644 --- a/apps/bot/src/modules/leveling/service.ts +++ b/apps/bot/src/modules/leveling/service.ts @@ -273,7 +273,7 @@ export async function applyLevelRewards( } } } else { - const reward = rewards.find((entry) => entry.level === newLevel); + const reward = [...rewards].reverse().find((entry) => entry.level <= newLevel); const removable = rewards .map((entry) => entry.roleId) .filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId)); diff --git a/apps/bot/src/modules/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts index f68524a..1210990 100644 --- a/apps/bot/src/modules/moderation/actions.ts +++ b/apps/bot/src/modules/moderation/actions.ts @@ -1,8 +1,9 @@ import { TextChannel, type ButtonInteraction } from 'discord.js'; -import { type Locale, t } from '@nexumi/shared'; +import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; +import { assertBannableForConfirm } from '../../hierarchy.js'; import { createCase, scheduleTempBanExpire } from './service.js'; -import { parseDuration } from './duration.js'; +import { tryParseDuration } from './duration.js'; type BanPayload = { userId: string; @@ -36,8 +37,24 @@ export async function executeBan( return; } + if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) { + return; + } + + let durationMs: number | null = null; + if (payload.duration) { + durationMs = tryParseDuration(payload.duration); + if (durationMs === null || durationMs <= 0) { + await interaction.update({ + content: t(locale, 'moderation.duration.invalid'), + components: [] + }); + return; + } + } + await guild.members.ban(payload.userId, { reason: payload.reason }); - await createCase(context, { + const modCase = await createCase(context, { guildId: guild.id, targetUserId: payload.userId, moderatorId: interaction.user.id, @@ -47,21 +64,17 @@ export async function executeBan( source: 'button_confirmation', metadata: { confirmed: true, - duration: payload.duration + duration: payload.duration, + durationMs } }); - if (payload.duration) { - await scheduleTempBanExpire( - guild.id, - payload.userId, - parseDuration(payload.duration), - payload.reason - ); + if (durationMs !== null) { + await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason); } await interaction.update({ - content: t(locale, 'moderation.success'), + content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }), components: [] }); } @@ -97,7 +110,7 @@ export async function executePurge( deletedCount = deleted.size; } - await createCase(context, { + const modCase = await createCase(context, { guildId: guild.id, targetUserId: interaction.user.id, moderatorId: interaction.user.id, @@ -122,7 +135,10 @@ export async function executePurge( }); await interaction.update({ - content: t(locale, 'moderation.success'), + content: tf(locale, 'moderation.purge.done', { + deletedCount, + caseNumber: modCase.caseNumber + }), components: [] }); } @@ -143,7 +159,7 @@ export async function executeCaseDelete( where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } } }); - if (!existing) { + if (!existing || existing.deletedAt) { await interaction.update({ content: t(locale, 'moderation.case.notFound'), components: [] @@ -156,7 +172,7 @@ export async function executeCaseDelete( data: { deletedAt: new Date() } }); - await createCase(context, { + const modCase = await createCase(context, { guildId, targetUserId: existing.targetUserId, moderatorId: interaction.user.id, @@ -172,7 +188,7 @@ export async function executeCaseDelete( }); await interaction.update({ - content: t(locale, 'moderation.success'), + content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }), components: [] }); } diff --git a/apps/bot/src/modules/moderation/command-definitions.ts b/apps/bot/src/modules/moderation/command-definitions.ts index 5842a32..1122075 100644 --- a/apps/bot/src/modules/moderation/command-definitions.ts +++ b/apps/bot/src/modules/moderation/command-definitions.ts @@ -1,4 +1,5 @@ import { + PermissionFlagsBits, SlashCommandBuilder, type SlashCommandStringOption, type SlashCommandUserOption @@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) { } export const banCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('ban'), + new SlashCommandBuilder() + .setName('ban') + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), 'moderation.ban.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) @@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription( ); export const unbanCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('unban'), + new SlashCommandBuilder() + .setName('unban') + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), 'moderation.unban.description' ) .addStringOption((o) => @@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription( .addStringOption((o) => reasonOpt(o)); export const kickCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('kick'), + new SlashCommandBuilder() + .setName('kick') + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), 'moderation.kick.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) .addStringOption((o) => reasonOpt(o)); export const timeoutCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('timeout'), + new SlashCommandBuilder() + .setName('timeout') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.timeout.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) @@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription( .addStringOption((o) => reasonOpt(o)); export const untimeoutCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('untimeout'), + new SlashCommandBuilder() + .setName('untimeout') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.untimeout.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) .addStringOption((o) => reasonOpt(o)); export const warnCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('warn'), + new SlashCommandBuilder() + .setName('warn') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.warn.description' ) .addSubcommand((s) => @@ -123,7 +136,9 @@ export const warnCommandData = applyCommandDescription( ); export const purgeCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('purge'), + new SlashCommandBuilder() + .setName('purge') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), 'moderation.purge.description' ) .addIntegerOption((o) => @@ -138,24 +153,38 @@ export const purgeCommandData = applyCommandDescription( .addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex')); export const slowmodeCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('slowmode'), + new SlashCommandBuilder() + .setName('slowmode') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), 'moderation.slowmode.description' ).addIntegerOption((o) => applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds') ); export const lockCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('lock'), + new SlashCommandBuilder() + .setName('lock') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) + .addBooleanOption((o) => + applyOptionDescription(o.setName('server'), 'moderation.lock.options.server') + ), 'moderation.lock.description' ); export const unlockCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('unlock'), + new SlashCommandBuilder() + .setName('unlock') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) + .addBooleanOption((o) => + applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server') + ), 'moderation.unlock.description' ); export const nickCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('nick'), + new SlashCommandBuilder() + .setName('nick') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames), 'moderation.nick.description' ) .addSubcommand((s) => @@ -172,7 +201,9 @@ export const nickCommandData = applyCommandDescription( ); export const caseCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('case'), + new SlashCommandBuilder() + .setName('case') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.case.description' ) .addSubcommand((s) => @@ -196,7 +227,9 @@ export const caseCommandData = applyCommandDescription( ); export const modNoteCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('modnote'), + new SlashCommandBuilder() + .setName('modnote') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.modnote.description' ) .addSubcommand((s) => diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 3ec072d..c42cc21 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -2,14 +2,14 @@ import { ChannelType, PermissionFlagsBits, TextChannel, - type ChatInputCommandInteraction, - type GuildMember + type ChatInputCommandInteraction } from 'discord.js'; import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { requirePermission } from '../../permissions.js'; +import { assertModerableMember } from '../../hierarchy.js'; import { applyWarnEscalation, createCase } from './service.js'; -import { parseDuration } from './duration.js'; +import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js'; import { getGuildLocale } from '../../i18n.js'; import { promptDestructiveConfirmation } from './confirmations.js'; import { @@ -51,6 +51,17 @@ async function ensureMod( return requirePermission(interaction, permission, locale); } +async function replySuccessCase( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en', + caseNumber: number +): Promise { + await interaction.reply({ + content: tf(locale, 'moderation.success.case', { caseNumber }), + ephemeral: true + }); +} + const banCommand: SlashCommand = { data: banCommandData, async execute(interaction, context) { @@ -59,7 +70,22 @@ const banCommand: SlashCommand = { const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); const duration = interaction.options.getString('duration'); - await promptDestructiveConfirmation(interaction, locale, { + + if (duration) { + const durationMs = tryParseDuration(duration); + if (durationMs === null || durationMs <= 0) { + await interaction.reply({ + content: t(locale, 'moderation.duration.invalid'), + ephemeral: true + }); + return; + } + } + + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'ban'); + if (!hierarchy.ok) return; + + await promptDestructiveConfirmation(interaction, context, locale, { action: 'ban', payload: { userId: user.id, @@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = { const userId = interaction.options.getString('user_id', true); const reason = reasonFrom(interaction, locale); await interaction.guild!.members.unban(userId, reason); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: userId, moderatorId: interaction.user.id, @@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = { action: 'UNBAN', reason }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -97,9 +123,10 @@ const kickCommand: SlashCommand = { if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); - const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember; - await member.kick(reason); - await createCase(context, { + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick'); + if (!hierarchy.ok || !hierarchy.member) return; + await hierarchy.member.kick(reason); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -107,7 +134,7 @@ const kickCommand: SlashCommand = { action: 'KICK', reason }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -117,11 +144,21 @@ const timeoutCommand: SlashCommand = { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const user = interaction.options.getUser('user', true); - const duration = parseDuration(interaction.options.getString('duration', true)); + let duration: number; + try { + duration = parseTimeoutDuration(interaction.options.getString('duration', true)); + } catch { + await interaction.reply({ + content: t(locale, 'moderation.duration.invalidTimeout'), + ephemeral: true + }); + return; + } const reason = reasonFrom(interaction, locale); - const member = await interaction.guild!.members.fetch(user.id); - await member.timeout(duration, reason); - await createCase(context, { + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout'); + if (!hierarchy.ok || !hierarchy.member) return; + await hierarchy.member.timeout(duration, reason); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = { reason, metadata: { durationMs: duration } }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -141,9 +178,10 @@ const untimeoutCommand: SlashCommand = { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); - const member = await interaction.guild!.members.fetch(user.id); - await member.timeout(null, reason); - await createCase(context, { + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout'); + if (!hierarchy.ok || !hierarchy.member) return; + await hierarchy.member.timeout(null, reason); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = { action: 'UN_TIMEOUT', reason }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -166,7 +204,19 @@ const warnCommand: SlashCommand = { const action = interaction.options.getString('action', true); const durationInput = interaction.options.getString('duration'); const reason = interaction.options.getString('reason'); - const durationMs = durationInput ? parseDuration(durationInput) : null; + let durationMs: number | null = null; + if (durationInput) { + try { + durationMs = + action === 'TIMEOUT' ? parseTimeoutDuration(durationInput) : parseDuration(durationInput); + } catch { + await interaction.reply({ + content: t(locale, 'moderation.duration.invalid'), + ephemeral: true + }); + return; + } + } if (action === 'TIMEOUT' && !durationMs) { await interaction.reply({ content: t(locale, 'moderation.escalation.durationRequired'), @@ -267,7 +317,8 @@ const warnCommand: SlashCommand = { interaction, context, user.id, - interaction.user.id + interaction.user.id, + locale ); await interaction.reply({ content: escalationResult @@ -300,8 +351,18 @@ const warnCommand: SlashCommand = { if (sub === 'remove') { const warningId = interaction.options.getString('warning_id', true); - const warning = await context.prisma.warning.delete({ where: { id: warningId } }); - await createCase(context, { + const warning = await context.prisma.warning.findFirst({ + where: { id: warningId, guildId: interaction.guildId! } + }); + if (!warning) { + await interaction.reply({ + content: t(locale, 'moderation.warning.notFound'), + ephemeral: true + }); + return; + } + await context.prisma.warning.delete({ where: { id: warning.id } }); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: warning.userId, moderatorId: interaction.user.id, @@ -309,13 +370,15 @@ const warnCommand: SlashCommand = { action: 'WARN_REMOVE', reason: `Warning ${warningId} removed` }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); return; } const user = interaction.options.getUser('user', true); - await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } }); - await createCase(context, { + await context.prisma.warning.deleteMany({ + where: { guildId: interaction.guildId!, userId: user.id } + }); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -323,7 +386,7 @@ const warnCommand: SlashCommand = { action: 'WARN_CLEAR', reason: 'Warnings cleared' }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -352,7 +415,7 @@ const purgeCommand: SlashCommand = { return; } - await promptDestructiveConfirmation(interaction, locale, { + await promptDestructiveConfirmation(interaction, context, locale, { action: 'purge', payload: { channelId: interaction.channel.id, @@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = { if (interaction.channel instanceof TextChannel) { await interaction.channel.setRateLimitPerUser(seconds); } - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, @@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = { action: 'SLOWMODE', reason: `Set slowmode to ${seconds}s` }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -393,20 +456,33 @@ const lockCommand: SlashCommand = { async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return; - if (interaction.channel?.type === ChannelType.GuildText) { - await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { + const serverWide = interaction.options.getBoolean('server') ?? false; + const guild = interaction.guild!; + + if (serverWide) { + for (const channel of guild.channels.cache.values()) { + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + continue; + } + await channel.permissionOverwrites.edit(guild.roles.everyone, { + SendMessages: false + }); + } + } else if (interaction.channel?.type === ChannelType.GuildText) { + await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: false }); } - await createCase(context, { + + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'LOCK', - reason: 'Channel locked' + reason: serverWide ? 'Server locked' : 'Channel locked' }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -415,20 +491,33 @@ const unlockCommand: SlashCommand = { async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return; - if (interaction.channel?.type === ChannelType.GuildText) { - await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { + const serverWide = interaction.options.getBoolean('server') ?? false; + const guild = interaction.guild!; + + if (serverWide) { + for (const channel of guild.channels.cache.values()) { + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + continue; + } + await channel.permissionOverwrites.edit(guild.roles.everyone, { + SendMessages: null + }); + } + } else if (interaction.channel?.type === ChannelType.GuildText) { + await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: null }); } - await createCase(context, { + + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'UNLOCK', - reason: 'Channel unlocked' + reason: serverWide ? 'Server unlocked' : 'Channel unlocked' }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -439,11 +528,13 @@ const nickCommand: SlashCommand = { if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return; const sub = interaction.options.getSubcommand(); const user = interaction.options.getUser('user', true); - const member = await interaction.guild!.members.fetch(user.id); + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'nick'); + if (!hierarchy.ok || !hierarchy.member) return; + const member = hierarchy.member; if (sub === 'set') { const nickname = interaction.options.getString('nickname', true); await member.setNickname(nickname); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -451,9 +542,10 @@ const nickCommand: SlashCommand = { action: 'NICK_SET', reason: nickname }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } else { await member.setNickname(null); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -461,8 +553,8 @@ const nickCommand: SlashCommand = { action: 'NICK_RESET', reason: 'Nickname reset' }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); } }; @@ -474,8 +566,12 @@ const caseCommand: SlashCommand = { const sub = interaction.options.getSubcommand(); const caseNumber = interaction.options.getInteger('id', true); if (sub === 'view') { - const found = await context.prisma.case.findUnique({ - where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } } + const found = await context.prisma.case.findFirst({ + where: { + guildId: interaction.guildId!, + caseNumber, + deletedAt: null + } }); await interaction.reply({ content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'), @@ -485,14 +581,31 @@ const caseCommand: SlashCommand = { } if (sub === 'edit') { const reason = interaction.options.getString('reason', true); + const existing = await context.prisma.case.findFirst({ + where: { + guildId: interaction.guildId!, + caseNumber, + deletedAt: null + } + }); + if (!existing) { + await interaction.reply({ + content: t(locale, 'moderation.case.notFound'), + ephemeral: true + }); + return; + } await context.prisma.case.update({ - where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, + where: { id: existing.id }, data: { reason } }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await interaction.reply({ + content: tf(locale, 'moderation.success.case', { caseNumber }), + ephemeral: true + }); return; } - await promptDestructiveConfirmation(interaction, locale, { + await promptDestructiveConfirmation(interaction, context, locale, { action: 'case_delete', payload: { caseNumber } }); @@ -517,7 +630,7 @@ const modNoteCommand: SlashCommand = { note } }); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -525,7 +638,7 @@ const modNoteCommand: SlashCommand = { action: 'MODNOTE_ADD', reason: note }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); return; } const notes = await context.prisma.modNote.findMany({ diff --git a/apps/bot/src/modules/moderation/confirmations.ts b/apps/bot/src/modules/moderation/confirmations.ts index eab1f0f..cdf6537 100644 --- a/apps/bot/src/modules/moderation/confirmations.ts +++ b/apps/bot/src/modules/moderation/confirmations.ts @@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto'; import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; +import { + deleteConfirmation, + getConfirmation, + setConfirmation, + takeConfirmation +} from '../../confirmation-store.js'; import { executeBan, executeCaseDelete, executePurge } from './actions.js'; const CONFIRM_PREFIX = 'mod:confirm:'; const CANCEL_PREFIX = 'mod:cancel:'; -const TTL_MS = 60_000; +const NAMESPACE = 'moderation'; +const TTL_SECONDS = 60; type BanPayload = { userId: string; @@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & { moderatorId: string; guildId: string; locale: Locale; - expiresAt: number; }; -const pending = new Map(); - -function pruneExpired(): void { - const now = Date.now(); - for (const [token, entry] of pending) { - if (entry.expiresAt <= now) { - pending.delete(token); - } - } -} - function confirmationMessage(locale: Locale, entry: PendingConfirmation): string { if (entry.action === 'ban') { return tf(locale, 'moderation.confirm.ban', { @@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder { - pruneExpired(); const token = randomUUID(); - pending.set(token, { - ...entry, - moderatorId: interaction.user.id, - guildId: interaction.guildId!, - locale, - expiresAt: Date.now() + TTL_MS - }); + await setConfirmation( + context.redis, + NAMESPACE, + token, + { + ...entry, + moderatorId: interaction.user.id, + guildId: interaction.guildId!, + locale + } satisfies PendingEntry, + TTL_SECONDS + ); await interaction.reply({ content: confirmationMessage(locale, entry), @@ -113,8 +113,6 @@ export async function handleModerationConfirmation( interaction: ButtonInteraction, context: BotContext ): Promise { - pruneExpired(); - const customId = interaction.customId; const isConfirm = customId.startsWith(CONFIRM_PREFIX); const isCancel = customId.startsWith(CANCEL_PREFIX); @@ -123,31 +121,37 @@ export async function handleModerationConfirmation( } const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length); - const entry = pending.get(token); - if (!entry) { + const peeked = await getConfirmation(context.redis, NAMESPACE, token); + if (!peeked) { const locale = await getGuildLocale(context.prisma, interaction.guildId); await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true }); return; } - if (interaction.user.id !== entry.moderatorId) { + if (interaction.user.id !== peeked.moderatorId) { await interaction.reply({ - content: t(entry.locale, 'moderation.confirm.unauthorized'), + content: t(peeked.locale, 'moderation.confirm.unauthorized'), ephemeral: true }); return; } - pending.delete(token); - if (isCancel) { + await deleteConfirmation(context.redis, NAMESPACE, token); await interaction.update({ - content: t(entry.locale, 'moderation.confirm.cancelled'), + content: t(peeked.locale, 'moderation.confirm.cancelled'), components: [] }); return; } + const entry = await takeConfirmation(context.redis, NAMESPACE, token); + if (!entry) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true }); + return; + } + if (entry.action === 'ban') { await executeBan(interaction, context, entry.locale, entry.payload); return; diff --git a/apps/bot/src/modules/moderation/duration.test.ts b/apps/bot/src/modules/moderation/duration.test.ts index b275305..9abf571 100644 --- a/apps/bot/src/modules/moderation/duration.test.ts +++ b/apps/bot/src/modules/moderation/duration.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { parseDuration } from './duration.js'; +import { + MAX_TIMEOUT_MS, + parseDuration, + parseTimeoutDuration, + tryParseDuration +} from './duration.js'; describe('parseDuration', () => { it('parses minutes', () => { @@ -14,3 +19,27 @@ describe('parseDuration', () => { expect(() => parseDuration('tomorrow')).toThrow(); }); }); + +describe('parseTimeoutDuration', () => { + it('accepts durations within Discord limit', () => { + expect(parseTimeoutDuration('1d')).toBe(86_400_000); + }); + + it('rejects durations over 28 days', () => { + expect(() => parseTimeoutDuration('30d')).toThrow(/28/); + }); + + it('exposes MAX_TIMEOUT_MS as 28 days', () => { + expect(MAX_TIMEOUT_MS).toBe(28 * 24 * 60 * 60 * 1000); + }); +}); + +describe('tryParseDuration', () => { + it('returns null for invalid input', () => { + expect(tryParseDuration('nope')).toBeNull(); + }); + + it('returns ms for valid input', () => { + expect(tryParseDuration('10s')).toBe(10_000); + }); +}); diff --git a/apps/bot/src/modules/moderation/duration.ts b/apps/bot/src/modules/moderation/duration.ts index abb55cd..d8efbb7 100644 --- a/apps/bot/src/modules/moderation/duration.ts +++ b/apps/bot/src/modules/moderation/duration.ts @@ -1,10 +1,39 @@ +export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000; + +export class DurationParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'DurationParseError'; + } +} + export function parseDuration(input: string): number { const m = input.match(/^(\d+)([smhd])$/i); if (!m) { - throw new Error('Duration must be like 30m, 12h, 7d'); + throw new DurationParseError('Duration must be like 30m, 12h, 7d'); } const amount = Number(m[1]); - const unit = m[2].toLowerCase(); + const unit = m[2]!.toLowerCase(); const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000; return amount * factor; } + +/** Parse a duration and enforce Discord's 28-day timeout maximum. */ +export function parseTimeoutDuration(input: string): number { + const ms = parseDuration(input); + if (ms <= 0) { + throw new DurationParseError('Duration must be positive'); + } + if (ms > MAX_TIMEOUT_MS) { + throw new DurationParseError('Timeout duration cannot exceed 28 days'); + } + return ms; +} + +export function tryParseDuration(input: string): number | null { + try { + return parseDuration(input); + } catch { + return null; + } +} diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index c257764..d7c28ac 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import type { Prisma } from '@prisma/client'; -import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared'; +import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared'; +import { MAX_TIMEOUT_MS } from './duration.js'; type CreateCaseInput = { guildId: string; @@ -22,39 +23,69 @@ export async function createCase(context: BotContext, input: CreateCaseInput) { create: { id: input.guildId } }); - const lastCase = await context.prisma.case.findFirst({ - where: { guildId: input.guildId }, - orderBy: { caseNumber: 'desc' } - }); - const metadata = buildCaseMetadata({ source: input.source ?? 'slash_command', channelId: input.channelId, extras: input.metadata }); - return context.prisma.case.create({ - data: { - guildId: input.guildId, - caseNumber: (lastCase?.caseNumber ?? 0) + 1, - targetUserId: input.targetUserId, - moderatorId: input.moderatorId, - action: input.action, - reason: input.reason, - metadata: metadata as Prisma.InputJsonValue + let created = null; + for (let attempt = 0; attempt < 5; attempt++) { + try { + created = await context.prisma.$transaction( + async (tx) => { + const lastCase = await tx.case.findFirst({ + where: { guildId: input.guildId }, + orderBy: { caseNumber: 'desc' }, + select: { caseNumber: true } + }); + return tx.case.create({ + data: { + guildId: input.guildId, + caseNumber: (lastCase?.caseNumber ?? 0) + 1, + targetUserId: input.targetUserId, + moderatorId: input.moderatorId, + action: input.action, + reason: input.reason, + metadata: metadata as Prisma.InputJsonValue + } + }); + }, + { isolationLevel: 'Serializable' } + ); + break; + } catch (error) { + const code = + typeof error === 'object' && error && 'code' in error + ? String((error as { code: unknown }).code) + : ''; + if (code !== 'P2002' && attempt === 4) { + throw error; + } + if (attempt === 4) { + throw error; + } } - }).then(async (created) => { - const { logModAction } = await import('../logging/events.js'); - await logModAction(context, { - guildId: input.guildId, - caseNumber: created.caseNumber, - action: input.action, - targetUserId: input.targetUserId, - moderatorId: input.moderatorId, - reason: input.reason - }); - return created; + } + + if (!created) { + throw new Error('Failed to create case'); + } + + const { logModAction } = await import('../logging/events.js'); + await logModAction(context, { + guildId: input.guildId, + caseNumber: created.caseNumber, + action: input.action, + targetUserId: input.targetUserId, + moderatorId: input.moderatorId, + reason: input.reason }); + return created; +} + +export function tempBanJobId(guildId: string, userId: string): string { + return `tempBan:${guildId}:${userId}`; } export async function scheduleTempBanExpire( @@ -63,10 +94,17 @@ export async function scheduleTempBanExpire( durationMs: number, reason?: string ) { + const jobId = tempBanJobId(guildId, userId); + const existing = await moderationQueue.getJob(jobId); + if (existing) { + await existing.remove(); + } + await moderationQueue.add( 'tempBanExpire', { guildId, userId, reason: reason ?? null }, { + jobId, delay: durationMs, removeOnComplete: 1000, removeOnFail: 500 @@ -78,7 +116,8 @@ export async function applyWarnEscalation( interaction: ChatInputCommandInteraction, context: BotContext, userId: string, - moderatorId: string + moderatorId: string, + locale: Locale ): Promise { const guildId = interaction.guildId; if (!guildId || !interaction.guild) { @@ -98,16 +137,25 @@ export async function applyWarnEscalation( } const member = await interaction.guild.members.fetch(userId); - const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`; + const reason = + rule.reason ?? + t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount)); if (rule.action === 'TIMEOUT') { if (!rule.durationMs) { - return `Escalation rule for ${warningCount} warns skipped (missing duration).`; + return t(locale, 'moderation.escalation.skippedDuration').replace( + '{warnCount}', + String(warningCount) + ); } if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) { - return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + return t(locale, 'moderation.escalation.skippedPermission').replace( + '{warnCount}', + String(warningCount) + ); } - await member.timeout(rule.durationMs, reason); + const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS); + await member.timeout(durationMs, reason); await createCase(context, { guildId, targetUserId: userId, @@ -116,14 +164,20 @@ export async function applyWarnEscalation( reason, channelId: interaction.channelId ?? undefined, source: 'escalation', - metadata: { escalation: true, durationMs: rule.durationMs, warningCount } + metadata: { escalation: true, durationMs, warningCount } }); - return `Auto escalation applied: timeout (${rule.durationMs} ms).`; + return t(locale, 'moderation.escalation.appliedTimeout').replace( + '{durationMs}', + String(durationMs) + ); } if (rule.action === 'KICK') { if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) { - return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + return t(locale, 'moderation.escalation.skippedPermission').replace( + '{warnCount}', + String(warningCount) + ); } await member.kick(reason); await createCase(context, { @@ -136,11 +190,14 @@ export async function applyWarnEscalation( source: 'escalation', metadata: { escalation: true, warningCount } }); - return 'Auto escalation applied: kick.'; + return t(locale, 'moderation.escalation.appliedKick'); } if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) { - return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + return t(locale, 'moderation.escalation.skippedPermission').replace( + '{warnCount}', + String(warningCount) + ); } await interaction.guild.members.ban(userId, { reason }); await createCase(context, { @@ -153,5 +210,5 @@ export async function applyWarnEscalation( source: 'escalation', metadata: { escalation: true, warningCount } }); - return 'Auto escalation applied: ban.'; + return t(locale, 'moderation.escalation.appliedBan'); } diff --git a/apps/bot/src/modules/scheduler/service.ts b/apps/bot/src/modules/scheduler/service.ts index 6c835bc..e282a83 100644 --- a/apps/bot/src/modules/scheduler/service.ts +++ b/apps/bot/src/modules/scheduler/service.ts @@ -1,13 +1,14 @@ import { - EmbedBuilder, PermissionFlagsBits, type GuildTextBasedChannel, type MessageCreateOptions, type TextChannel } from 'discord.js'; -import { WelcomeEmbedSchema, t, tf } from '@nexumi/shared'; +import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import { logger } from '../../logger.js'; import { scheduleQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; @@ -91,23 +92,31 @@ function parseStoredEmbed(raw: unknown) { export function buildAnnouncementPayload(schedule: { content: string | null; embed: unknown; + components?: unknown; rolePingId: string | null; + id?: string; }): MessageCreateOptions { - const embedData = parseStoredEmbed(schedule.embed); - const embeds: EmbedBuilder[] = []; - - if (embedData) { - const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1); - if (embedData.title) { - embed.setTitle(embedData.title); + const componentsData = parseMessageComponentsV2(schedule.components); + if (componentsData && schedule.id) { + const componentsPayload = buildComponentsV2Payload(componentsData, { + source: 's', + ref: schedule.id, + renderText: expandBracketChannelMentions + }); + if (componentsPayload) { + if (schedule.rolePingId) { + // Components V2 cannot mix classic content; role ping is skipped. + } + return componentsPayload; } - if (embedData.description) { - embed.setDescription(embedData.description); - } - embeds.push(embed); } - const baseContent = schedule.content?.trim() ?? ''; + const embedData = parseStoredEmbed(schedule.embed); + const embed = buildEmbedFromPayload(embedData, { + renderText: expandBracketChannelMentions + }); + + const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? ''); const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : ''; const content = [roleMention, baseContent].filter(Boolean).join(' ').trim(); @@ -115,8 +124,8 @@ export function buildAnnouncementPayload(schedule: { if (content) { payload.content = content; } - if (embeds.length > 0) { - payload.embeds = embeds; + if (embed) { + payload.embeds = [embed]; } if (schedule.rolePingId) { payload.allowedMentions = { roles: [schedule.rolePingId] }; @@ -264,7 +273,11 @@ export async function listScheduledMessages( .map((schedule) => { const preview = schedule.content?.slice(0, 60) ?? - (schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—'); + (schedule.components + ? t(locale, 'scheduler.list.componentsPreview') + : schedule.embed + ? t(locale, 'scheduler.list.embedPreview') + : '—'); const timing = schedule.cron ? tf(locale, 'scheduler.list.cron', { cron: schedule.cron }) : schedule.runAt @@ -322,7 +335,7 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri try { const channel = await assertSendableChannel(context, schedule.channelId); const payload = buildAnnouncementPayload(schedule); - if (!payload.content && !payload.embeds?.length) { + if (!payload.content && !payload.embeds?.length && !payload.components?.length) { throw new SchedulerError('content_required'); } await (channel as TextChannel).send(payload); diff --git a/apps/bot/src/modules/tags/commands.ts b/apps/bot/src/modules/tags/commands.ts index c0bdc9c..2ee8c39 100644 --- a/apps/bot/src/modules/tags/commands.ts +++ b/apps/bot/src/modules/tags/commands.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits } from 'discord.js'; +import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js'; import { TagResponseTypeSchema, t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; @@ -67,7 +67,7 @@ const tagCommand: SlashCommand = { interaction.channelId!, args ); - await interaction.reply(payload); + await interaction.reply(payload as InteractionReplyOptions); return; } diff --git a/apps/bot/src/modules/tags/service.ts b/apps/bot/src/modules/tags/service.ts index e900ab9..9734cb2 100644 --- a/apps/bot/src/modules/tags/service.ts +++ b/apps/bot/src/modules/tags/service.ts @@ -1,12 +1,19 @@ -import { EmbedBuilder, type Guild, type GuildMember } from 'discord.js'; +import type { Guild, GuildMember, MessageCreateOptions } from 'discord.js'; import { applyTagPlaceholders, + componentsV2HasContent, + embedHasContent, + parseMessageComponentsV2, TagResponseTypeSchema, WelcomeEmbedSchema, - type TagResponseType + type MessageComponentsV2, + type TagResponseType, + type WelcomeEmbed } from '@nexumi/shared'; import type { Tag } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import type { BotContext } from '../../types.js'; import { assertPremiumLimit, PremiumLimitError } from '../../premium.js'; @@ -50,7 +57,9 @@ export function buildTagPlaceholderVars( 'user.name': member?.displayName ?? '', 'user.tag': member?.user.tag ?? '', 'user.id': member?.id ?? '', + 'user.avatar': member?.user.displayAvatarURL({ size: 256 }) ?? '', server: guild.name, + 'server.icon': guild.iconURL({ size: 128 }) ?? '', args }; } @@ -65,10 +74,7 @@ export function parseTagEmbed(raw: unknown) { return parsed.success ? parsed.data : null; } -export type TagReplyPayload = { - content?: string; - embeds?: EmbedBuilder[]; -}; +export type TagReplyPayload = MessageCreateOptions; export function buildTagReply( tag: Tag, @@ -79,16 +85,22 @@ export function buildTagReply( const vars = buildTagPlaceholderVars(member, guild, args); const responseType = TagResponseTypeSchema.parse(tag.responseType); + if (responseType === 'COMPONENTS_V2') { + const components = parseMessageComponentsV2(tag.components); + const payload = buildComponentsV2Payload(components, { + source: 't', + ref: tag.id, + renderText: (value) => renderTagContent(value, vars) + }); + return payload ?? { content: '—' }; + } + if (responseType === 'EMBED') { const embedData = parseTagEmbed(tag.embed); - const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1); - if (embedData?.title) { - embed.setTitle(renderTagContent(embedData.title, vars)); - } - if (embedData?.description) { - embed.setDescription(renderTagContent(embedData.description, vars)); - } - return { embeds: [embed] }; + const embed = buildEmbedFromPayload(embedData, { + renderText: (value) => renderTagContent(value, vars) + }); + return embed ? { embeds: [embed] } : { content: '—' }; } const content = renderTagContent(tag.content ?? '', vars); @@ -132,7 +144,8 @@ export async function createTag( name: string; responseType: TagResponseType; content?: string | null; - embed?: { title?: string; description?: string; color?: number } | null; + embed?: WelcomeEmbed | null; + components?: MessageComponentsV2 | null; triggerWord?: string | null; allowedRoleIds?: string[]; allowedChannelIds?: string[]; @@ -150,10 +163,14 @@ export async function createTag( throw new TagError('content_required'); } - if (params.responseType === 'EMBED' && !params.embed?.title && !params.embed?.description) { + if (params.responseType === 'EMBED' && !embedHasContent(params.embed) && params.embed?.color === undefined) { throw new TagError('embed_required'); } + if (params.responseType === 'COMPONENTS_V2' && !componentsV2HasContent(params.components)) { + throw new TagError('components_required'); + } + const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } }); try { await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount); @@ -171,6 +188,7 @@ export async function createTag( responseType: params.responseType, content: params.content ?? null, embed: params.embed ?? undefined, + components: params.components ?? undefined, triggerWord: params.triggerWord?.trim() || null, allowedRoleIds: params.allowedRoleIds ?? [], allowedChannelIds: params.allowedChannelIds ?? [], @@ -187,7 +205,8 @@ export async function updateTag( newName?: string; responseType?: TagResponseType; content?: string | null; - embed?: { title?: string; description?: string; color?: number } | null; + embed?: WelcomeEmbed | null; + components?: MessageComponentsV2 | null; triggerWord?: string | null; allowedRoleIds?: string[]; allowedChannelIds?: string[]; @@ -201,15 +220,23 @@ export async function updateTag( const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType); const content = updates.content !== undefined ? updates.content : existing.content; const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed); + const components = + updates.components !== undefined + ? updates.components + : parseMessageComponentsV2(existing.components); if (responseType === 'TEXT' && !content?.trim()) { throw new TagError('content_required'); } - if (responseType === 'EMBED' && !embed?.title && !embed?.description) { + if (responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) { throw new TagError('embed_required'); } + if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(components)) { + throw new TagError('components_required'); + } + let newName: string | undefined; if (updates.newName) { newName = normalizeTagName(updates.newName); @@ -225,6 +252,7 @@ export async function updateTag( responseType, content, embed: embed ?? undefined, + components: components ?? undefined, triggerWord: updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord, allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds, diff --git a/apps/bot/src/modules/utility/command-definitions.ts b/apps/bot/src/modules/utility/command-definitions.ts index 34369db..b6ee0cf 100644 --- a/apps/bot/src/modules/utility/command-definitions.ts +++ b/apps/bot/src/modules/utility/command-definitions.ts @@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription( export const embedCommandData = applyCommandDescription( new SlashCommandBuilder().setName('embed'), 'utility.embed.description' -).addSubcommand((s) => - applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description') -); +) + .addSubcommand((s) => + applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description') + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('components'), 'utility.embed.components.description') + .addChannelOption((o) => + applyOptionDescription( + o.setName('channel').setRequired(true), + 'utility.embed.components.options.channel' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('text').setRequired(true).setMaxLength(2000), + 'utility.embed.components.options.text' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('image_url').setMaxLength(2048), + 'utility.embed.components.options.image_url' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('button_label').setMaxLength(80), + 'utility.embed.components.options.button_label' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('button_url').setMaxLength(2048), + 'utility.embed.components.options.button_url' + ) + ) + ); diff --git a/apps/bot/src/modules/utility/commands.ts b/apps/bot/src/modules/utility/commands.ts index 00a05b5..c5facb5 100644 --- a/apps/bot/src/modules/utility/commands.ts +++ b/apps/bot/src/modules/utility/commands.ts @@ -422,7 +422,123 @@ const embedCommand: SlashCommand = { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) { return; } - await showEmbedBuilderModal(interaction); + + const sub = interaction.options.getSubcommand(); + if (sub === 'builder') { + await showEmbedBuilderModal(interaction); + return; + } + + if (sub === 'components') { + const { ChannelType } = await import('discord.js'); + const channel = interaction.options.getChannel('channel', true); + const text = interaction.options.getString('text', true); + const imageUrl = interaction.options.getString('image_url'); + const buttonLabel = interaction.options.getString('button_label'); + const buttonUrl = interaction.options.getString('button_url'); + + if (!text.trim()) { + await interaction.reply({ + content: t(locale, 'utility.embed.components.missingText'), + ephemeral: true + }); + return; + } + + if ( + !channel || + (channel.type !== ChannelType.GuildText && + channel.type !== ChannelType.GuildAnnouncement && + channel.type !== ChannelType.PublicThread && + channel.type !== ChannelType.PrivateThread) + ) { + await interaction.reply({ + content: t(locale, 'utility.error.channel_not_found'), + ephemeral: true + }); + return; + } + + const containerChildren: import('@nexumi/shared').ComponentV2Node[] = [ + { type: 'text_display', content: text.trim() } + ]; + if (imageUrl?.trim()) { + containerChildren.push({ + type: 'media_gallery', + items: [{ url: imageUrl.trim() }] + }); + } + + const components: import('@nexumi/shared').MessageComponentsV2 = { + components: [ + { + type: 'container', + components: containerChildren + } + ], + actions: {} + }; + + if (buttonLabel?.trim() && buttonUrl?.trim()) { + components.components.push({ + type: 'action_row', + components: [ + { + type: 'button', + style: 'Link', + label: buttonLabel.trim(), + url: buttonUrl.trim() + } + ] + }); + } + + const binding = await context.prisma.componentMessageBinding.create({ + data: { + guildId: interaction.guildId!, + channelId: channel.id, + payload: components, + createdById: interaction.user.id + } + }); + + const { buildComponentsV2Payload } = await import('../../lib/components-v2-payload.js'); + const payload = buildComponentsV2Payload(components, { + source: 'm', + ref: binding.id + }); + if (!payload) { + await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true }); + return; + } + + const textChannel = await context.client.channels.fetch(channel.id); + if (!textChannel?.isTextBased() || textChannel.isDMBased()) { + await interaction.reply({ + content: t(locale, 'utility.error.channel_not_found'), + ephemeral: true + }); + return; + } + + const sent = await textChannel.send(payload); + await context.prisma.componentMessageBinding.update({ + where: { id: binding.id }, + data: { messageId: sent.id } + }); + + const webui = (await import('../../env.js')).env.WEBUI_URL; + const hint = webui + ? tf(locale, 'utility.embed.components.dashboardHint', { + url: `${webui}/dashboard/${interaction.guildId}/messages` + }) + : ''; + + await interaction.reply({ + content: [t(locale, 'utility.embed.components.sent'), hint].filter(Boolean).join('\n'), + ephemeral: true + }); + } } }; diff --git a/apps/bot/src/modules/verification/alt-detection.test.ts b/apps/bot/src/modules/verification/alt-detection.test.ts new file mode 100644 index 0000000..ff8bce9 --- /dev/null +++ b/apps/bot/src/modules/verification/alt-detection.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { detectAltAccount } from './alt-detection.js'; + +describe('detectAltAccount', () => { + const basePrior = { + userId: 'user-a', + inviteCode: 'abc123', + ipHash: 'ip-hash-1', + createdAt: new Date(), + flaggedAsAlt: false + }; + + it('matches identical IP hash within window', () => { + const match = detectAltAccount({ + userId: 'user-b', + accountAgeDays: 30, + inviteCode: null, + ipHash: 'ip-hash-1', + priorRecords: [basePrior], + altIpWindowDays: 30, + altInviteWindowHours: 48, + altMaxAccountsPerInvite: 3 + }); + expect(match).toEqual({ reason: 'ip', matchedUserId: 'user-a' }); + }); + + it('does not match when IP window expired', () => { + const match = detectAltAccount({ + userId: 'user-b', + accountAgeDays: 30, + inviteCode: null, + ipHash: 'ip-hash-1', + priorRecords: [ + { + ...basePrior, + createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000) + } + ], + altIpWindowDays: 30, + altInviteWindowHours: 48, + altMaxAccountsPerInvite: 3 + }); + expect(match).toBeNull(); + }); + + it('flags invite clusters for young accounts', () => { + const now = Date.now(); + const match = detectAltAccount({ + userId: 'user-new', + accountAgeDays: 2, + inviteCode: 'raid', + ipHash: null, + priorRecords: [ + { ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) }, + { ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) }, + { ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) } + ], + altIpWindowDays: 30, + altInviteWindowHours: 48, + altMaxAccountsPerInvite: 3 + }); + expect(match?.reason).toBe('invite_cluster'); + }); + + it('ignores invite clusters for older accounts', () => { + const now = Date.now(); + const match = detectAltAccount({ + userId: 'user-old', + accountAgeDays: 90, + inviteCode: 'raid', + ipHash: null, + priorRecords: [ + { ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) }, + { ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) }, + { ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) } + ], + altIpWindowDays: 30, + altInviteWindowHours: 48, + altMaxAccountsPerInvite: 3 + }); + expect(match).toBeNull(); + }); +}); diff --git a/apps/bot/src/modules/verification/alt-detection.ts b/apps/bot/src/modules/verification/alt-detection.ts new file mode 100644 index 0000000..3d9bf0d --- /dev/null +++ b/apps/bot/src/modules/verification/alt-detection.ts @@ -0,0 +1,62 @@ +export type AltSignalMatch = { + reason: 'ip' | 'invite_cluster'; + matchedUserId: string | null; +}; + +export type AltDetectionInput = { + userId: string; + accountAgeDays: number; + inviteCode: string | null; + ipHash: string | null; + /** Prior verification records in the same guild (excluding current user). */ + priorRecords: Array<{ + userId: string; + inviteCode: string | null; + ipHash: string | null; + createdAt: Date; + flaggedAsAlt: boolean; + }>; + altIpWindowDays: number; + altInviteWindowHours: number; + altMaxAccountsPerInvite: number; + /** Only flag invite clusters when the current account is this young or younger. */ + youngAccountMaxDays?: number; +}; + +/** + * Detects likely alt accounts from Discord + captcha signals. + * Does not use browser fingerprinting beyond salted IP / UA hashes from the captcha page. + */ +export function detectAltAccount(input: AltDetectionInput): AltSignalMatch | null { + const youngMax = input.youngAccountMaxDays ?? 7; + const now = Date.now(); + + if (input.ipHash) { + const ipWindowMs = input.altIpWindowDays * 24 * 60 * 60 * 1000; + const ipMatch = input.priorRecords.find( + (record) => + record.ipHash === input.ipHash && + now - record.createdAt.getTime() <= ipWindowMs + ); + if (ipMatch) { + return { reason: 'ip', matchedUserId: ipMatch.userId }; + } + } + + if (input.inviteCode && input.accountAgeDays <= youngMax) { + const inviteWindowMs = input.altInviteWindowHours * 60 * 60 * 1000; + const recentSameInvite = input.priorRecords.filter( + (record) => + record.inviteCode === input.inviteCode && + now - record.createdAt.getTime() <= inviteWindowMs + ); + if (recentSameInvite.length >= input.altMaxAccountsPerInvite) { + return { + reason: 'invite_cluster', + matchedUserId: recentSameInvite[0]?.userId ?? null + }; + } + } + + return null; +} diff --git a/apps/bot/src/modules/verification/command-definitions.ts b/apps/bot/src/modules/verification/command-definitions.ts index 8f8e6b7..05f4823 100644 --- a/apps/bot/src/modules/verification/command-definitions.ts +++ b/apps/bot/src/modules/verification/command-definitions.ts @@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription( { name: 'Captcha', value: 'CAPTCHA' } ) ) + .addStringOption((o) => + applyOptionDescription( + o.setName('captcha_provider'), + 'verify.setup.options.captcha_provider' + ).addChoices( + { name: 'Math', value: 'MATH' }, + { name: 'reCAPTCHA v2', value: 'RECAPTCHA_V2' }, + { name: 'reCAPTCHA v3', value: 'RECAPTCHA_V3' }, + { name: 'hCaptcha', value: 'HCAPTCHA' }, + { name: 'Turnstile', value: 'TURNSTILE' } + ) + ) .addIntegerOption((o) => applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days') .setMinValue(0) @@ -39,6 +51,9 @@ export const verifyCommandData = applyCommandDescription( { name: 'None', value: 'NONE' } ) ) + .addBooleanOption((o) => + applyOptionDescription(o.setName('alt_detection'), 'verify.setup.options.alt_detection') + ) ) .addSubcommand((s) => applyCommandDescription(s.setName('panel'), 'verify.panel.description') diff --git a/apps/bot/src/modules/verification/commands.ts b/apps/bot/src/modules/verification/commands.ts index 5d938af..4de4338 100644 --- a/apps/bot/src/modules/verification/commands.ts +++ b/apps/bot/src/modules/verification/commands.ts @@ -1,21 +1,41 @@ -import { PermissionFlagsBits } from 'discord.js'; +import { + PermissionFlagsBits, + type GuildBasedChannel, + type GuildTextBasedChannel +} from 'discord.js'; import { t } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { requirePermission } from '../../permissions.js'; +import { ephemeral } from '../../interaction-reply.js'; import { verifyCommandData } from './command-definitions.js'; -import { - getVerificationConfig, - updateVerificationConfig -} from './service.js'; +import { getVerificationConfig, updateVerificationConfig } from './service.js'; import { buildVerificationPanel } from './handlers.js'; +function botCanPostPanel( + channel: GuildBasedChannel, + meId: string +): channel is GuildTextBasedChannel { + if (!channel.isTextBased() || channel.isDMBased()) { + return false; + } + const permissions = channel.permissionsFor(meId); + if (!permissions) { + return false; + } + return ( + permissions.has(PermissionFlagsBits.ViewChannel) && + permissions.has(PermissionFlagsBits.SendMessages) && + permissions.has(PermissionFlagsBits.EmbedLinks) + ); +} + const verifyCommand: SlashCommand = { data: verifyCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!interaction.inGuild()) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return; } if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { @@ -30,11 +50,20 @@ const verifyCommand: SlashCommand = { const verifiedRole = interaction.options.getRole('verified_role', true); const unverifiedRole = interaction.options.getRole('unverified_role'); const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA'; + const captchaProvider = (interaction.options.getString('captcha_provider') ?? + 'MATH') as + | 'MATH' + | 'RECAPTCHA_V2' + | 'RECAPTCHA_V3' + | 'HCAPTCHA' + | 'TURNSTILE'; const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0; const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as | 'KICK' | 'BAN' | 'NONE'; + const altDetectionEnabled = + interaction.options.getBoolean('alt_detection') ?? false; await updateVerificationConfig(context.prisma, guildId, { enabled: true, @@ -42,17 +71,22 @@ const verifyCommand: SlashCommand = { verifiedRoleId: verifiedRole.id, unverifiedRoleId: unverifiedRole?.id ?? null, mode, + captchaProvider, minAccountAgeDays, - failAction + failAction, + altDetectionEnabled }); - await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() }); return; } const config = await getVerificationConfig(context.prisma, guildId); if (!config.enabled || !config.verifiedRoleId) { - await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'verification.notConfigured'), + ...ephemeral() + }); return; } @@ -63,12 +97,16 @@ const verifyCommand: SlashCommand = { ? await interaction.guild!.channels.fetch(config.channelId) : null; - if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) { - await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true }); + const me = interaction.guild!.members.me; + if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) { + await interaction.reply({ + content: t(locale, 'verification.panelMissingPermission'), + ...ephemeral() + }); return; } - const panel = buildVerificationPanel(config); + const panel = buildVerificationPanel(config, locale); const message = await panelChannel.send(panel); await context.prisma.verificationConfig.update({ where: { guildId }, @@ -77,7 +115,7 @@ const verifyCommand: SlashCommand = { panelMessageId: message.id } }); - await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() }); } }; diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts index a75026f..e1c8061 100644 --- a/apps/bot/src/modules/verification/handlers.ts +++ b/apps/bot/src/modules/verification/handlers.ts @@ -7,7 +7,7 @@ import { type ButtonInteraction, type GuildMember } from 'discord.js'; -import { t, tf } from '@nexumi/shared'; +import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { @@ -18,8 +18,16 @@ import { verificationButtonId, verificationCaptchaButtonId } from './service.js'; +import { detectAltAccount } from './alt-detection.js'; import { env } from '../../env.js'; import { logger } from '../../logger.js'; +import { ephemeral } from '../../interaction-reply.js'; + +export type CompleteVerificationOptions = { + ipHash?: string | null; + userAgentHash?: string | null; + captchaProvider?: string | null; +}; async function applyFailAction( member: GuildMember, @@ -39,7 +47,8 @@ async function applyFailAction( export async function completeVerification( context: BotContext, guildId: string, - userId: string + userId: string, + options: CompleteVerificationOptions = {} ): Promise<{ ok: boolean; reason?: string }> { const config = await getVerificationConfig(context.prisma, guildId); if (!config.enabled || !config.verifiedRoleId) { @@ -62,6 +71,88 @@ export async function completeVerification( return { ok: false, reason: 'account_too_young' }; } + const inviteJoin = await context.prisma.inviteJoin.findUnique({ + where: { guildId_userId: { guildId, userId } } + }); + + let flaggedAsAlt = false; + let matchedUserId: string | null = null; + + if (config.altDetectionEnabled) { + const lookbackMs = Math.max( + config.altIpWindowDays * 24 * 60 * 60 * 1000, + config.altInviteWindowHours * 60 * 60 * 1000 + ); + const since = new Date(Date.now() - lookbackMs); + const priorRecords = await context.prisma.verificationRecord.findMany({ + where: { + guildId, + userId: { not: userId }, + createdAt: { gte: since } + }, + select: { + userId: true, + inviteCode: true, + ipHash: true, + createdAt: true, + flaggedAsAlt: true + } + }); + + const match = detectAltAccount({ + userId, + accountAgeDays: ageDays, + inviteCode: inviteJoin?.code ?? null, + ipHash: options.ipHash ?? null, + priorRecords, + altIpWindowDays: config.altIpWindowDays, + altInviteWindowHours: config.altInviteWindowHours, + altMaxAccountsPerInvite: config.altMaxAccountsPerInvite + }); + + if (match) { + flaggedAsAlt = true; + matchedUserId = match.matchedUserId; + logger.info( + { guildId, userId, reason: match.reason, matchedUserId }, + 'Verification alt-account signal' + ); + + await context.prisma.verificationRecord.upsert({ + where: { guildId_userId: { guildId, userId } }, + create: { + guildId, + userId, + inviteCode: inviteJoin?.code ?? null, + inviterId: inviteJoin?.inviterId ?? null, + ipHash: options.ipHash ?? null, + userAgentHash: options.userAgentHash ?? null, + captchaProvider: options.captchaProvider ?? null, + flaggedAsAlt: true, + matchedUserId + }, + update: { + inviteCode: inviteJoin?.code ?? null, + inviterId: inviteJoin?.inviterId ?? null, + ipHash: options.ipHash ?? null, + userAgentHash: options.userAgentHash ?? null, + captchaProvider: options.captchaProvider ?? null, + flaggedAsAlt: true, + matchedUserId + } + }); + + if (config.altBlockOnMatch) { + await applyFailAction( + member, + config.failAction, + `Verification failed: possible alt account (${match.reason})` + ); + return { ok: false, reason: 'alt_detected' }; + } + } + } + const me = guild.members.me; if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { return { ok: false, reason: 'missing_permissions' }; @@ -82,13 +173,40 @@ export async function completeVerification( await member.roles.add(config.verifiedRoleId); } + await context.prisma.verificationRecord.upsert({ + where: { guildId_userId: { guildId, userId } }, + create: { + guildId, + userId, + inviteCode: inviteJoin?.code ?? null, + inviterId: inviteJoin?.inviterId ?? null, + ipHash: options.ipHash ?? null, + userAgentHash: options.userAgentHash ?? null, + captchaProvider: options.captchaProvider ?? null, + flaggedAsAlt, + matchedUserId + }, + update: { + inviteCode: inviteJoin?.code ?? null, + inviterId: inviteJoin?.inviterId ?? null, + ipHash: options.ipHash ?? null, + userAgentHash: options.userAgentHash ?? null, + captchaProvider: options.captchaProvider ?? null, + flaggedAsAlt, + matchedUserId + } + }); + return { ok: true }; } -export function buildVerificationPanel(config: Awaited>) { +export function buildVerificationPanel( + config: Awaited>, + locale: 'de' | 'en' +) { const embed = new EmbedBuilder() - .setTitle('Verification') - .setDescription('Click the button below to verify and gain access to the server.') + .setTitle(t(locale, 'verification.panel.title')) + .setDescription(t(locale, 'verification.panel.description')) .setColor(0x6366f1); const customId = @@ -99,7 +217,11 @@ export function buildVerificationPanel(config: Awaited().addComponents( new ButtonBuilder() .setCustomId(customId) - .setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify') + .setLabel( + config.mode === 'CAPTCHA' + ? t(locale, 'verification.panel.buttonCaptcha') + : t(locale, 'verification.panel.button') + ) .setStyle(ButtonStyle.Success) ); @@ -119,25 +241,29 @@ export async function handleVerificationButton( const config = await getVerificationConfig(context.prisma, parsed.guildId); if (!config.enabled) { - await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'verification.disabled'), ...ephemeral() }); return; } if (parsed.mode === 'captcha') { + const providerParse = CaptchaProviderSchema.safeParse(config.captchaProvider); + const provider: CaptchaProvider = providerParse.success ? providerParse.data : 'MATH'; const challenge = await createCaptchaChallenge( context.redis, parsed.guildId, - interaction.user.id + interaction.user.id, + provider ); - const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`; + const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, ''); + const url = `${base}/verify/captcha?token=${challenge.token}`; await interaction.reply({ content: tf(locale, 'verification.captchaLink', { url }), - ephemeral: true + ...ephemeral() }); return; } - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply(ephemeral()); const result = await completeVerification(context, parsed.guildId, interaction.user.id); if (result.ok) { await interaction.editReply({ content: t(locale, 'verification.success') }); diff --git a/apps/bot/src/modules/verification/service.ts b/apps/bot/src/modules/verification/service.ts index 9f7af36..f5a96d4 100644 --- a/apps/bot/src/modules/verification/service.ts +++ b/apps/bot/src/modules/verification/service.ts @@ -1,11 +1,27 @@ -import { randomBytes, randomInt, createHash } from 'node:crypto'; +import { createHash, randomBytes, randomInt } from 'node:crypto'; +import type { CaptchaProvider, VerificationFailAction, VerificationMode } from '@nexumi/shared'; import type { PrismaClient } from '@prisma/client'; -import type { VerificationFailAction, VerificationMode } from '@nexumi/shared'; -import { ensureGuild } from '../../guild.js'; import type { Redis } from 'ioredis'; +import { ensureGuild } from '../../guild.js'; const CAPTCHA_PREFIX = 'verify:captcha:'; +export type VerificationConfigUpdate = { + enabled: boolean; + channelId: string; + verifiedRoleId: string; + unverifiedRoleId?: string | null; + mode: VerificationMode; + captchaProvider?: CaptchaProvider; + minAccountAgeDays: number; + failAction: VerificationFailAction; + altDetectionEnabled?: boolean; + altBlockOnMatch?: boolean; + altIpWindowDays?: number; + altInviteWindowHours?: number; + altMaxAccountsPerInvite?: number; +}; + export async function getVerificationConfig(prisma: PrismaClient, guildId: string) { await ensureGuild(prisma, guildId); let config = await prisma.verificationConfig.findUnique({ where: { guildId } }); @@ -18,15 +34,7 @@ export async function getVerificationConfig(prisma: PrismaClient, guildId: strin export async function updateVerificationConfig( prisma: PrismaClient, guildId: string, - data: { - enabled: boolean; - channelId: string; - verifiedRoleId: string; - unverifiedRoleId?: string | null; - mode: VerificationMode; - minAccountAgeDays: number; - failAction: VerificationFailAction; - } + data: VerificationConfigUpdate ) { await ensureGuild(prisma, guildId); return prisma.verificationConfig.upsert({ @@ -65,27 +73,32 @@ export type CaptchaChallenge = { token: string; guildId: string; userId: string; - question: string; - answerHash: string; + provider: CaptchaProvider; + question?: string; + answerHash?: string; }; export async function createCaptchaChallenge( redis: Redis, guildId: string, - userId: string + userId: string, + provider: CaptchaProvider = 'MATH' ): Promise { - const a = randomInt(2, 12); - const b = randomInt(2, 12); - const answer = String(a + b); const token = randomBytes(24).toString('hex'); - const answerHash = createHash('sha256').update(answer).digest('hex'); const challenge: CaptchaChallenge = { token, guildId, userId, - question: `${a} + ${b}`, - answerHash + provider }; + + if (provider === 'MATH') { + const a = randomInt(2, 12); + const b = randomInt(2, 12); + challenge.question = `${a} + ${b}`; + challenge.answerHash = createHash('sha256').update(String(a + b)).digest('hex'); + } + await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600); return challenge; } @@ -98,7 +111,11 @@ export async function getCaptchaChallenge( if (!raw) { return null; } - return JSON.parse(raw) as CaptchaChallenge; + const parsed = JSON.parse(raw) as CaptchaChallenge; + if (!parsed.provider) { + parsed.provider = 'MATH'; + } + return parsed; } export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise { @@ -113,3 +130,7 @@ export function accountAgeDays(userCreatedAt: Date): number { const ms = Date.now() - userCreatedAt.getTime(); return Math.floor(ms / (1000 * 60 * 60 * 24)); } + +export function hashClientSignal(salt: string, value: string): string { + return createHash('sha256').update(`${salt}:${value}`).digest('hex'); +} diff --git a/apps/bot/src/modules/welcome/commands.ts b/apps/bot/src/modules/welcome/commands.ts index 650cb22..278258d 100644 --- a/apps/bot/src/modules/welcome/commands.ts +++ b/apps/bot/src/modules/welcome/commands.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits } from 'discord.js'; +import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js'; import { t } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; @@ -7,6 +7,26 @@ import { welcomeCommandData } from './command-definitions.js'; import { getWelcomeConfig, resolveWelcomePayload } from './service.js'; import { buildWelcomeMessage } from './renderer.js'; +function toInteractionReply( + message: Awaited>, + ephemeral: boolean +): InteractionReplyOptions { + const flags = + typeof message.flags === 'number' + ? ephemeral + ? message.flags | MessageFlags.Ephemeral + : message.flags + : undefined; + + return { + content: message.content, + embeds: message.embeds, + files: message.files, + components: message.components, + ...(flags !== undefined ? { flags } : ephemeral ? { ephemeral: true } : {}) + }; +} + const welcomeCommand: SlashCommand = { data: welcomeCommandData, async execute(interaction, context) { @@ -32,7 +52,7 @@ const welcomeCommand: SlashCommand = { if (sub === 'preview') { const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); - await interaction.reply({ ...message, ephemeral: true }); + await interaction.reply(toInteractionReply(message, true)); return; } @@ -48,7 +68,9 @@ const welcomeCommand: SlashCommand = { } const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); - await channel.send(message); + if ('send' in channel) { + await channel.send(message); + } await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true }); } }; diff --git a/apps/bot/src/modules/welcome/events.ts b/apps/bot/src/modules/welcome/events.ts index c4d04e2..f344ffe 100644 --- a/apps/bot/src/modules/welcome/events.ts +++ b/apps/bot/src/modules/welcome/events.ts @@ -1,6 +1,6 @@ import { PermissionFlagsBits, type GuildMember } from 'discord.js'; import type { BotContext } from '../../types.js'; -import { getWelcomeConfig, resolveWelcomePayload } from './service.js'; +import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js'; import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js'; import { logger } from '../../logger.js'; @@ -41,7 +41,7 @@ export async function handleMemberWelcome(context: BotContext, member: GuildMemb if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) { await member - .send({ content: config.welcomeDmContent.replace('{user}', `<@${member.id}>`) }) + .send({ content: renderWelcomeText(config.welcomeDmContent, member, member.guild) }) .catch(() => undefined); } } @@ -55,8 +55,7 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember if (!channel?.isTextBased() || channel.isDMBased()) { return; } - const content = config.leaveContent ?? '{user.tag} left {server}.'; - await sendLeaveMessage(channel, content, member, member.guild); + await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild); } export function registerWelcomeEvents(context: BotContext): void { diff --git a/apps/bot/src/modules/welcome/renderer.ts b/apps/bot/src/modules/welcome/renderer.ts index 9e26d6f..339d880 100644 --- a/apps/bot/src/modules/welcome/renderer.ts +++ b/apps/bot/src/modules/welcome/renderer.ts @@ -1,34 +1,40 @@ import { AttachmentBuilder, - EmbedBuilder, type Guild, type GuildMember, + type MessageCreateOptions, type SendableChannels, type TextBasedChannel } from 'discord.js'; import { createCanvas, loadImage } from '@napi-rs/canvas'; -import type { WelcomePayload } from './service.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import type { LeavePayload, WelcomePayload } from './service.js'; import { renderWelcomeText } from './service.js'; export async function buildWelcomeMessage( payload: WelcomePayload, member: GuildMember, guild: Guild -): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> { +): Promise { + if (payload.type === 'COMPONENTS_V2') { + const componentsPayload = buildComponentsV2Payload(payload.components, { + source: 'w', + ref: guild.id, + renderText: (value) => renderWelcomeText(value, member, guild) + }); + if (componentsPayload) { + return componentsPayload; + } + return { content: renderWelcomeText('{user}', member, guild) }; + } + if (payload.type === 'EMBED') { - const embed = new EmbedBuilder(); - const data = payload.embed; - if (data?.title) { - embed.setTitle(renderWelcomeText(data.title, member, guild)); - } - if (data?.description) { - embed.setDescription(renderWelcomeText(data.description, member, guild)); - } - if (data?.color !== undefined) { - embed.setColor(data.color); - } - embed.setThumbnail(member.user.displayAvatarURL({ size: 256 })); - return { embeds: [embed] }; + const embed = buildEmbedFromPayload(payload.embed, { + renderText: (value) => renderWelcomeText(value, member, guild), + defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) + }); + return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) }; } if (payload.type === 'IMAGE') { @@ -95,13 +101,47 @@ export async function sendWelcomeMessage( } } +export async function buildLeaveMessage( + payload: LeavePayload, + member: GuildMember, + guild: Guild +): Promise { + if (payload.type === 'COMPONENTS_V2') { + const componentsPayload = buildComponentsV2Payload(payload.components, { + source: 'l', + ref: guild.id, + renderText: (value) => renderWelcomeText(value, member, guild) + }); + if (componentsPayload) { + return componentsPayload; + } + } + + if (payload.type === 'EMBED') { + const embed = buildEmbedFromPayload(payload.embed, { + renderText: (value) => renderWelcomeText(value, member, guild), + defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) + }); + if (embed) { + return { embeds: [embed] }; + } + } + + const content = payload.content ?? '{user.tag} left {server}.'; + return { content: renderWelcomeText(content, member, guild) }; +} + export async function sendLeaveMessage( channel: TextBasedChannel, - content: string, + payload: LeavePayload | string, member: GuildMember, guild: Guild ): Promise { if ('send' in channel) { - await (channel as SendableChannels).send({ content: renderWelcomeText(content, member, guild) }); + const message = + typeof payload === 'string' + ? { content: renderWelcomeText(payload, member, guild) } + : await buildLeaveMessage(payload, member, guild); + await (channel as SendableChannels).send(message); } } diff --git a/apps/bot/src/modules/welcome/service.ts b/apps/bot/src/modules/welcome/service.ts index 7ab4e11..4d5c7c0 100644 --- a/apps/bot/src/modules/welcome/service.ts +++ b/apps/bot/src/modules/welcome/service.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from '@prisma/client'; import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared'; -import { applyWelcomePlaceholders } from '@nexumi/shared'; +import { applyWelcomePlaceholders, parseMessageComponentsV2 } from '@nexumi/shared'; import type { Guild, GuildMember } from 'discord.js'; import { ensureGuild } from '../../guild.js'; @@ -19,8 +19,10 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) { 'user.name': member.displayName, 'user.tag': member.user.tag, 'user.id': member.id, + 'user.avatar': member.user.displayAvatarURL({ size: 256 }), server: guild.name, - memberCount: guild.memberCount + memberCount: guild.memberCount, + 'server.icon': guild.iconURL({ size: 128 }) ?? '' }; } @@ -39,6 +41,7 @@ export type WelcomePayload = { type: WelcomeMessageType; content?: string | null; embed?: WelcomeEmbed | null; + components?: import('@nexumi/shared').MessageComponentsV2 | null; imageTitle?: string | null; imageSubtitle?: string | null; }; @@ -50,7 +53,26 @@ export function resolveWelcomePayload( type: config.welcomeType as WelcomeMessageType, content: config.welcomeContent, embed: parseWelcomeEmbed(config.welcomeEmbed), + components: parseMessageComponentsV2(config.welcomeComponents), imageTitle: config.imageTitle, imageSubtitle: config.imageSubtitle }; } + +export type LeavePayload = { + type: import('@nexumi/shared').LeaveMessageType; + content?: string | null; + embed?: WelcomeEmbed | null; + components?: import('@nexumi/shared').MessageComponentsV2 | null; +}; + +export function resolveLeavePayload( + config: Awaited> +): LeavePayload { + return { + type: (config.leaveType as LeavePayload['type']) || 'TEXT', + content: config.leaveContent, + embed: parseWelcomeEmbed(config.leaveEmbed), + components: parseMessageComponentsV2(config.leaveComponents) + }; +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index 7d1fb4e..a2f1535 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -23,6 +23,8 @@ export const feedsQueueName = 'feeds'; export const feedsQueue = new Queue(feedsQueueName, { connection: redis }); export const scheduleQueueName = 'schedules'; export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis }); +export const messagesQueueName = 'messages'; +export const messagesQueue = new Queue(messagesQueueName, { connection: redis }); export const guildBackupQueueName = 'guild-backups'; export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis }); export const suggestionsQueueName = 'suggestions'; diff --git a/apps/bot/src/sentry.ts b/apps/bot/src/sentry.ts new file mode 100644 index 0000000..41f77b8 --- /dev/null +++ b/apps/bot/src/sentry.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import { env } from './env.js'; +import { logger } from './logger.js'; + +let initialized = false; + +export function initSentry(service: 'bot-manager' | 'bot-shard' | 'webui'): void { + if (initialized || !env.SENTRY_DSN) { + return; + } + Sentry.init({ + dsn: env.SENTRY_DSN, + environment: env.NODE_ENV, + release: process.env.npm_package_version + ? `nexumi@${process.env.npm_package_version}` + : 'nexumi@0.1.0', + tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0, + initialScope: { + tags: { service } + } + }); + initialized = true; + logger.info({ service }, 'Sentry initialized'); +} + +export function captureException(error: unknown, context?: Record): void { + if (!env.SENTRY_DSN) { + return; + } + Sentry.withScope((scope) => { + if (context) { + scope.setExtras(context); + } + Sentry.captureException(error); + }); +} diff --git a/apps/bot/src/shard-lifecycle.ts b/apps/bot/src/shard-lifecycle.ts new file mode 100644 index 0000000..eedebb3 --- /dev/null +++ b/apps/bot/src/shard-lifecycle.ts @@ -0,0 +1,37 @@ +import type { Client } from 'discord.js'; +import { redis } from './redis.js'; +import { logger } from './logger.js'; + +/** + * Returns true when this process owns shard 0 (or has no shard manager = single process). + */ +export function isPrimaryShard(client: Client): boolean { + const ids = client.shard?.ids; + if (!ids || ids.length === 0) { + return true; + } + return ids.includes(0); +} + +/** + * Acquires a short-lived Redis lock. Returns true if this caller owns the lock. + */ +export async function acquireOnceLock(lockKey: string, ttlSeconds: number): Promise { + const result = await redis.set(lockKey, String(Date.now()), 'EX', ttlSeconds, 'NX'); + return result === 'OK'; +} + +export async function runOncePerCluster( + lockKey: string, + ttlSeconds: number, + fn: () => Promise, + label: string +): Promise { + const acquired = await acquireOnceLock(lockKey, ttlSeconds); + if (!acquired) { + logger.info({ lockKey }, `${label} skipped (another process holds the lock)`); + return false; + } + await fn(); + return true; +} diff --git a/apps/webui/next.config.ts b/apps/webui/next.config.ts index 1ca2391..a79ac4a 100644 --- a/apps/webui/next.config.ts +++ b/apps/webui/next.config.ts @@ -4,7 +4,8 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone', transpilePackages: ['@nexumi/shared'], - outputFileTracingRoot: path.join(__dirname, '../..') + outputFileTracingRoot: path.join(__dirname, '../..'), + serverExternalPackages: ['@prisma/client', 'ioredis', 'bullmq'] }; export default nextConfig; diff --git a/apps/webui/package.json b/apps/webui/package.json index d2486ad..910df60 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -15,8 +15,10 @@ "@nexumi/shared": "workspace:^", "@prisma/client": "^5.22.0", "@radix-ui/react-avatar": "^1.2.3", + "@radix-ui/react-dialog": "^1.1.20", "@radix-ui/react-dropdown-menu": "^2.1.21", "@radix-ui/react-label": "^2.1.12", + "@radix-ui/react-popover": "^1.1.20", "@radix-ui/react-select": "^2.3.4", "@radix-ui/react-separator": "^1.1.12", "@radix-ui/react-slot": "^1.3.0", @@ -24,6 +26,7 @@ "bullmq": "^5.34.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "dotenv": "^16.4.7", "ioredis": "^5.4.2", "lucide-react": "^1.25.0", diff --git a/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts b/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts index d25bf54..a040bbf 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts @@ -1,7 +1,9 @@ -import { CaseListQuerySchema } from '@nexumi/shared'; +import { CaseListQuerySchema, CaseUpdateDashboardSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; -import { listCases } from '@/lib/module-configs/cases'; +import { writeDashboardAudit } from '@/lib/audit'; +import { listCases, softDeleteCase, updateCaseReason } from '@/lib/module-configs/cases'; +import { prisma } from '@/lib/prisma'; interface RouteParams { params: Promise<{ guildId: string }>; @@ -11,15 +13,63 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { guildId } = await params; try { await requireGuildAccess(guildId); - const searchParams = request.nextUrl.searchParams; - const query = CaseListQuerySchema.parse({ - search: searchParams.get('search') ?? undefined, - action: searchParams.get('action') ?? undefined, - limit: searchParams.get('limit') ?? undefined - }); + const query = CaseListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); const cases = await listCases(guildId, query); return NextResponse.json({ cases }); } catch (error) { return toApiErrorResponse(error); } } + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = (await request.json()) as { id?: string; reason?: string }; + if (!body.id) { + return NextResponse.json({ error: 'Missing case id' }, { status: 400 }); + } + const patch = CaseUpdateDashboardSchema.parse({ reason: body.reason }); + const updated = await updateCaseReason(guildId, body.id, patch); + if (!updated) { + return NextResponse.json({ error: 'Case not found' }, { status: 404 }); + } + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'case.update', + path: `/dashboard/${guildId}/moderation`, + before: { id: body.id }, + after: updated + }); + return NextResponse.json({ case: updated }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const id = request.nextUrl.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing case id' }, { status: 400 }); + } + const ok = await softDeleteCase(guildId, id); + if (!ok) { + return NextResponse.json({ error: 'Case not found' }, { status: 404 }); + } + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'case.delete', + path: `/dashboard/${guildId}/moderation`, + before: { id }, + after: { deleted: true } + }); + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/channels/route.ts b/apps/webui/src/app/api/guilds/[guildId]/channels/route.ts new file mode 100644 index 0000000..0e64f6a --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/channels/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { listGuildChannelsForDashboard } from '@/lib/discord-guild-resources'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const channels = await listGuildChannelsForDashboard(guildId); + return NextResponse.json(channels); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/commands/globals/route.ts b/apps/webui/src/app/api/guilds/[guildId]/commands/globals/route.ts new file mode 100644 index 0000000..6ae58f0 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/commands/globals/route.ts @@ -0,0 +1,46 @@ +import { CommandGlobalRulesPatchSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { getCommandGlobalRules, updateCommandGlobalRules } from '@/lib/module-configs/command-global-rules'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const rules = await getCommandGlobalRules(guildId); + return NextResponse.json(rules); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const patch = CommandGlobalRulesPatchSchema.parse(body); + + const before = await getCommandGlobalRules(guildId); + const after = await updateCommandGlobalRules(guildId, patch); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'commands.global.update', + path: `/dashboard/${guildId}/commands`, + before, + after + }); + + return NextResponse.json(after); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/messages/route.ts b/apps/webui/src/app/api/guilds/[guildId]/messages/route.ts new file mode 100644 index 0000000..63dce7d --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/messages/route.ts @@ -0,0 +1,41 @@ +import { DashboardMessageSendSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { MessageSendTimeoutError, sendDashboardMessageFromWebui } from '@/lib/module-configs/messages'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = DashboardMessageSendSchema.parse(body); + + const result = await sendDashboardMessageFromWebui(guildId, session.user.id, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'messages.send', + path: `/dashboard/${guildId}/messages`, + after: { + channelId: result.channelId, + mode: input.mode, + messageId: result.messageId, + bindingId: result.bindingId + } + }); + + return NextResponse.json(result, { status: 201 }); + } catch (error) { + if (error instanceof MessageSendTimeoutError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/roles/route.ts b/apps/webui/src/app/api/guilds/[guildId]/roles/route.ts new file mode 100644 index 0000000..b48d690 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/roles/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { listGuildRolesForDashboard } from '@/lib/discord-guild-resources'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const roles = await listGuildRolesForDashboard(guildId); + return NextResponse.json(roles); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts index 33d9c64..8d1e109 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts @@ -13,8 +13,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { const { guildId } = await params; try { await requireGuildAccess(guildId); - const config = await getVerificationDashboard(guildId); - return NextResponse.json(config); + const payload = await getVerificationDashboard(guildId); + return NextResponse.json(payload); } catch (error) { return toApiErrorResponse(error); } @@ -28,15 +28,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const patch = VerificationConfigDashboardPatchSchema.parse(body); const before = await getVerificationDashboard(guildId); - const after = await updateVerificationDashboard(guildId, patch); + const afterConfig = await updateVerificationDashboard(guildId, patch); + const after = { + config: afterConfig, + availableCaptchaProviders: before.availableCaptchaProviders + }; await writeDashboardAudit(prisma, { guildId, actorUserId: session.user.id, action: 'verification.update', path: `/dashboard/${guildId}/verification`, - before, - after + before: before.config, + after: afterConfig }); return NextResponse.json(after); diff --git a/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts b/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts index 6d7a77f..1f3d288 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts @@ -1,7 +1,9 @@ import { WarningListQuerySchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; -import { listWarnings } from '@/lib/module-configs/warnings'; +import { writeDashboardAudit } from '@/lib/audit'; +import { deleteWarning, listWarnings } from '@/lib/module-configs/warnings'; +import { prisma } from '@/lib/prisma'; interface RouteParams { params: Promise<{ guildId: string }>; @@ -11,13 +13,36 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { guildId } = await params; try { await requireGuildAccess(guildId); - const searchParams = request.nextUrl.searchParams; - const query = WarningListQuerySchema.parse({ - userId: searchParams.get('userId') ?? undefined - }); + const query = WarningListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); const warnings = await listWarnings(guildId, query); return NextResponse.json({ warnings }); } catch (error) { return toApiErrorResponse(error); } } + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const id = request.nextUrl.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing warning id' }, { status: 400 }); + } + const ok = await deleteWarning(guildId, id); + if (!ok) { + return NextResponse.json({ error: 'Warning not found' }, { status: 404 }); + } + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'warning.delete', + path: `/dashboard/${guildId}/moderation`, + before: { id }, + after: { deleted: true } + }); + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx index 4362d01..68d7f21 100644 --- a/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx @@ -1,5 +1,8 @@ +import { Suspense } from 'react'; import { CommandsManager } from '@/components/modules/commands-manager'; +import { listGuildChannelsForDashboard } from '@/lib/discord-guild-resources'; import { getLocale, t } from '@/lib/i18n'; +import { getCommandGlobalRules } from '@/lib/module-configs/command-global-rules'; import { listCommandOverridesDashboard } from '@/lib/module-configs/commands'; interface CommandsPageProps { @@ -8,7 +11,12 @@ interface CommandsPageProps { export default async function CommandsPage({ params }: CommandsPageProps) { const { guildId } = await params; - const [locale, commands] = await Promise.all([getLocale(), listCommandOverridesDashboard(guildId)]); + const [locale, commands, globalRules, channels] = await Promise.all([ + getLocale(), + listCommandOverridesDashboard(guildId), + getCommandGlobalRules(guildId), + listGuildChannelsForDashboard(guildId).catch(() => []) + ]); return (
@@ -16,7 +24,14 @@ export default async function CommandsPage({ params }: CommandsPageProps) {

{t(locale, 'modulePages.commands.title')}

{t(locale, 'modulePages.commands.description')}

- + + + ); } diff --git a/apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx new file mode 100644 index 0000000..d3cba05 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function MessagesLoading() { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/messages/page.tsx b/apps/webui/src/app/dashboard/[guildId]/messages/page.tsx new file mode 100644 index 0000000..db847fa --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/messages/page.tsx @@ -0,0 +1,21 @@ +import { MessagesManager } from '@/components/modules/messages-manager'; +import { getLocale, t } from '@/lib/i18n'; + +interface MessagesPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function MessagesPage({ params }: MessagesPageProps) { + const { guildId } = await params; + const locale = await getLocale(); + + return ( +
+
+

{t(locale, 'modules.messages.label')}

+

{t(locale, 'modules.messages.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx b/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx index e70627b..2097460 100644 --- a/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx @@ -1,9 +1,11 @@ -import { CaseListQuerySchema } from '@nexumi/shared'; +import { CaseListQuerySchema, WarningListQuerySchema } from '@nexumi/shared'; import { CasesTable } from '@/components/modules/cases-table'; import { ModerationForm } from '@/components/modules/moderation-form'; +import { WarningsTable } from '@/components/modules/warnings-table'; import { getLocale, t } from '@/lib/i18n'; import { listCases } from '@/lib/module-configs/cases'; import { getModerationDashboard } from '@/lib/module-configs/moderation'; +import { listWarnings } from '@/lib/module-configs/warnings'; interface ModerationPageProps { params: Promise<{ guildId: string }>; @@ -11,10 +13,11 @@ interface ModerationPageProps { export default async function ModerationPage({ params }: ModerationPageProps) { const { guildId } = await params; - const [locale, moderation, cases] = await Promise.all([ + const [locale, moderation, cases, warnings] = await Promise.all([ getLocale(), getModerationDashboard(guildId), - listCases(guildId, CaseListQuerySchema.parse({})) + listCases(guildId, CaseListQuerySchema.parse({})), + listWarnings(guildId, WarningListQuerySchema.parse({})) ]); return ( @@ -24,6 +27,7 @@ export default async function ModerationPage({ params }: ModerationPageProps) {

{t(locale, 'modules.moderation.description')}

+ ); diff --git a/apps/webui/src/app/dashboard/[guildId]/page.tsx b/apps/webui/src/app/dashboard/[guildId]/page.tsx index ed8c6e3..c6ac62a 100644 --- a/apps/webui/src/app/dashboard/[guildId]/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/page.tsx @@ -1,7 +1,9 @@ +import { DASHBOARD_MODULES } from '@nexumi/shared'; +import { Activity, MessageSquare, Mic, Users } from 'lucide-react'; import Link from 'next/link'; import { getActivitySummary } from '@/lib/activity'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { listRecentDashboardAudit } from '@/lib/audit'; import { getLocale, t } from '@/lib/i18n'; import { getModuleStatuses } from '@/lib/modules'; @@ -17,6 +19,10 @@ function formatDate(iso: string, locale: string): string { }); } +function moduleHref(moduleId: string): string { + return DASHBOARD_MODULES.find((module) => module.id === moduleId)?.href ?? moduleId; +} + export default async function GuildOverviewPage({ params }: OverviewPageProps) { const { guildId } = await params; const locale = await getLocale(); @@ -26,55 +32,108 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) { getActivitySummary(guildId) ]); + const enabledCount = modules.filter((module) => module.enabled).length; + const numberLocale = locale === 'de' ? 'de-DE' : 'en-US'; + return ( -
-

{t(locale, 'dashboard.overview.title')}

+
+
+

{t(locale, 'dashboard.overview.title')}

+

+ {t(locale, 'dashboard.overview.activityHint')} +

+
-

{t(locale, 'dashboard.overview.activityTitle')}

-
- - -

{t(locale, 'dashboard.overview.activityMessages')}

-

{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+
+ +

{t(locale, 'dashboard.overview.activityTitle')}

+
+
+ + +
+ +
+
+

+ {t(locale, 'dashboard.overview.activityMessages')} +

+

+ {activitySummary.messageCount.toLocaleString(numberLocale)} +

+
- - -

{t(locale, 'dashboard.overview.activityVoiceMinutes')}

-

{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+ + +
+ +
+
+

+ {t(locale, 'dashboard.overview.activityVoiceMinutes')} +

+

+ {activitySummary.voiceMinutes.toLocaleString(numberLocale)} +

+
- - -

{t(locale, 'dashboard.overview.activityActiveUsers')}

-

{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+ + +
+ +
+
+

+ {t(locale, 'dashboard.overview.activityActiveUsers')} +

+

+ {activitySummary.activeUserCount.toLocaleString(numberLocale)} +

+
-

{t(locale, 'dashboard.overview.activityHint')}

-
-

{t(locale, 'dashboard.overview.moduleStatusTitle')}

- +
+
+

{t(locale, 'dashboard.overview.moduleStatusTitle')}

+ {modules.length > 0 ? ( +

+ {enabledCount}/{modules.length} {t(locale, 'common.enabled').toLowerCase()} +

+ ) : null} +
+ {t(locale, 'dashboard.overview.viewAllModules')}
{modules.length === 0 ? ( -

{t(locale, 'dashboard.overview.moduleStatusEmpty')}

+ + + {t(locale, 'dashboard.overview.moduleStatusEmpty')} + + ) : (
{modules.map((module) => ( - - - {t(locale, `modules.${module.id}.label`)} - - {t(locale, module.enabled ? 'common.enabled' : 'common.disabled')} - - - + + + + {t(locale, `modules.${module.id}.label`)} + + {t(locale, module.enabled ? 'common.enabled' : 'common.disabled')} + + + + ))}
)} @@ -82,22 +141,23 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {

{t(locale, 'dashboard.overview.recentAuditTitle')}

- - - - {t(locale, 'dashboard.overview.recentAuditTitle')} - + + + {t(locale, 'dashboard.overview.recentAuditTitle')} + {t(locale, 'dashboard.overview.auditPath')} - + {auditEntries.length === 0 ? ( -

{t(locale, 'dashboard.overview.auditEmpty')}

+

+ {t(locale, 'dashboard.overview.auditEmpty')} +

) : (
    {auditEntries.map((entry) => ( -
  • +
  • {entry.action}

    - {entry.path &&

    {entry.path}

    } + {entry.path ?

    {entry.path}

    : null}
    {formatDate(entry.createdAt, locale)} diff --git a/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx b/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx index b5c859c..7238e81 100644 --- a/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx @@ -8,7 +8,7 @@ interface VerificationPageProps { export default async function VerificationPage({ params }: VerificationPageProps) { const { guildId } = await params; - const [locale, config] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]); + const [locale, payload] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]); return (
    @@ -16,7 +16,11 @@ export default async function VerificationPage({ params }: VerificationPageProps

    {t(locale, 'modules.verification.label')}

    {t(locale, 'modules.verification.description')}

    - +
); } diff --git a/apps/webui/src/app/dashboard/page.tsx b/apps/webui/src/app/dashboard/page.tsx index 794d2ee..685ad50 100644 --- a/apps/webui/src/app/dashboard/page.tsx +++ b/apps/webui/src/app/dashboard/page.tsx @@ -1,33 +1,13 @@ -import type { DashboardGuild } from '@nexumi/shared'; -import Link from 'next/link'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; +import { DashboardGuildGrid } from '@/components/dashboard/dashboard-guild-grid'; import { requireAuthOrRedirect } from '@/lib/auth'; import { env } from '@/lib/env'; import { getManageableGuilds } from '@/lib/guilds'; import { getLocale, t } from '@/lib/i18n'; -function guildIconUrl(guild: Pick): string | undefined { - return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined; -} - -function initials(name: string): string { - return ( - name - .split(' ') - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]?.toUpperCase()) - .join('') || '?' - ); -} - function buildInviteUrl(guildId: string): string { const url = new URL('https://discord.com/api/oauth2/authorize'); url.searchParams.set('client_id', env.BOT_CLIENT_ID); - url.searchParams.set('permissions', '8'); + url.searchParams.set('permissions', '4786708802825463'); url.searchParams.set('scope', 'bot applications.commands'); url.searchParams.set('guild_id', guildId); return url.toString(); @@ -38,6 +18,11 @@ export default async function DashboardGuildListPage() { const guilds = await getManageableGuilds(session); const locale = await getLocale(); + const inviteUrls: Record = {}; + for (const guild of guilds) { + inviteUrls[guild.id] = buildInviteUrl(guild.id); + } + return (
@@ -46,47 +31,16 @@ export default async function DashboardGuildListPage() {

{t(locale, 'dashboard.guildList.subtitle')}

- {guilds.length === 0 ? ( - - - {t(locale, 'dashboard.guildList.empty')} - - - ) : ( -
- {guilds.map((guild) => ( - - -
- - - {initials(guild.name)} - -
-

{guild.name}

- {!guild.botPresent && ( - - {t(locale, 'dashboard.guildList.botMissing')} - - )} -
-
- {guild.botPresent ? ( - - ) : ( - - )} -
-
- ))} -
- )} +
); diff --git a/apps/webui/src/app/globals.css b/apps/webui/src/app/globals.css index 86addc2..fefdfdc 100644 --- a/apps/webui/src/app/globals.css +++ b/apps/webui/src/app/globals.css @@ -81,4 +81,11 @@ body { background: var(--background); color: var(--foreground); font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* Subtle page depth without full-bleed gradients (SPEC). */ +.dark body { + background-image: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.08), transparent); + background-attachment: fixed; } diff --git a/apps/webui/src/app/impressum/page.tsx b/apps/webui/src/app/impressum/page.tsx index 99753b5..73dd75e 100644 --- a/apps/webui/src/app/impressum/page.tsx +++ b/apps/webui/src/app/impressum/page.tsx @@ -1,46 +1,13 @@ +import { LegalDocument } from '@/components/site/legal-document'; import { LegalShell } from '@/components/site/legal-shell'; -import { env } from '@/lib/env'; import { getLocale, t } from '@/lib/i18n'; export default async function ImpressumPage() { const locale = await getLocale(); - const configured = Boolean(env.LEGAL_OPERATOR_NAME && env.LEGAL_OPERATOR_ADDRESS && env.LEGAL_OPERATOR_EMAIL); return ( -

{t(locale, 'legal.impressum.intro')}

- {configured ? ( -
-
-
{t(locale, 'legal.impressum.name')}
-
{env.LEGAL_OPERATOR_NAME}
-
-
-
{t(locale, 'legal.impressum.address')}
-
{env.LEGAL_OPERATOR_ADDRESS}
-
-
-
{t(locale, 'legal.impressum.email')}
-
- - {env.LEGAL_OPERATOR_EMAIL} - -
-
- {env.LEGAL_OPERATOR_PHONE ? ( -
-
{t(locale, 'legal.impressum.phone')}
-
{env.LEGAL_OPERATOR_PHONE}
-
- ) : null} -
- ) : ( -

- {t(locale, 'legal.impressum.notConfigured')} -

- )} -

{t(locale, 'legal.impressum.p1')}

-

{t(locale, 'legal.impressum.p2')}

+
); } diff --git a/apps/webui/src/app/layout.tsx b/apps/webui/src/app/layout.tsx index 1ce16e5..8717499 100644 --- a/apps/webui/src/app/layout.tsx +++ b/apps/webui/src/app/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import type { ReactNode } from 'react'; -import { Toaster } from 'sonner'; import { LocaleProvider } from '@/components/locale-provider'; import { ThemeProvider } from '@/components/layout/theme-provider'; +import { Toaster } from '@/components/ui/toaster'; import { getLocale, MESSAGES } from '@/lib/i18n'; import './globals.css'; @@ -11,7 +11,7 @@ const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'sw export const metadata: Metadata = { title: 'Nexumi — Discord Bot', - description: 'Self-hosted Discord bot with dashboard, moderation, and community modules' + description: 'Discord bot with dashboard, moderation, and community modules' }; export default async function RootLayout({ children }: { children: ReactNode }) { @@ -23,7 +23,7 @@ export default async function RootLayout({ children }: { children: ReactNode }) {children} - + diff --git a/apps/webui/src/app/login/page.tsx b/apps/webui/src/app/login/page.tsx index 1ec7b6e..737ad65 100644 --- a/apps/webui/src/app/login/page.tsx +++ b/apps/webui/src/app/login/page.tsx @@ -1,52 +1,12 @@ import { redirect } from 'next/navigation'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { getLocale, t } from '@/lib/i18n'; -import { getSession } from '@/lib/session'; interface LoginPageProps { searchParams: Promise<{ error?: string }>; } -const KNOWN_ERRORS = ['access_denied', 'invalid_request', 'invalid_state', 'oauth_failed'] as const; - +/** Alias for `/` so OAuth error redirects to `/login?error=…` keep working. */ export default async function LoginPage({ searchParams }: LoginPageProps) { - const session = await getSession(); - if (session) { - redirect('/dashboard'); - } - const { error } = await searchParams; - const locale = await getLocale(); - const errorMessage = - error && (KNOWN_ERRORS as readonly string[]).includes(error) - ? t(locale, `login.errors.${error}`) - : undefined; - - return ( -
- - -
- N -
- {t(locale, 'login.title')} - {t(locale, 'login.subtitle')} -
- - {errorMessage && ( -

- {errorMessage} -

- )} - - {t(locale, 'login.cta')} - -

{t(locale, 'login.footer')}

-
-
-
- ); + const target = error ? `/?error=${encodeURIComponent(error)}` : '/'; + redirect(target); } diff --git a/apps/webui/src/app/page.tsx b/apps/webui/src/app/page.tsx index 59bd845..f11278a 100644 --- a/apps/webui/src/app/page.tsx +++ b/apps/webui/src/app/page.tsx @@ -1,111 +1,17 @@ -import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; -import Image from 'next/image'; -import Link from 'next/link'; -import { SiteShell } from '@/components/site/site-shell'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { buildBotInviteUrl, env } from '@/lib/env'; -import { getLocale, t } from '@/lib/i18n'; -import { getPublicStats } from '@/lib/public-stats'; +import { redirect } from 'next/navigation'; +import { LoginCard } from '@/components/auth/login-card'; import { getSession } from '@/lib/session'; -const GROUP_ORDER: DashboardModuleGroup[] = [ - 'moderation', - 'engagement', - 'community', - 'utility', - 'integrations' -]; - -export default async function LandingPage() { - const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]); - const inviteUrl = buildBotInviteUrl(); - const isLoggedIn = Boolean(session); - - return ( - -
-
-
- -

Nexumi

-
-

{t(locale, 'landing.hero.subtitle')}

-
- - - {env.SUPPORT_SERVER_URL ? ( - - ) : null} -
-
-
- -
-
- - - - {t(locale, 'landing.stats.guilds')} - - - {stats.guildCount.toLocaleString(locale)} - - - - - {t(locale, 'landing.stats.users')} - - - - {stats.approximateUserCount.toLocaleString(locale)} - - -
-
- -
-
-
-

{t(locale, 'landing.features.title')}

-

{t(locale, 'landing.features.subtitle')}

-
- {GROUP_ORDER.map((group) => { - const modules = DASHBOARD_MODULES.filter((module) => module.group === group); - return ( -
-

- {t(locale, `moduleGroups.${group}`)} -

-
- {modules.map((module) => ( - - - {t(locale, `modules.${module.id}.label`)} - - - {t(locale, `modules.${module.id}.description`)} - - - ))} -
-
- ); - })} -
-
-
- ); +interface HomePageProps { + searchParams: Promise<{ error?: string }>; +} + +export default async function HomePage({ searchParams }: HomePageProps) { + const session = await getSession(); + if (session) { + redirect('/dashboard'); + } + + const { error } = await searchParams; + return ; } diff --git a/apps/webui/src/app/privacy/page.tsx b/apps/webui/src/app/privacy/page.tsx index 6612860..6b8108f 100644 --- a/apps/webui/src/app/privacy/page.tsx +++ b/apps/webui/src/app/privacy/page.tsx @@ -1,3 +1,4 @@ +import { LegalDocument } from '@/components/site/legal-document'; import { LegalShell } from '@/components/site/legal-shell'; import { getLocale, t } from '@/lib/i18n'; @@ -6,12 +7,7 @@ export default async function PrivacyPage() { return ( -

{t(locale, 'legal.scaffoldNotice')}

- {['p1', 'p2', 'p3', 'p4', 'p5'].map((key) => ( -

- {t(locale, `legal.privacy.${key}`)} -

- ))} +
); } diff --git a/apps/webui/src/app/terms/page.tsx b/apps/webui/src/app/terms/page.tsx index 4eefa9f..53961e1 100644 --- a/apps/webui/src/app/terms/page.tsx +++ b/apps/webui/src/app/terms/page.tsx @@ -1,3 +1,4 @@ +import { LegalDocument } from '@/components/site/legal-document'; import { LegalShell } from '@/components/site/legal-shell'; import { getLocale, t } from '@/lib/i18n'; @@ -6,12 +7,7 @@ export default async function TermsPage() { return ( -

{t(locale, 'legal.scaffoldNotice')}

- {['p1', 'p2', 'p3', 'p4'].map((key) => ( -

- {t(locale, `legal.terms.${key}`)} -

- ))} +
); } diff --git a/apps/webui/src/app/verify/captcha/route.ts b/apps/webui/src/app/verify/captcha/route.ts new file mode 100644 index 0000000..c7afb7c --- /dev/null +++ b/apps/webui/src/app/verify/captcha/route.ts @@ -0,0 +1,243 @@ +import { NextResponse } from 'next/server'; +import type { CaptchaProvider } from '@nexumi/shared'; +import { + deleteCaptchaChallenge, + getCaptchaChallenge, + getCaptchaPublicConfig, + getClientIp, + hashCaptchaAnswer, + hashClientSignal, + verifyProviderToken +} from '@/lib/captcha'; +import { getVerificationQueue } from '@/lib/queues'; + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +const PAGE_STYLES = ` + body { font-family: system-ui, sans-serif; background:#0b1220; color:#f8fafc; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; } + .card { background:#111827; padding:2rem; border-radius:12px; width:min(440px, 92vw); border:1px solid #1f2937; } + h1 { margin:0 0 0.5rem; font-size:1.35rem; } + p { margin:0 0 1rem; color:#cbd5e1; line-height:1.45; } + .error { color:#f87171; margin-bottom:0.75rem; } + input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#0b1220; color:#f8fafc; box-sizing:border-box; } + button { background:#4f46e5; border:none; cursor:pointer; font-weight:600; } + .widget { display:flex; justify-content:center; margin:1rem 0; min-height:78px; } +`; + +function renderShell(body: string): string { + return ` + + + + + Nexumi Verification + + +${body} +`; +} + +function renderMathPage(token: string, question: string, error?: string): string { + const errorBlock = error ? `

${escapeHtml(error)}

` : ''; + return renderShell(` +
+

Nexumi Verification

+

Solve: ${escapeHtml(question)} = ?

+ ${errorBlock} + + + + +
`); +} + +function renderProviderPage( + token: string, + provider: CaptchaProvider, + siteKey: string, + error?: string +): string { + const errorBlock = error ? `

${escapeHtml(error)}

` : ''; + const labels: Record = { + RECAPTCHA_V2: 'Complete the Google reCAPTCHA below.', + RECAPTCHA_V3: 'Checking with Google reCAPTCHA…', + HCAPTCHA: 'Complete the hCaptcha below.', + TURNSTILE: 'Complete the Cloudflare Turnstile below.' + }; + + if (provider === 'RECAPTCHA_V3') { + return renderShell(` +
+

Nexumi Verification

+

${escapeHtml(labels.RECAPTCHA_V3)}

+ ${errorBlock} + + + + +
+ + `); + } + + let widget = ''; + let scripts = ''; + if (provider === 'RECAPTCHA_V2') { + widget = `
`; + scripts = ``; + } else if (provider === 'HCAPTCHA') { + widget = `
`; + scripts = ``; + } else if (provider === 'TURNSTILE') { + widget = `
`; + scripts = ``; + } + + return renderShell(` +
+

Nexumi Verification

+

${escapeHtml(labels[provider] ?? 'Complete the captcha below.')}

+ ${errorBlock} + + +
${widget}
+ +
+ ${scripts}`); +} + +function renderMisconfigured(provider: string): string { + return renderShell(` +
+

Captcha unavailable

+

Provider ${escapeHtml(provider)} is not configured on this server. Ask an admin to set the keys in .env, or switch the guild to Math captcha.

+
`); +} + +function renderDonePage(): string { + return renderShell(` +
+

Verified

+

You can return to Discord.

+
`); +} + +function html(body: string, status = 200): NextResponse { + return new NextResponse(body, { + status, + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); +} + +function extractProviderResponse(form: FormData, provider: CaptchaProvider): string { + const direct = String(form.get('captcha_response') ?? '').trim(); + if (direct) { + return direct; + } + if (provider === 'RECAPTCHA_V2' || provider === 'RECAPTCHA_V3') { + return String(form.get('g-recaptcha-response') ?? '').trim(); + } + if (provider === 'HCAPTCHA') { + return String(form.get('h-captcha-response') ?? '').trim(); + } + if (provider === 'TURNSTILE') { + return String(form.get('cf-turnstile-response') ?? '').trim(); + } + return ''; +} + +function renderChallengePage( + token: string, + provider: CaptchaProvider, + question: string | undefined, + error?: string +): NextResponse { + if (provider === 'MATH') { + return html(renderMathPage(token, question ?? '?', error)); + } + const publicConfig = getCaptchaPublicConfig(provider); + if (!publicConfig.siteKey) { + return html(renderMisconfigured(provider), 503); + } + return html(renderProviderPage(token, provider, publicConfig.siteKey, error)); +} + +export async function GET(request: Request): Promise { + const token = new URL(request.url).searchParams.get('token'); + if (!token) { + return new NextResponse('Missing token', { status: 400 }); + } + const challenge = await getCaptchaChallenge(token); + if (!challenge) { + return new NextResponse('Captcha expired', { status: 404 }); + } + return renderChallengePage(token, challenge.provider, challenge.question); +} + +export async function POST(request: Request): Promise { + const form = await request.formData(); + const token = String(form.get('token') ?? ''); + if (!token) { + return new NextResponse('Missing fields', { status: 400 }); + } + + const challenge = await getCaptchaChallenge(token); + if (!challenge) { + return new NextResponse('Captcha expired', { status: 404 }); + } + + const provider = challenge.provider; + const clientIp = getClientIp(request); + const userAgent = request.headers.get('user-agent') ?? ''; + + if (provider === 'MATH') { + const answer = String(form.get('answer') ?? ''); + if (!answer || !challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) { + return renderChallengePage(token, provider, challenge.question, 'Wrong answer. Try again.'); + } + } else { + const responseToken = extractProviderResponse(form, provider); + const verified = await verifyProviderToken(provider, responseToken, clientIp); + if (!verified.ok) { + const message = + verified.error === 'score_too_low' + ? 'reCAPTCHA score too low. Try again.' + : verified.error === 'provider_not_configured' + ? 'Captcha provider is not configured.' + : 'Captcha failed. Try again.'; + return renderChallengePage(token, provider, challenge.question, message); + } + } + + await deleteCaptchaChallenge(token); + + const ipHash = clientIp ? hashClientSignal(clientIp) : null; + const userAgentHash = userAgent ? hashClientSignal(userAgent) : null; + + await getVerificationQueue().add( + 'verificationComplete', + { + guildId: challenge.guildId, + userId: challenge.userId, + ipHash, + userAgentHash, + captchaProvider: provider + }, + { removeOnComplete: 1000, removeOnFail: 500 } + ); + + return html(renderDonePage()); +} diff --git a/apps/webui/src/archived/landing-page.tsx b/apps/webui/src/archived/landing-page.tsx new file mode 100644 index 0000000..51a0285 --- /dev/null +++ b/apps/webui/src/archived/landing-page.tsx @@ -0,0 +1,117 @@ +/** + * Archived marketing landing page (Phase 8). + * Replaced by the login screen at `/` for the dashboard.nexumi.de deploy. + * A separate public landing will live on nexumi.de later. + * This file is not an App Router page and is not served. + */ +import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; +import Image from 'next/image'; +import Link from 'next/link'; +import { SiteShell } from '@/components/site/site-shell'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { buildBotInviteUrl, env } from '@/lib/env'; +import { getLocale, t } from '@/lib/i18n'; +import { getPublicStats } from '@/lib/public-stats'; +import { getSession } from '@/lib/session'; + +const GROUP_ORDER: DashboardModuleGroup[] = [ + 'moderation', + 'engagement', + 'community', + 'utility', + 'integrations' +]; + +export default async function ArchivedLandingPage() { + const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]); + const inviteUrl = buildBotInviteUrl(); + const isLoggedIn = Boolean(session); + + return ( + +
+
+
+ +

Nexumi

+
+

{t(locale, 'landing.hero.subtitle')}

+
+ + + {env.SUPPORT_SERVER_URL ? ( + + ) : null} +
+
+
+ +
+
+ + + + {t(locale, 'landing.stats.guilds')} + + + {stats.guildCount.toLocaleString(locale)} + + + + + {t(locale, 'landing.stats.users')} + + + + {stats.approximateUserCount.toLocaleString(locale)} + + +
+
+ +
+
+
+

{t(locale, 'landing.features.title')}

+

{t(locale, 'landing.features.subtitle')}

+
+ {GROUP_ORDER.map((group) => { + const modules = DASHBOARD_MODULES.filter((module) => module.group === group); + return ( +
+

+ {t(locale, `moduleGroups.${group}`)} +

+
+ {modules.map((module) => ( + + + {t(locale, `modules.${module.id}.label`)} + + + {t(locale, `modules.${module.id}.description`)} + + + ))} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/webui/src/components/auth/login-card.tsx b/apps/webui/src/components/auth/login-card.tsx new file mode 100644 index 0000000..e9aa30f --- /dev/null +++ b/apps/webui/src/components/auth/login-card.tsx @@ -0,0 +1,40 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { getLocale, t } from '@/lib/i18n'; + +const KNOWN_ERRORS = ['access_denied', 'invalid_request', 'invalid_state', 'oauth_failed'] as const; + +export async function LoginCard({ error }: { error?: string }) { + const locale = await getLocale(); + const errorMessage = + error && (KNOWN_ERRORS as readonly string[]).includes(error) + ? t(locale, `login.errors.${error}`) + : undefined; + + return ( +
+ + +
+ N +
+ {t(locale, 'login.title')} + {t(locale, 'login.subtitle')} +
+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + + {t(locale, 'login.cta')} + +

{t(locale, 'login.footer')}

+
+
+
+ ); +} diff --git a/apps/webui/src/components/dashboard/dashboard-guild-grid.tsx b/apps/webui/src/components/dashboard/dashboard-guild-grid.tsx new file mode 100644 index 0000000..5fa682d --- /dev/null +++ b/apps/webui/src/components/dashboard/dashboard-guild-grid.tsx @@ -0,0 +1,86 @@ +'use client'; + +import type { DashboardGuild } from '@nexumi/shared'; +import Link from 'next/link'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; + +function guildIconUrl(guild: Pick): string | undefined { + return guild.icon + ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` + : undefined; +} + +function initials(name: string): string { + return ( + name + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join('') || '?' + ); +} + +export function DashboardGuildGrid({ + guilds, + inviteUrls, + labels +}: { + guilds: DashboardGuild[]; + inviteUrls: Record; + labels: { + empty: string; + botMissing: string; + manage: string; + invite: string; + }; +}) { + if (guilds.length === 0) { + return ( + + + {labels.empty} + + + ); + } + + return ( +
+ {guilds.map((guild) => ( + + +
+ + + {initials(guild.name)} + +
+

{guild.name}

+ {!guild.botPresent && ( + + {labels.botMissing} + + )} +
+
+ {guild.botPresent ? ( + + ) : ( + + )} +
+
+ ))} +
+ ); +} diff --git a/apps/webui/src/components/layout/dashboard-command-palette.tsx b/apps/webui/src/components/layout/dashboard-command-palette.tsx new file mode 100644 index 0000000..0398685 --- /dev/null +++ b/apps/webui/src/components/layout/dashboard-command-palette.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { Search, Terminal, Settings2, LayoutGrid } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from '@/components/locale-provider'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command'; +import { + OWNER_NAV_ENTRIES, + buildGuildSearchHref, + buildGuildSearchIndex, + type SearchIndexEntry, + type SearchItemKind +} from '@/lib/dashboard-search-index'; + +function kindIcon(kind: SearchItemKind) { + switch (kind) { + case 'command': + return Terminal; + case 'setting': + return Settings2; + default: + return LayoutGrid; + } +} + +interface DashboardCommandPaletteProps { + guildId?: string; + mode?: 'guild' | 'owner'; +} + +export function DashboardCommandPalette({ guildId, mode = 'guild' }: DashboardCommandPaletteProps) { + const t = useTranslations(); + const router = useRouter(); + const [open, setOpen] = useState(false); + + const entries = useMemo(() => { + if (mode === 'owner') { + return OWNER_NAV_ENTRIES; + } + return buildGuildSearchIndex(); + }, [mode]); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setOpen((prev) => !prev); + } + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + const resolveLabel = useCallback( + (entry: SearchIndexEntry) => { + if (entry.labelOverride) { + return entry.labelOverride; + } + if (entry.labelKey) { + return t(entry.labelKey); + } + return entry.id; + }, + [t] + ); + + function navigateTo(entry: SearchIndexEntry) { + setOpen(false); + if (mode === 'owner') { + router.push(entry.href); + return; + } + if (!guildId) { + return; + } + const href = buildGuildSearchHref(guildId, entry); + router.push(href); + if (entry.hash) { + window.setTimeout(() => { + document.getElementById(entry.hash!)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 250); + } + } + + const pages = entries.filter((e) => e.kind === 'page'); + const commands = entries.filter((e) => e.kind === 'command'); + const settings = entries.filter((e) => e.kind === 'setting'); + + const isMac = + typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); + + return ( + <> + + + + + + {t('search.empty')} + {pages.length > 0 ? ( + + {pages.map((entry) => { + const Icon = kindIcon(entry.kind); + const label = resolveLabel(entry); + return ( + navigateTo(entry)} + > + + {label} + + ); + })} + + ) : null} + {commands.length > 0 ? ( + + {commands.map((entry) => { + const Icon = kindIcon(entry.kind); + const label = resolveLabel(entry); + return ( + navigateTo(entry)} + > + + {label} + + ); + })} + + ) : null} + {settings.length > 0 ? ( + + {settings.map((entry) => { + const Icon = kindIcon(entry.kind); + const label = resolveLabel(entry); + return ( + navigateTo(entry)} + > + + {label} + + ); + })} + + ) : null} + + + + ); +} diff --git a/apps/webui/src/components/layout/dashboard-shell.tsx b/apps/webui/src/components/layout/dashboard-shell.tsx index c0e1746..904d83c 100644 --- a/apps/webui/src/components/layout/dashboard-shell.tsx +++ b/apps/webui/src/components/layout/dashboard-shell.tsx @@ -1,8 +1,16 @@ +'use client'; + import type { DashboardGuild, SessionUser } from '@nexumi/shared'; -import type { ReactNode } from 'react'; -import { ServerSwitcher } from './server-switcher'; -import { Sidebar } from './sidebar'; -import { UserMenu } from './user-menu'; +import { Menu } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette'; +import { HashScroll } from '@/components/layout/field-anchor'; +import { ServerSwitcher } from '@/components/layout/server-switcher'; +import { Sidebar, SidebarNav } from '@/components/layout/sidebar'; +import { UserMenu } from '@/components/layout/user-menu'; +import { Button } from '@/components/ui/button'; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { useTranslations } from '@/components/locale-provider'; interface DashboardShellProps { guildId: string; @@ -21,15 +29,36 @@ export function DashboardShell({ showOwnerLink = false, children }: DashboardShellProps) { + const t = useTranslations(); + const [mobileOpen, setMobileOpen] = useState(false); + return ( -
+
+ -
-
+
+
+ + + + + + {t('nav.dashboard')} + setMobileOpen(false)} /> + + + - +
+
+ +
+ +
-
+
{children}
diff --git a/apps/webui/src/components/layout/field-anchor.tsx b/apps/webui/src/components/layout/field-anchor.tsx new file mode 100644 index 0000000..fca2a23 --- /dev/null +++ b/apps/webui/src/components/layout/field-anchor.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useEffect, type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +/** Stable scroll target for command-palette deep links (`#field-…`). */ +export function FieldAnchor({ + id, + children, + className +}: { + id: string; + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +/** Scrolls to `location.hash` after navigation from the command palette. */ +export function HashScroll() { + useEffect(() => { + function scrollToHash() { + const hash = window.location.hash.replace(/^#/, ''); + if (!hash) { + return; + } + const el = document.getElementById(hash); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + + scrollToHash(); + window.addEventListener('hashchange', scrollToHash); + const timer = window.setTimeout(scrollToHash, 120); + return () => { + window.removeEventListener('hashchange', scrollToHash); + window.clearTimeout(timer); + }; + }, []); + + return null; +} diff --git a/apps/webui/src/components/layout/sidebar.tsx b/apps/webui/src/components/layout/sidebar.tsx index 2b2a641..6ef2c49 100644 --- a/apps/webui/src/components/layout/sidebar.tsx +++ b/apps/webui/src/components/layout/sidebar.tsx @@ -1,7 +1,36 @@ 'use client'; -import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; -import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react'; +import { DASHBOARD_MODULES, type DashboardModuleGroup, type DashboardModuleId } from '@nexumi/shared'; +import { + Archive, + BarChart3, + Cake, + CalendarClock, + Coins, + Gamepad2, + Gift, + Hash, + LayoutGrid, + MessageSquare, + MessageSquareQuote, + MessagesSquare, + PartyPopper, + Radio, + Rss, + Settings, + Shield, + ShieldAlert, + ShieldCheck, + SlidersHorizontal, + Sparkles, + Star, + Terminal, + Ticket, + UserCheck, + Volume2, + type LucideIcon +} from 'lucide-react'; +import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useTranslations } from '@/components/locale-provider'; @@ -15,19 +44,56 @@ const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [ 'integrations' ]; -interface SidebarProps { +const MODULE_ICONS: Record = { + moderation: Shield, + automod: ShieldAlert, + logging: Hash, + welcome: PartyPopper, + verification: UserCheck, + leveling: Sparkles, + economy: Coins, + fun: Gamepad2, + giveaways: Gift, + tickets: Ticket, + selfroles: UserCheck, + tags: MessageSquareQuote, + starboard: Star, + suggestions: MessagesSquare, + birthdays: Cake, + tempvoice: Volume2, + stats: BarChart3, + feeds: Rss, + scheduler: CalendarClock, + guildbackup: Archive, + messages: MessageSquare +}; + +interface SidebarNavProps { guildId: string; + onNavigate?: () => void; + className?: string; } -function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) { +function NavLink({ + href, + active, + onNavigate, + children +}: { + href: string; + active: boolean; + onNavigate?: () => void; + children: React.ReactNode; +}) { return ( {children} @@ -35,7 +101,7 @@ function NavLink({ href, active, children }: { href: string; active: boolean; ch ); } -export function Sidebar({ guildId }: SidebarProps) { +export function SidebarNav({ guildId, onNavigate, className }: SidebarNavProps) { const pathname = usePathname(); const t = useTranslations(); const base = `/dashboard/${guildId}`; @@ -46,39 +112,50 @@ export function Sidebar({ guildId }: SidebarProps) { })); return ( -