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}
-
-
- {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}
-
+
${body}
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')}
-
-
-
-
-
-
-
-
-
- {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 `
+
+