From 25181192571113fe4275818c5615400c7762b953 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 12:28:42 +0200 Subject: [PATCH] Enhance bot functionality with AutoMod, Logging, Welcome, and Verification features - Implemented AutoMod capabilities including spam filtering, anti-raid, and anti-nuke actions. - Added Logging module to track moderation actions and events across the bot. - Introduced Welcome module for customizable welcome messages and autoroles. - Developed Verification system with captcha and role assignment features. - Updated Prisma schema to include new models for AutoMod, Logging, Welcome, and Verification. - Enhanced command localization for new features in both German and English. - Improved health server to handle captcha verification requests. - Added new environment variable for public base URL to support captcha links. --- .dockerignore | 12 + .env.example | 1 + apps/bot/Dockerfile | 20 +- apps/bot/package.json | 1 + .../migration.sql | 148 +++++ apps/bot/prisma/schema.prisma | 107 ++++ apps/bot/src/commands.ts | 10 +- apps/bot/src/env.ts | 3 +- apps/bot/src/guild.ts | 21 + apps/bot/src/health.ts | 120 +++- apps/bot/src/index.ts | 28 +- apps/bot/src/jobs.ts | 73 ++- apps/bot/src/modules/automod/actions.ts | 287 ++++++++++ .../modules/automod/command-definitions.ts | 9 + apps/bot/src/modules/automod/commands.ts | 57 ++ apps/bot/src/modules/automod/filters.ts | 231 ++++++++ apps/bot/src/modules/automod/service.ts | 82 +++ apps/bot/src/modules/logging/events.ts | 537 ++++++++++++++++++ apps/bot/src/modules/moderation/service.ts | 13 +- .../verification/command-definitions.ts | 53 ++ apps/bot/src/modules/verification/commands.ts | 84 +++ apps/bot/src/modules/verification/events.ts | 27 + apps/bot/src/modules/verification/handlers.ts | 149 +++++ apps/bot/src/modules/verification/service.ts | 115 ++++ .../modules/welcome/command-definitions.ts | 14 + apps/bot/src/modules/welcome/commands.ts | 56 ++ apps/bot/src/modules/welcome/events.ts | 75 +++ apps/bot/src/modules/welcome/renderer.ts | 107 ++++ apps/bot/src/modules/welcome/service.ts | 56 ++ apps/bot/src/queues.ts | 11 + docker-compose.yml | 10 + docs/PHASE-TRACKING.md | 222 +++++--- packages/shared/src/audit.ts | 3 +- packages/shared/src/command-locales.ts | 60 ++ packages/shared/src/index.ts | 55 +- packages/shared/src/phase2.test.ts | 42 ++ packages/shared/src/phase2.ts | 211 +++++++ 37 files changed, 3002 insertions(+), 108 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/bot/prisma/migrations/20260722140000_phase2_modules/migration.sql create mode 100644 apps/bot/src/guild.ts create mode 100644 apps/bot/src/modules/automod/actions.ts create mode 100644 apps/bot/src/modules/automod/command-definitions.ts create mode 100644 apps/bot/src/modules/automod/commands.ts create mode 100644 apps/bot/src/modules/automod/filters.ts create mode 100644 apps/bot/src/modules/automod/service.ts create mode 100644 apps/bot/src/modules/logging/events.ts create mode 100644 apps/bot/src/modules/verification/command-definitions.ts create mode 100644 apps/bot/src/modules/verification/commands.ts create mode 100644 apps/bot/src/modules/verification/events.ts create mode 100644 apps/bot/src/modules/verification/handlers.ts create mode 100644 apps/bot/src/modules/verification/service.ts create mode 100644 apps/bot/src/modules/welcome/command-definitions.ts create mode 100644 apps/bot/src/modules/welcome/commands.ts create mode 100644 apps/bot/src/modules/welcome/events.ts create mode 100644 apps/bot/src/modules/welcome/renderer.ts create mode 100644 apps/bot/src/modules/welcome/service.ts create mode 100644 apps/bot/src/queues.ts create mode 100644 packages/shared/src/phase2.test.ts create mode 100644 packages/shared/src/phase2.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..51a1338 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.git +.turbo +dist +coverage +.env +.env.local +apps/*/dist +apps/*/.next +packages/*/dist +**/*.md +.cursor diff --git a/.env.example b/.env.example index a5ac9eb..32cb1f2 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,4 @@ BACKUP_RETENTION_DAYS=14 BACKUP_CRON=0 3 * * * BACKUP_DIR=/backups HEALTH_PORT=8080 +PUBLIC_BASE_URL=http://localhost:8080 diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile index 3d28408..cc94409 100644 --- a/apps/bot/Dockerfile +++ b/apps/bot/Dockerfile @@ -1,6 +1,7 @@ FROM node:22-alpine AS base WORKDIR /app RUN corepack enable +RUN apk add --no-cache openssl FROM base AS deps COPY package.json pnpm-workspace.yaml turbo.json tsconfig.base.json ./ @@ -9,18 +10,19 @@ COPY packages/shared/package.json packages/shared/package.json RUN pnpm install --frozen-lockfile=false FROM deps AS build -COPY . . +COPY apps ./apps +COPY packages ./packages +COPY eslint.config.js .eslintrc.cjs .prettierrc ./ +RUN pnpm install --frozen-lockfile=false RUN pnpm --filter @nexumi/shared build RUN pnpm --filter @nexumi/bot prisma:generate RUN pnpm --filter @nexumi/bot build -FROM node:22-alpine AS runner -WORKDIR /app -RUN corepack enable +FROM base AS runner RUN apk add --no-cache postgresql16-client +COPY --from=build /app/package.json ./package.json +COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/apps/bot/dist ./apps/bot/dist -COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma -COPY --from=build /app/packages/shared/dist ./packages/shared/dist -COPY --from=build /app/apps/bot/package.json ./apps/bot/package.json -CMD ["node", "apps/bot/dist/index.js"] +COPY --from=build /app/apps/bot ./apps/bot +COPY --from=build /app/packages/shared ./packages/shared +CMD ["sh", "-c", "pnpm --filter @nexumi/bot prisma:migrate && node apps/bot/dist/index.js"] diff --git a/apps/bot/package.json b/apps/bot/package.json index 59b5bfa..94f9719 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@nexumi/shared": "workspace:*", + "@napi-rs/canvas": "^0.1.65", "@prisma/client": "^5.22.0", "bullmq": "^5.34.0", "discord.js": "^14.17.3", diff --git a/apps/bot/prisma/migrations/20260722140000_phase2_modules/migration.sql b/apps/bot/prisma/migrations/20260722140000_phase2_modules/migration.sql new file mode 100644 index 0000000..ce441bd --- /dev/null +++ b/apps/bot/prisma/migrations/20260722140000_phase2_modules/migration.sql @@ -0,0 +1,148 @@ +-- CreateTable +CREATE TABLE "AutoModConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "antiRaidEnabled" BOOLEAN NOT NULL DEFAULT false, + "antiRaidJoinThreshold" INTEGER NOT NULL DEFAULT 10, + "antiRaidWindowSeconds" INTEGER NOT NULL DEFAULT 10, + "antiRaidAction" TEXT NOT NULL DEFAULT 'LOCKDOWN', + "antiNukeEnabled" BOOLEAN NOT NULL DEFAULT false, + "antiNukeBanThreshold" INTEGER NOT NULL DEFAULT 5, + "antiNukeChannelThreshold" INTEGER NOT NULL DEFAULT 3, + "antiNukeWindowSeconds" INTEGER NOT NULL DEFAULT 60, + "lockdownActive" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutoModConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AutoModRule" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "ruleType" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "action" TEXT NOT NULL DEFAULT 'DELETE', + "threshold" JSONB, + "exceptions" JSONB, + "wordList" JSONB, + "durationMs" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutoModRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LoggingConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "ignoreBots" BOOLEAN NOT NULL DEFAULT true, + "ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "ignoredRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LoggingConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LogChannel" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LogChannel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WelcomeConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "welcomeEnabled" BOOLEAN NOT NULL DEFAULT false, + "leaveEnabled" BOOLEAN NOT NULL DEFAULT false, + "welcomeChannelId" TEXT, + "leaveChannelId" TEXT, + "welcomeType" TEXT NOT NULL DEFAULT 'TEXT', + "welcomeContent" TEXT, + "welcomeEmbed" JSONB, + "leaveContent" TEXT, + "leaveEmbed" JSONB, + "welcomeDmEnabled" BOOLEAN NOT NULL DEFAULT false, + "welcomeDmContent" TEXT, + "userAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "botAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "imageTitle" TEXT, + "imageSubtitle" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WelcomeConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "channelId" TEXT, + "verifiedRoleId" TEXT, + "unverifiedRoleId" TEXT, + "mode" TEXT NOT NULL DEFAULT 'BUTTON', + "minAccountAgeDays" INTEGER NOT NULL DEFAULT 0, + "failAction" TEXT NOT NULL DEFAULT 'KICK', + "panelChannelId" TEXT, + "panelMessageId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VerificationConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AutoModConfig_guildId_key" ON "AutoModConfig"("guildId"); + +-- CreateIndex +CREATE INDEX "AutoModRule_guildId_enabled_idx" ON "AutoModRule"("guildId", "enabled"); + +-- CreateIndex +CREATE UNIQUE INDEX "AutoModRule_guildId_ruleType_key" ON "AutoModRule"("guildId", "ruleType"); + +-- CreateIndex +CREATE UNIQUE INDEX "LoggingConfig_guildId_key" ON "LoggingConfig"("guildId"); + +-- CreateIndex +CREATE INDEX "LogChannel_guildId_idx" ON "LogChannel"("guildId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LogChannel_guildId_eventType_key" ON "LogChannel"("guildId", "eventType"); + +-- CreateIndex +CREATE UNIQUE INDEX "WelcomeConfig_guildId_key" ON "WelcomeConfig"("guildId"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationConfig_guildId_key" ON "VerificationConfig"("guildId"); + +-- AddForeignKey +ALTER TABLE "AutoModConfig" ADD CONSTRAINT "AutoModConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AutoModRule" ADD CONSTRAINT "AutoModRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoggingConfig" ADD CONSTRAINT "LoggingConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LogChannel" ADD CONSTRAINT "LogChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WelcomeConfig" ADD CONSTRAINT "WelcomeConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VerificationConfig" ADD CONSTRAINT "VerificationConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index f3859a0..dfde187 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -14,6 +14,12 @@ model Guild { settings GuildSettings? cases Case[] escalationRules EscalationRule[] + autoModConfig AutoModConfig? + autoModRules AutoModRule[] + loggingConfig LoggingConfig? + logChannels LogChannel[] + welcomeConfig WelcomeConfig? + verificationConfig VerificationConfig? } model GuildSettings { @@ -96,3 +102,104 @@ model EscalationRule { @@unique([guildId, warnCount]) @@index([guildId, enabled]) } + +model AutoModConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(true) + antiRaidEnabled Boolean @default(false) + antiRaidJoinThreshold Int @default(10) + antiRaidWindowSeconds Int @default(10) + antiRaidAction String @default("LOCKDOWN") + antiNukeEnabled Boolean @default(false) + antiNukeBanThreshold Int @default(5) + antiNukeChannelThreshold Int @default(3) + antiNukeWindowSeconds Int @default(60) + lockdownActive Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model AutoModRule { + id String @id @default(cuid()) + guildId String + ruleType String + enabled Boolean @default(true) + action String @default("DELETE") + threshold Json? + exceptions Json? + wordList Json? + durationMs Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, ruleType]) + @@index([guildId, enabled]) +} + +model LoggingConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(true) + ignoreBots Boolean @default(true) + ignoredChannelIds String[] @default([]) + ignoredRoleIds String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model LogChannel { + id String @id @default(cuid()) + guildId String + eventType String + channelId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, eventType]) + @@index([guildId]) +} + +model WelcomeConfig { + id String @id @default(cuid()) + guildId String @unique + welcomeEnabled Boolean @default(false) + leaveEnabled Boolean @default(false) + welcomeChannelId String? + leaveChannelId String? + welcomeType String @default("TEXT") + welcomeContent String? + welcomeEmbed Json? + leaveContent String? + leaveEmbed Json? + welcomeDmEnabled Boolean @default(false) + welcomeDmContent String? + userAutoroleIds String[] @default([]) + botAutoroleIds String[] @default([]) + imageTitle String? + imageSubtitle String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +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) +} diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index b623d9a..c06ca9d 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -4,9 +4,17 @@ import { env } from './env.js'; import { logger } from './logger.js'; import { getGuildLocale } from './i18n.js'; import { moderationCommands } from './modules/moderation/commands.js'; +import { automodCommands } from './modules/automod/commands.js'; +import { welcomeCommands } from './modules/welcome/commands.js'; +import { verificationCommands } from './modules/verification/commands.js'; import type { BotContext, SlashCommand } from './types.js'; -const commands: SlashCommand[] = [...moderationCommands]; +const commands: SlashCommand[] = [ + ...moderationCommands, + ...automodCommands, + ...welcomeCommands, + ...verificationCommands +]; const map = new Map(commands.map((c) => [c.data.name, c])); export async function registerCommands() { diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index d48770f..e115f23 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -15,7 +15,8 @@ const EnvSchema = z.object({ BACKUP_CRON: z.string().default('0 3 * * *'), BACKUP_DIR: z.string().default('/backups'), METRICS_TOKEN: z.string().optional(), - HEALTH_PORT: z.coerce.number().int().positive().default(8080) + HEALTH_PORT: z.coerce.number().int().positive().default(8080), + PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080') }); export const env = EnvSchema.parse(process.env); diff --git a/apps/bot/src/guild.ts b/apps/bot/src/guild.ts new file mode 100644 index 0000000..da4a144 --- /dev/null +++ b/apps/bot/src/guild.ts @@ -0,0 +1,21 @@ +import type { PrismaClient } from '@prisma/client'; + +export async function ensureGuild(prisma: PrismaClient, guildId: string): Promise { + await prisma.guild.upsert({ + where: { id: guildId }, + update: {}, + create: { id: guildId } + }); +} + +export async function ensureGuildSettings( + prisma: PrismaClient, + guildId: string +): Promise { + await ensureGuild(prisma, guildId); + await prisma.guildSettings.upsert({ + where: { guildId }, + update: {}, + create: { guildId } + }); +} diff --git a/apps/bot/src/health.ts b/apps/bot/src/health.ts index 9e085dd..e7ada71 100644 --- a/apps/bot/src/health.ts +++ b/apps/bot/src/health.ts @@ -1,19 +1,120 @@ -import { createServer } from 'node:http'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { env } from './env.js'; +import { redis } from './redis.js'; +import { + deleteCaptchaChallenge, + getCaptchaChallenge, + hashCaptchaAnswer +} from './modules/verification/service.js'; +import { verificationQueue } from './queues.js'; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function parseFormBody(body: string): Record { + return Object.fromEntries(new URLSearchParams(body)); +} + +function renderCaptchaPage(token: string, question: string, error?: string): string { + const errorBlock = error ? `

${error}

` : ''; + return ` + + + + Nexumi Verification + + + +
+

Nexumi Verification

+

Solve: ${question} = ?

+ ${errorBlock} + + + +
+ +`; +} + +async function handleCaptchaGet(url: URL, res: ServerResponse): Promise { + const token = url.searchParams.get('token'); + if (!token) { + res.statusCode = 400; + res.end('Missing token'); + return; + } + const challenge = await getCaptchaChallenge(redis, token); + if (!challenge) { + res.statusCode = 404; + res.end('Captcha expired'); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderCaptchaPage(token, challenge.question)); +} + +async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise { + const body = await readBody(req); + const form = parseFormBody(body); + const token = form.token; + const answer = form.answer; + if (!token || !answer) { + res.statusCode = 400; + res.end('Missing fields'); + return; + } + const challenge = await getCaptchaChallenge(redis, token); + if (!challenge) { + res.statusCode = 404; + res.end('Captcha expired'); + return; + } + if (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.')); + return; + } + await deleteCaptchaChallenge(redis, token); + await verificationQueue.add( + 'verificationComplete', + { guildId: challenge.guildId, userId: challenge.userId }, + { removeOnComplete: 1000, removeOnFail: 500 } + ); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end('

Verified

You can return to Discord.

'); +} export function startHealthServer(): void { - const server = createServer((req, res) => { + const server = createServer(async (req, res) => { if (!req.url) { res.statusCode = 400; res.end('Bad Request'); return; } - if (req.url === '/health') { + const url = new URL(req.url, `http://127.0.0.1:${env.HEALTH_PORT}`); + + if (url.pathname === '/health') { res.statusCode = 200; res.end('ok'); return; } - if (req.url === '/metrics') { + + if (url.pathname === '/metrics') { const auth = req.headers.authorization; if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) { res.statusCode = 401; @@ -24,6 +125,17 @@ export function startHealthServer(): void { res.end('# Prometheus metrics scaffold\nnexumi_up 1\n'); return; } + + if (url.pathname === '/verify/captcha' && req.method === 'GET') { + await handleCaptchaGet(url, res); + return; + } + + if (url.pathname === '/verify/captcha' && req.method === 'POST') { + await handleCaptchaPost(req, res); + return; + } + res.statusCode = 404; res.end('Not Found'); }); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 2d4ea07..5cfe2e3 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -20,6 +20,12 @@ import { } 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'; const isShard = process.argv.includes('--shard'); @@ -38,20 +44,35 @@ if (!isShard) { GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildModeration, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildEmojisAndStickers, + GatewayIntentBits.GuildInvites, GatewayIntentBits.MessageContent ], - partials: [Partials.Channel] + partials: [Partials.Channel, Partials.Message, Partials.GuildMember] }); const context: BotContext = { client, prisma, redis }; startWorkers(context); await ensureRecurringJobs(); + registerLoggingEvents(context); + registerWelcomeEvents(context); + registerVerificationEvents(context); client.on(Events.ClientReady, async () => { logger.info({ tag: client.user?.tag }, 'Bot ready'); await registerCommands(); }); + client.on(Events.MessageCreate, async (message) => { + try { + await handleAutoModMessage(context, message); + } catch (error) { + logger.error({ error, messageId: message.id }, 'AutoMod message handler failed'); + } + }); + client.on(Events.InteractionCreate, async (interaction: Interaction) => { try { if (interaction.isChatInputCommand()) { @@ -63,6 +84,11 @@ if (!isShard) { await handleModerationConfirmation(interaction, context); return; } + + if (interaction.isButton() && isVerificationButton(interaction.customId)) { + await handleVerificationButton(interaction, context); + return; + } } catch (error) { logger.error({ error }, 'Interaction handling failed'); const locale = await getGuildLocale(context.prisma, interaction.guildId); diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index d212650..b72a668 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -1,4 +1,4 @@ -import { Queue, Worker } from 'bullmq'; +import { Worker } from 'bullmq'; import { PermissionFlagsBits } from 'discord.js'; import { mkdir, readdir, rm, stat } from 'node:fs/promises'; import { basename, join } from 'node:path'; @@ -7,11 +7,24 @@ import { redis } from './redis.js'; import { logger } from './logger.js'; import type { BotContext } from './types.js'; import { env } from './env.js'; +import { refreshPhishingDomains } from './modules/automod/filters.js'; +import { completeVerification } from './modules/verification/handlers.js'; +import { + automodQueue, + automodQueueName, + backupQueue, + backupQueueName, + moderationQueueName, + verificationQueueName +} from './queues.js'; -export const moderationQueueName = 'moderation'; -export const moderationQueue = new Queue(moderationQueueName, { connection: redis }); -export const backupQueueName = 'backups'; -export const backupQueue = new Queue(backupQueueName, { connection: redis }); +export { + automodQueue, + backupQueue, + moderationQueue, + moderationQueueName, + verificationQueue +} from './queues.js'; type TempBanJob = { guildId: string; @@ -19,6 +32,11 @@ type TempBanJob = { reason: string | null; }; +type VerificationCompleteJob = { + guildId: string; + userId: string; +}; + export function startWorkers(context: BotContext): Worker[] { const moderationWorker = new Worker( moderationQueueName, @@ -56,7 +74,38 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Backup job failed'); }); - return [moderationWorker, backupWorker]; + const automodWorker = new Worker( + automodQueueName, + async (job) => { + if (job.name !== 'refreshPhishingList') { + return; + } + const count = await refreshPhishingDomains(redis); + logger.info({ domainCount: count }, 'Phishing domain list refreshed'); + }, + { connection: redis } + ); + + automodWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'AutoMod job failed'); + }); + + const verificationWorker = new Worker( + verificationQueueName, + async (job) => { + if (job.name !== 'verificationComplete') { + return; + } + await completeVerification(context, job.data.guildId, job.data.userId); + }, + { connection: redis } + ); + + verificationWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Verification job failed'); + }); + + return [moderationWorker, backupWorker, automodWorker, verificationWorker]; } export async function ensureRecurringJobs(): Promise { @@ -69,6 +118,18 @@ export async function ensureRecurringJobs(): Promise { removeOnFail: 100 } ); + + await automodQueue.add( + 'refreshPhishingList', + {}, + { + repeat: { pattern: '0 */6 * * *' }, + removeOnComplete: 20, + removeOnFail: 20 + } + ); + + await automodQueue.add('refreshPhishingList', {}, { removeOnComplete: 20, removeOnFail: 20 }); } async function runPgDumpBackup(): Promise { diff --git a/apps/bot/src/modules/automod/actions.ts b/apps/bot/src/modules/automod/actions.ts new file mode 100644 index 0000000..1ea2695 --- /dev/null +++ b/apps/bot/src/modules/automod/actions.ts @@ -0,0 +1,287 @@ +import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js'; +import type { BotContext } from '../../types.js'; +import { createCase } from '../moderation/service.js'; +import { + getAutoModConfig, + getEnabledAutoModRules, + isExcepted, + parseExceptions, + parseThreshold, + parseWordList +} from './service.js'; +import { evaluateRule, loadPhishingDomains, type FilterResult } from './filters.js'; +import type { AutoModRuleType } from '@nexumi/shared'; +import { logger } from '../../logger.js'; + +async function applyAutoModAction( + context: BotContext, + message: Message, + ruleType: AutoModRuleType, + action: string, + reason: string, + durationMs?: number | null +): Promise { + const guild = message.guild; + if (!guild || !message.member) { + return; + } + + const me = guild.members.me; + if (!me) { + return; + } + + if (me.permissions.has(PermissionFlagsBits.ManageMessages) && message.deletable) { + await message.delete().catch((error) => { + logger.warn({ error, messageId: message.id }, 'AutoMod failed to delete message'); + }); + } + + const moderatorId = context.client.user?.id ?? guild.client.user?.id ?? '0'; + const baseReason = `AutoMod (${ruleType}): ${reason}`; + + if (action === 'WARN') { + await context.prisma.warning.create({ + data: { + guildId: guild.id, + userId: message.author.id, + moderatorId, + reason: baseReason + } + }); + await createCase(context, { + guildId: guild.id, + targetUserId: message.author.id, + moderatorId, + action: 'WARN_ADD', + reason: baseReason, + channelId: message.channel.id, + source: 'automod', + metadata: { ruleType, automod: true } + }); + return; + } + + if (action === 'TIMEOUT' && durationMs && durationMs > 0) { + if (me.permissions.has(PermissionFlagsBits.ModerateMembers)) { + await message.member.timeout(durationMs, baseReason); + await createCase(context, { + guildId: guild.id, + targetUserId: message.author.id, + moderatorId, + action: 'TIMEOUT', + reason: baseReason, + channelId: message.channel.id, + source: 'automod', + metadata: { ruleType, automod: true, durationMs } + }); + } + return; + } + + if (action === 'KICK') { + if (me.permissions.has(PermissionFlagsBits.KickMembers)) { + await message.member.kick(baseReason); + await createCase(context, { + guildId: guild.id, + targetUserId: message.author.id, + moderatorId, + action: 'KICK', + reason: baseReason, + channelId: message.channel.id, + source: 'automod', + metadata: { ruleType, automod: true } + }); + } + return; + } + + if (action === 'BAN') { + if (me.permissions.has(PermissionFlagsBits.BanMembers)) { + await guild.members.ban(message.author.id, { reason: baseReason }); + await createCase(context, { + guildId: guild.id, + targetUserId: message.author.id, + moderatorId, + action: 'BAN', + reason: baseReason, + channelId: message.channel.id, + source: 'automod', + metadata: { ruleType, automod: true } + }); + } + } +} + +export async function handleAutoModMessage( + context: BotContext, + message: Message +): Promise { + if (!message.guild || message.author.bot || !message.content) { + return null; + } + + const config = await getAutoModConfig(context.prisma, message.guild.id); + if (!config.enabled) { + return null; + } + + const member = message.member ?? (await message.guild.members.fetch(message.author.id)); + const roleIds = member.roles.cache.map((role) => role.id); + const rules = await getEnabledAutoModRules(context.prisma, message.guild.id); + const phishingDomains = await loadPhishingDomains(context.redis); + + const filterCtx = { + content: message.content, + authorId: message.author.id, + channelId: message.channel.id, + roleIds, + redis: context.redis, + phishingDomains + }; + + for (const rule of rules) { + const exceptions = parseExceptions(rule.exceptions); + if (isExcepted(exceptions, message.channel.id, roleIds)) { + continue; + } + + const threshold = parseThreshold(rule.threshold); + const wordList = parseWordList(rule.wordList); + const result = await evaluateRule( + rule.ruleType as AutoModRuleType, + filterCtx, + threshold, + wordList + ); + + if (result) { + await applyAutoModAction( + context, + message, + result.ruleType, + rule.action, + result.reason, + rule.durationMs + ); + return result; + } + } + + return null; +} + +export async function activateLockdown(guild: Guild): Promise { + for (const channel of guild.channels.cache.values()) { + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + continue; + } + const everyone = guild.roles.everyone; + await channel.permissionOverwrites.edit(everyone, { + SendMessages: false + }); + } +} + +export async function deactivateLockdown(guild: Guild): Promise { + for (const channel of guild.channels.cache.values()) { + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + continue; + } + const everyone = guild.roles.everyone; + await channel.permissionOverwrites.edit(everyone, { + SendMessages: null + }); + } +} + +export async function handleAntiRaidJoin( + context: BotContext, + member: GuildMember +): Promise { + const config = await getAutoModConfig(context.prisma, member.guild.id); + if (!config.antiRaidEnabled) { + return; + } + + const key = `automod:raid:${member.guild.id}`; + const count = await context.redis.incr(key); + if (count === 1) { + await context.redis.expire(key, config.antiRaidWindowSeconds); + } + + if (count < config.antiRaidJoinThreshold) { + return; + } + + if (config.lockdownActive) { + return; + } + + await context.prisma.autoModConfig.update({ + where: { guildId: member.guild.id }, + data: { lockdownActive: true } + }); + + if (config.antiRaidAction === 'LOCKDOWN') { + await activateLockdown(member.guild); + } + + 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.` + ); + } +} + +export async function handleAntiNukeAction( + context: BotContext, + guildId: string, + executorId: string, + actionType: 'ban' | 'channel_delete' +): Promise { + const config = await getAutoModConfig(context.prisma, guildId); + if (!config.antiNukeEnabled) { + return; + } + + const botId = context.client.user?.id; + if (!botId || executorId === botId) { + return; + } + + const key = `automod:nuke:${guildId}:${executorId}:${actionType}`; + const count = await context.redis.incr(key); + if (count === 1) { + await context.redis.expire(key, config.antiNukeWindowSeconds); + } + + const threshold = + actionType === 'ban' ? config.antiNukeBanThreshold : config.antiNukeChannelThreshold; + if (count < threshold) { + return; + } + + const guild = await context.client.guilds.fetch(guildId); + const executor = await guild.members.fetch(executorId).catch(() => null); + if (!executor) { + return; + } + + const me = guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.Administrator)) { + return; + } + + await executor.roles.set([], 'Anti-nuke: mass destructive action detected'); + await createCase(context, { + guildId, + targetUserId: executorId, + moderatorId: botId, + action: 'ANTI_NUKE', + reason: `Anti-nuke triggered (${actionType}: ${count} in ${config.antiNukeWindowSeconds}s)`, + source: 'automod', + metadata: { actionType, count, antiNuke: true } + }); +} diff --git a/apps/bot/src/modules/automod/command-definitions.ts b/apps/bot/src/modules/automod/command-definitions.ts new file mode 100644 index 0000000..7b5a77c --- /dev/null +++ b/apps/bot/src/modules/automod/command-definitions.ts @@ -0,0 +1,9 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { applyCommandDescription } from '@nexumi/shared'; + +export const automodStatusCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('automod').addSubcommand((s) => + applyCommandDescription(s.setName('status'), 'automod.status.description') + ), + 'automod.description' +); diff --git a/apps/bot/src/modules/automod/commands.ts b/apps/bot/src/modules/automod/commands.ts new file mode 100644 index 0000000..bf39d9d --- /dev/null +++ b/apps/bot/src/modules/automod/commands.ts @@ -0,0 +1,57 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { t, tf } from '@nexumi/shared'; +import type { SlashCommand } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { requirePermission } from '../../permissions.js'; +import { automodStatusCommandData } from './command-definitions.js'; +import { getAutoModConfig, getEnabledAutoModRules } from './service.js'; + +const automodCommand: SlashCommand = { + data: automodStatusCommandData, + 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 }); + return; + } + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { + return; + } + + const sub = interaction.options.getSubcommand(); + if (sub !== 'status') { + return; + } + + const guildId = interaction.guildId!; + const config = await getAutoModConfig(context.prisma, guildId); + const rules = await getEnabledAutoModRules(context.prisma, guildId); + + const lines = rules.map((rule) => + tf(locale, 'automod.status.ruleLine', { + type: rule.ruleType, + action: rule.action + }) + ); + + const body = [ + tf(locale, 'automod.status.header', { + enabled: config.enabled ? t(locale, 'automod.status.yes') : t(locale, 'automod.status.no'), + antiRaid: config.antiRaidEnabled + ? t(locale, 'automod.status.yes') + : t(locale, 'automod.status.no'), + antiNuke: config.antiNukeEnabled + ? t(locale, 'automod.status.yes') + : t(locale, 'automod.status.no'), + lockdown: config.lockdownActive + ? t(locale, 'automod.status.yes') + : t(locale, 'automod.status.no') + }), + lines.length > 0 ? lines.join('\n') : t(locale, 'automod.status.noRules') + ].join('\n\n'); + + await interaction.reply({ content: body, ephemeral: true }); + } +}; + +export const automodCommands: SlashCommand[] = [automodCommand]; diff --git a/apps/bot/src/modules/automod/filters.ts b/apps/bot/src/modules/automod/filters.ts new file mode 100644 index 0000000..5b68fa3 --- /dev/null +++ b/apps/bot/src/modules/automod/filters.ts @@ -0,0 +1,231 @@ +import type { Redis } from 'ioredis'; +import { + capsRatio, + countEmojis, + extractUrls, + hasZalgo, + isDiscordInvite, + normalizeHost, + type AutoModRuleType, + type AutoModThreshold, + type AutoModWordList +} from '@nexumi/shared'; + +const PHISHING_CACHE_KEY = 'automod:phishing-domains'; + +export type FilterContext = { + content: string; + authorId: string; + channelId: string; + roleIds: string[]; + redis: Redis; + phishingDomains: Set; +}; + +export type FilterResult = { + matched: boolean; + ruleType: AutoModRuleType; + reason: string; +}; + +export async function loadPhishingDomains(redis: Redis): Promise> { + const cached = await redis.smembers(PHISHING_CACHE_KEY); + if (cached.length > 0) { + return new Set(cached); + } + return new Set(); +} + +export async function refreshPhishingDomains(redis: Redis): Promise { + const response = await fetch( + 'https://raw.githubusercontent.com/Discord-AntiScam/scam-links/main/list.txt' + ); + if (!response.ok) { + throw new Error(`Failed to fetch phishing list: ${response.status}`); + } + const text = await response.text(); + const domains = text + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter((line) => line.length > 0 && !line.startsWith('#')); + + const pipeline = redis.pipeline(); + pipeline.del(PHISHING_CACHE_KEY); + if (domains.length > 0) { + pipeline.sadd(PHISHING_CACHE_KEY, ...domains); + } + pipeline.expire(PHISHING_CACHE_KEY, 60 * 60 * 24); + await pipeline.exec(); + return domains.length; +} + +async function checkSpam( + ctx: FilterContext, + threshold: AutoModThreshold +): Promise { + const maxMessages = threshold.maxMessages ?? 5; + const windowSeconds = threshold.windowSeconds ?? 5; + const key = `automod:spam:${ctx.authorId}:${ctx.channelId}`; + const count = await ctx.redis.incr(key); + if (count === 1) { + await ctx.redis.expire(key, windowSeconds); + } + if (count > maxMessages) { + return { matched: true, ruleType: 'SPAM', reason: 'Message rate exceeded' }; + } + return null; +} + +function checkMassMention(content: string, threshold: AutoModThreshold): FilterResult | null { + const maxMentions = threshold.maxMentions ?? 5; + const mentions = (content.match(/<@[!&]?\d+>/g) ?? []).length; + if (mentions >= maxMentions) { + return { matched: true, ruleType: 'MASS_MENTION', reason: `${mentions} mentions` }; + } + return null; +} + +function checkCaps(content: string, threshold: AutoModThreshold): FilterResult | null { + const minLength = threshold.minLength ?? 10; + const capsPercent = threshold.capsPercent ?? 70; + if (content.length < minLength) { + return null; + } + if (capsRatio(content) >= capsPercent) { + return { matched: true, ruleType: 'CAPS', reason: 'Excessive caps' }; + } + return null; +} + +function checkInviteLink(content: string): FilterResult | null { + if (isDiscordInvite(content)) { + return { matched: true, ruleType: 'INVITE_LINK', reason: 'Discord invite link' }; + } + return null; +} + +function checkExternalLink(content: string, threshold: AutoModThreshold): FilterResult | null { + const whitelist = (threshold.linkWhitelist ?? []).map((h) => h.toLowerCase()); + const blacklist = (threshold.linkBlacklist ?? []).map((h) => h.toLowerCase()); + const urls = extractUrls(content); + for (const url of urls) { + const host = normalizeHost(url); + if (!host) { + continue; + } + if (blacklist.some((entry) => host.includes(entry))) { + return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Blocked host: ${host}` }; + } + if (whitelist.length > 0 && !whitelist.some((entry) => host.includes(entry))) { + return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Non-whitelisted host: ${host}` }; + } + } + return null; +} + +function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null { + const lower = content.toLowerCase(); + for (const word of wordList.words) { + if (word.length > 0 && lower.includes(word.toLowerCase())) { + return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' }; + } + } + for (const pattern of wordList.regexPatterns) { + try { + const regex = new RegExp(pattern, 'i'); + if (regex.test(content)) { + return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' }; + } + } catch { + continue; + } + } + return null; +} + +async function checkDuplicate( + ctx: FilterContext, + threshold: AutoModThreshold +): Promise { + const duplicateCount = threshold.duplicateCount ?? 3; + const windowSeconds = threshold.windowSeconds ?? 30; + const normalized = ctx.content.trim().toLowerCase(); + if (normalized.length < 3) { + return null; + } + const key = `automod:dup:${ctx.authorId}:${ctx.channelId}`; + const count = await ctx.redis.incr(`${key}:${normalized}`); + if (count === 1) { + await ctx.redis.expire(`${key}:${normalized}`, windowSeconds); + } + if (count >= duplicateCount) { + return { matched: true, ruleType: 'DUPLICATE', reason: 'Duplicate message' }; + } + return null; +} + +function checkEmojiSpam(content: string, threshold: AutoModThreshold): FilterResult | null { + const maxEmojis = threshold.maxEmojis ?? 10; + const emojiCount = countEmojis(content); + if (emojiCount >= maxEmojis) { + return { matched: true, ruleType: 'EMOJI_SPAM', reason: `${emojiCount} emojis` }; + } + return null; +} + +function checkZalgo(content: string): FilterResult | null { + if (hasZalgo(content)) { + return { matched: true, ruleType: 'ZALGO', reason: 'Zalgo text detected' }; + } + return null; +} + +function checkPhishing(content: string, domains: Set): FilterResult | null { + if (domains.size === 0) { + return null; + } + for (const url of extractUrls(content)) { + const host = normalizeHost(url); + if (!host) { + continue; + } + for (const domain of domains) { + if (host === domain || host.endsWith(`.${domain}`)) { + return { matched: true, ruleType: 'PHISHING', reason: `Phishing domain: ${host}` }; + } + } + } + return null; +} + +export async function evaluateRule( + ruleType: AutoModRuleType, + ctx: FilterContext, + threshold: AutoModThreshold, + wordList: AutoModWordList +): Promise { + switch (ruleType) { + case 'SPAM': + return checkSpam(ctx, threshold); + case 'MASS_MENTION': + return checkMassMention(ctx.content, threshold); + case 'CAPS': + return checkCaps(ctx.content, threshold); + case 'INVITE_LINK': + return checkInviteLink(ctx.content); + case 'EXTERNAL_LINK': + return checkExternalLink(ctx.content, threshold); + case 'WORD_FILTER': + return checkWordFilter(ctx.content, wordList); + case 'DUPLICATE': + return checkDuplicate(ctx, threshold); + case 'EMOJI_SPAM': + return checkEmojiSpam(ctx.content, threshold); + case 'ZALGO': + return checkZalgo(ctx.content); + case 'PHISHING': + return checkPhishing(ctx.content, ctx.phishingDomains); + default: + return null; + } +} diff --git a/apps/bot/src/modules/automod/service.ts b/apps/bot/src/modules/automod/service.ts new file mode 100644 index 0000000..b70bf97 --- /dev/null +++ b/apps/bot/src/modules/automod/service.ts @@ -0,0 +1,82 @@ +import type { PrismaClient } from '@prisma/client'; +import { + DEFAULT_AUTOMOD_RULES, + type AutoModExceptions, + type AutoModThreshold, + type AutoModWordList +} from '@nexumi/shared'; +import { ensureGuild } from '../../guild.js'; + +export async function getAutoModConfig(prisma: PrismaClient, guildId: string) { + await ensureGuild(prisma, guildId); + let config = await prisma.autoModConfig.findUnique({ where: { guildId } }); + if (!config) { + config = await prisma.autoModConfig.create({ data: { guildId } }); + } + return config; +} + +export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) { + await ensureGuild(prisma, guildId); + const existing = await prisma.autoModRule.count({ where: { guildId } }); + if (existing > 0) { + return; + } + await prisma.autoModRule.createMany({ + data: DEFAULT_AUTOMOD_RULES.map((rule) => ({ + guildId, + ruleType: rule.ruleType, + action: rule.action, + threshold: rule.threshold ?? undefined, + wordList: rule.wordList ?? undefined, + enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) + })) + }); +} + +export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) { + await ensureDefaultAutoModRules(prisma, guildId); + return prisma.autoModRule.findMany({ + where: { guildId, enabled: true } + }); +} + +export function parseExceptions(raw: unknown): AutoModExceptions { + if (!raw || typeof raw !== 'object') { + return { roleIds: [], channelIds: [] }; + } + const obj = raw as Record; + return { + roleIds: Array.isArray(obj.roleIds) ? obj.roleIds.map(String) : [], + channelIds: Array.isArray(obj.channelIds) ? obj.channelIds.map(String) : [] + }; +} + +export function parseThreshold(raw: unknown): AutoModThreshold { + if (!raw || typeof raw !== 'object') { + return {}; + } + return raw as AutoModThreshold; +} + +export function parseWordList(raw: unknown): AutoModWordList { + if (!raw || typeof raw !== 'object') { + return { words: [], regexPatterns: [] }; + } + const obj = raw as Record; + return { + words: Array.isArray(obj.words) ? obj.words.map(String) : [], + regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [] + }; +} + +export function isExcepted( + exceptions: AutoModExceptions, + channelId: string, + roleIds: string[] +): boolean { + if (exceptions.channelIds.includes(channelId)) { + return true; + } + return roleIds.some((roleId) => exceptions.roleIds.includes(roleId)); +} diff --git a/apps/bot/src/modules/logging/events.ts b/apps/bot/src/modules/logging/events.ts new file mode 100644 index 0000000..448cab6 --- /dev/null +++ b/apps/bot/src/modules/logging/events.ts @@ -0,0 +1,537 @@ +import { + AuditLogEvent, + EmbedBuilder, + type Guild, + type GuildMember, + type TextChannel +} from 'discord.js'; +import type { BotContext } from '../../types.js'; +import type { LogEventType } from '@nexumi/shared'; +import { ensureGuild } from '../../guild.js'; +import { logger } from '../../logger.js'; + +export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) { + await ensureGuild(prisma, guildId); + let config = await prisma.loggingConfig.findUnique({ where: { guildId } }); + if (!config) { + config = await prisma.loggingConfig.create({ data: { guildId } }); + } + return config; +} + +export async function getLogChannelId( + prisma: BotContext['prisma'], + guildId: string, + eventType: LogEventType +): Promise { + const entry = await prisma.logChannel.findUnique({ + where: { guildId_eventType: { guildId, eventType } } + }); + return entry?.channelId ?? null; +} + +function shouldIgnoreMember( + config: Awaited>, + member: GuildMember | null, + isBot: boolean +): boolean { + if (isBot && config.ignoreBots) { + return true; + } + if (!member) { + return false; + } + return member.roles.cache.some((role) => config.ignoredRoleIds.includes(role.id)); +} + +function shouldIgnoreChannel( + config: Awaited>, + channelId: string | null +): boolean { + if (!channelId) { + return false; + } + return config.ignoredChannelIds.includes(channelId); +} + +async function resolveLogChannel( + context: BotContext, + guild: Guild, + eventType: LogEventType +): Promise { + const config = await getLoggingConfig(context.prisma, guild.id); + if (!config.enabled) { + return null; + } + const channelId = await getLogChannelId(context.prisma, guild.id, eventType); + if (!channelId) { + return null; + } + if (shouldIgnoreChannel(config, channelId)) { + return null; + } + const channel = await guild.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased() || channel.isDMBased()) { + return null; + } + return channel as TextChannel; +} + +export async function sendLogEmbed( + context: BotContext, + guild: Guild, + eventType: LogEventType, + title: string, + description: string, + color = 0x6366f1 +): Promise { + const channel = await resolveLogChannel(context, guild, eventType); + if (!channel) { + return; + } + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(description.slice(0, 4096)) + .setColor(color) + .setTimestamp(); + await channel.send({ embeds: [embed] }).catch((error) => { + logger.warn({ error, guildId: guild.id, eventType }, 'Failed to send log embed'); + }); +} + +export async function logModAction( + context: BotContext, + input: { + guildId: string; + caseNumber: number; + action: string; + targetUserId: string; + moderatorId: string; + reason?: string | null; + } +): Promise { + const guild = await context.client.guilds.fetch(input.guildId).catch(() => null); + if (!guild) { + return; + } + const description = [ + `**Case:** #${input.caseNumber}`, + `**Action:** ${input.action}`, + `**Target:** <@${input.targetUserId}> (\`${input.targetUserId}\`)`, + `**Moderator:** <@${input.moderatorId}> (\`${input.moderatorId}\`)`, + `**Reason:** ${input.reason ?? '—'}` + ].join('\n'); + await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444); +} + +export async function handleChannelDeleteForAntiNuke( + context: BotContext, + guild: Guild, + channelId: string +): Promise { + const config = await getLoggingConfig(context.prisma, guild.id); + if (shouldIgnoreChannel(config, channelId)) { + return; + } + + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.ChannelDelete, + limit: 1 + }); + const entry = auditLogs.entries.first(); + if (!entry?.executor) { + return; + } + + const { handleAntiNukeAction } = await import('../automod/actions.js'); + await handleAntiNukeAction(context, guild.id, entry.executor.id, 'channel_delete'); +} + +export async function handleBanForAntiNuke( + context: BotContext, + guild: Guild +): Promise { + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.MemberBanAdd, + limit: 1 + }); + const entry = auditLogs.entries.first(); + if (!entry?.executor) { + return; + } + + const { handleAntiNukeAction } = await import('../automod/actions.js'); + await handleAntiNukeAction(context, guild.id, entry.executor.id, 'ban'); +} + +export function memberIgnored( + config: Awaited>, + member: GuildMember | null, + isBot: boolean, + channelId?: string | null +): boolean { + if (shouldIgnoreChannel(config, channelId ?? null)) { + return true; + } + return shouldIgnoreMember(config, member, isBot); +} + +export async function registerLoggingEvents(context: BotContext): Promise { + const { client } = context; + + client.on('messageUpdate', async (oldMessage, newMessage) => { + if (!newMessage.guild || newMessage.author?.bot) { + return; + } + const config = await getLoggingConfig(context.prisma, newMessage.guild.id); + if (memberIgnored(config, newMessage.member, newMessage.author?.bot ?? false, newMessage.channel.id)) { + return; + } + const before = oldMessage.content ?? '—'; + const after = newMessage.content ?? '—'; + if (before === after) { + return; + } + await sendLogEmbed( + context, + newMessage.guild, + 'MESSAGE_EDIT', + 'Message Edited', + `**Author:** <@${newMessage.author?.id}>\n**Channel:** <#${newMessage.channel.id}>\n**Before:** ${before}\n**After:** ${after}` + ); + }); + + client.on('messageDelete', async (message) => { + if (!message.guild || message.author?.bot) { + return; + } + const config = await getLoggingConfig(context.prisma, message.guild.id); + if (memberIgnored(config, message.member, message.author?.bot ?? false, message.channel.id)) { + return; + } + await sendLogEmbed( + context, + message.guild, + 'MESSAGE_DELETE', + 'Message Deleted', + `**Author:** <@${message.author?.id}>\n**Channel:** <#${message.channel.id}>\n**Content:** ${message.content ?? '—'}` + ); + }); + + client.on('messageDeleteBulk', async (messages, channel) => { + if (!channel.guild) { + return; + } + await sendLogEmbed( + context, + channel.guild, + 'MESSAGE_BULK_DELETE', + 'Bulk Delete', + `**Channel:** <#${channel.id}>\n**Count:** ${messages.size}` + ); + }); + + client.on('guildMemberAdd', async (member) => { + const config = await getLoggingConfig(context.prisma, member.guild.id); + if (memberIgnored(config, member, member.user.bot)) { + return; + } + await sendLogEmbed( + context, + member.guild, + 'MEMBER_JOIN', + 'Member Joined', + `**User:** ${member.user.tag} (<@${member.id}>)` + ); + }); + + client.on('guildMemberRemove', async (member) => { + if (!member.guild) { + return; + } + const config = await getLoggingConfig(context.prisma, member.guild.id); + if (memberIgnored(config, member as GuildMember, member.user.bot)) { + return; + } + await sendLogEmbed( + context, + member.guild, + 'MEMBER_LEAVE', + 'Member Left', + `**User:** ${member.user.tag} (<@${member.id}>)` + ); + }); + + client.on('guildBanAdd', async (ban) => { + const guild = ban.guild; + await handleBanForAntiNuke(context, guild); + await sendLogEmbed( + context, + guild, + 'MEMBER_BAN', + 'Member Banned', + `**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}` + ); + }); + + client.on('guildBanRemove', async (ban) => { + await sendLogEmbed( + context, + ban.guild, + 'MEMBER_UNBAN', + 'Member Unbanned', + `**User:** ${ban.user.tag} (<@${ban.user.id}>)` + ); + }); + + client.on('guildMemberUpdate', async (oldMember, newMember) => { + const config = await getLoggingConfig(context.prisma, newMember.guild.id); + if (memberIgnored(config, newMember, newMember.user.bot)) { + return; + } + const changes: string[] = []; + if (oldMember.nickname !== newMember.nickname) { + changes.push(`**Nickname:** ${oldMember.nickname ?? '—'} → ${newMember.nickname ?? '—'}`); + } + const addedRoles = newMember.roles.cache.filter((role) => !oldMember.roles.cache.has(role.id)); + const removedRoles = oldMember.roles.cache.filter((role) => !newMember.roles.cache.has(role.id)); + if (addedRoles.size > 0) { + changes.push(`**Roles added:** ${addedRoles.map((r) => r.name).join(', ')}`); + } + if (removedRoles.size > 0) { + changes.push(`**Roles removed:** ${removedRoles.map((r) => r.name).join(', ')}`); + } + if (changes.length === 0) { + return; + } + await sendLogEmbed( + context, + newMember.guild, + 'MEMBER_UPDATE', + 'Member Updated', + `**User:** <@${newMember.id}>\n${changes.join('\n')}` + ); + }); + + client.on('channelCreate', async (channel) => { + if (!channel.guild) { + return; + } + await sendLogEmbed( + context, + channel.guild, + 'CHANNEL_CREATE', + 'Channel Created', + `**Channel:** ${channel.name} (<#${channel.id}>)` + ); + }); + + client.on('channelDelete', async (channel) => { + if (!('guild' in channel) || !channel.guild) { + return; + } + await handleChannelDeleteForAntiNuke(context, channel.guild, channel.id); + await sendLogEmbed( + context, + channel.guild, + 'CHANNEL_DELETE', + 'Channel Deleted', + `**Channel:** ${'name' in channel ? channel.name : 'Unknown'} (\`${channel.id}\`)` + ); + }); + + client.on('channelUpdate', async (oldChannel, newChannel) => { + if (!('guild' in newChannel) || !newChannel.guild) { + return; + } + await sendLogEmbed( + context, + newChannel.guild, + 'CHANNEL_UPDATE', + 'Channel Updated', + `**Channel:** <#${newChannel.id}>\n**Before:** ${'name' in oldChannel ? oldChannel.name : '—'}\n**After:** ${'name' in newChannel ? newChannel.name : '—'}` + ); + }); + + client.on('voiceStateUpdate', async (oldState, newState) => { + const guild = newState.guild; + const member = newState.member; + if (!member || member.user.bot) { + return; + } + const config = await getLoggingConfig(context.prisma, guild.id); + if (memberIgnored(config, member, member.user.bot)) { + return; + } + + if (!oldState.channelId && newState.channelId) { + await sendLogEmbed( + context, + guild, + 'VOICE_JOIN', + 'Voice Join', + `**User:** <@${member.id}>\n**Channel:** <#${newState.channelId}>` + ); + return; + } + + if (oldState.channelId && !newState.channelId) { + await sendLogEmbed( + context, + guild, + 'VOICE_LEAVE', + 'Voice Leave', + `**User:** <@${member.id}>\n**Channel:** <#${oldState.channelId}>` + ); + return; + } + + if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) { + await sendLogEmbed( + context, + guild, + 'VOICE_MOVE', + 'Voice Move', + `**User:** <@${member.id}>\n**From:** <#${oldState.channelId}>\n**To:** <#${newState.channelId}>` + ); + } + }); + + client.on('inviteCreate', async (invite) => { + if (!invite.guild || !('members' in invite.guild)) { + return; + } + await sendLogEmbed( + context, + invite.guild, + 'INVITE_CREATE', + 'Invite Created', + `**Code:** ${invite.code}\n**Channel:** <#${invite.channelId}>\n**Creator:** ${invite.inviter ? `<@${invite.inviter.id}>` : '—'}` + ); + }); + + client.on('emojiCreate', async (emoji) => { + await sendLogEmbed( + context, + emoji.guild!, + 'EMOJI_CREATE', + 'Emoji Created', + `**Name:** ${emoji.name}` + ); + }); + + client.on('emojiUpdate', async (oldEmoji, newEmoji) => { + await sendLogEmbed( + context, + newEmoji.guild!, + 'EMOJI_UPDATE', + 'Emoji Updated', + `**Before:** ${oldEmoji.name}\n**After:** ${newEmoji.name}` + ); + }); + + client.on('emojiDelete', async (emoji) => { + await sendLogEmbed( + context, + emoji.guild!, + 'EMOJI_DELETE', + 'Emoji Deleted', + `**Name:** ${emoji.name}` + ); + }); + + client.on('stickerCreate', async (sticker) => { + if (!sticker.guild) { + return; + } + await sendLogEmbed( + context, + sticker.guild, + 'STICKER_CREATE', + 'Sticker Created', + `**Name:** ${sticker.name}` + ); + }); + + client.on('stickerUpdate', async (oldSticker, newSticker) => { + if (!newSticker.guild) { + return; + } + await sendLogEmbed( + context, + newSticker.guild, + 'STICKER_UPDATE', + 'Sticker Updated', + `**Before:** ${oldSticker.name}\n**After:** ${newSticker.name}` + ); + }); + + client.on('stickerDelete', async (sticker) => { + if (!sticker.guild) { + return; + } + await sendLogEmbed( + context, + sticker.guild, + 'STICKER_DELETE', + 'Sticker Deleted', + `**Name:** ${sticker.name}` + ); + }); + + client.on('threadCreate', async (thread) => { + if (!thread.guild) { + return; + } + await sendLogEmbed( + context, + thread.guild, + 'THREAD_CREATE', + 'Thread Created', + `**Thread:** ${thread.name} (<#${thread.id}>)` + ); + }); + + client.on('threadUpdate', async (oldThread, newThread) => { + if (!newThread.guild) { + return; + } + await sendLogEmbed( + context, + newThread.guild, + 'THREAD_UPDATE', + 'Thread Updated', + `**Before:** ${oldThread.name}\n**After:** ${newThread.name}` + ); + }); + + client.on('threadDelete', async (thread) => { + if (!thread.guild) { + return; + } + await sendLogEmbed( + context, + thread.guild, + 'THREAD_DELETE', + 'Thread Deleted', + `**Thread:** ${thread.name} (\`${thread.id}\`)` + ); + }); + + client.on('guildUpdate', async (oldGuild, newGuild) => { + const changes: string[] = []; + if (oldGuild.name !== newGuild.name) { + changes.push(`**Name:** ${oldGuild.name} → ${newGuild.name}`); + } + if (changes.length === 0) { + return; + } + await sendLogEmbed( + context, + newGuild, + 'GUILD_UPDATE', + 'Guild Updated', + changes.join('\n') + ); + }); +} diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index 917044f..c257764 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -1,4 +1,4 @@ -import { moderationQueue } from '../../jobs.js'; +import { moderationQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import type { Prisma } from '@prisma/client'; @@ -43,6 +43,17 @@ export async function createCase(context: BotContext, input: CreateCaseInput) { reason: input.reason, metadata: metadata as Prisma.InputJsonValue } + }).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; }); } diff --git a/apps/bot/src/modules/verification/command-definitions.ts b/apps/bot/src/modules/verification/command-definitions.ts new file mode 100644 index 0000000..8f8e6b7 --- /dev/null +++ b/apps/bot/src/modules/verification/command-definitions.ts @@ -0,0 +1,53 @@ +import { ChannelType } from 'discord.js'; +import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; +import { SlashCommandBuilder } from 'discord.js'; + +export const verifyCommandData = applyCommandDescription( + new SlashCommandBuilder() + .setName('verify') + .addSubcommand((s) => + applyCommandDescription(s.setName('setup'), 'verify.setup.description') + .addChannelOption((o) => + applyOptionDescription( + o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true), + 'verify.setup.options.channel' + ) + ) + .addRoleOption((o) => + applyOptionDescription(o.setName('verified_role').setRequired(true), 'verify.setup.options.verified_role') + ) + .addRoleOption((o) => + applyOptionDescription(o.setName('unverified_role'), 'verify.setup.options.unverified_role') + ) + .addStringOption((o) => + applyOptionDescription(o.setName('mode'), 'verify.setup.options.mode') + .addChoices( + { name: 'Button', value: 'BUTTON' }, + { name: 'Captcha', value: 'CAPTCHA' } + ) + ) + .addIntegerOption((o) => + applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days') + .setMinValue(0) + .setMaxValue(365) + ) + .addStringOption((o) => + applyOptionDescription(o.setName('fail_action'), 'verify.setup.options.fail_action') + .addChoices( + { name: 'Kick', value: 'KICK' }, + { name: 'Ban', value: 'BAN' }, + { name: 'None', value: 'NONE' } + ) + ) + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('panel'), 'verify.panel.description') + .addChannelOption((o) => + applyOptionDescription( + o.setName('channel').addChannelTypes(ChannelType.GuildText), + 'verify.panel.options.channel' + ) + ) + ), + 'verify.description' +); diff --git a/apps/bot/src/modules/verification/commands.ts b/apps/bot/src/modules/verification/commands.ts new file mode 100644 index 0000000..5d938af --- /dev/null +++ b/apps/bot/src/modules/verification/commands.ts @@ -0,0 +1,84 @@ +import { PermissionFlagsBits } 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 { verifyCommandData } from './command-definitions.js'; +import { + getVerificationConfig, + updateVerificationConfig +} from './service.js'; +import { buildVerificationPanel } from './handlers.js'; + +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 }); + return; + } + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { + return; + } + + const guildId = interaction.guildId!; + const sub = interaction.options.getSubcommand(); + + if (sub === 'setup') { + const channel = interaction.options.getChannel('channel', true); + 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 minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0; + const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as + | 'KICK' + | 'BAN' + | 'NONE'; + + await updateVerificationConfig(context.prisma, guildId, { + enabled: true, + channelId: channel.id, + verifiedRoleId: verifiedRole.id, + unverifiedRoleId: unverifiedRole?.id ?? null, + mode, + minAccountAgeDays, + failAction + }); + + await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true }); + return; + } + + const config = await getVerificationConfig(context.prisma, guildId); + if (!config.enabled || !config.verifiedRoleId) { + await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true }); + return; + } + + const panelChannelOption = interaction.options.getChannel('channel'); + const panelChannel = panelChannelOption + ? await interaction.guild!.channels.fetch(panelChannelOption.id) + : config.channelId + ? await interaction.guild!.channels.fetch(config.channelId) + : null; + + if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) { + await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true }); + return; + } + + const panel = buildVerificationPanel(config); + const message = await panelChannel.send(panel); + await context.prisma.verificationConfig.update({ + where: { guildId }, + data: { + panelChannelId: panelChannel.id, + panelMessageId: message.id + } + }); + await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true }); + } +}; + +export const verificationCommands: SlashCommand[] = [verifyCommand]; diff --git a/apps/bot/src/modules/verification/events.ts b/apps/bot/src/modules/verification/events.ts new file mode 100644 index 0000000..59c7d20 --- /dev/null +++ b/apps/bot/src/modules/verification/events.ts @@ -0,0 +1,27 @@ +import type { BotContext } from '../../types.js'; +import { getVerificationConfig } from './service.js'; +import { PermissionFlagsBits } from 'discord.js'; +import { logger } from '../../logger.js'; + +export function registerVerificationEvents(context: BotContext): void { + context.client.on('guildMemberAdd', async (member) => { + const config = await getVerificationConfig(context.prisma, member.guild.id); + if (!config.enabled || !config.unverifiedRoleId) { + return; + } + if (config.verifiedRoleId && member.roles.cache.has(config.verifiedRoleId)) { + return; + } + const me = member.guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return; + } + const role = member.guild.roles.cache.get(config.unverifiedRoleId); + if (!role || role.position >= me.roles.highest.position) { + return; + } + await member.roles.add(role).catch((error) => { + logger.warn({ error, memberId: member.id }, 'Failed to assign unverified role'); + }); + }); +} diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts new file mode 100644 index 0000000..a75026f --- /dev/null +++ b/apps/bot/src/modules/verification/handlers.ts @@ -0,0 +1,149 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + PermissionFlagsBits, + type ButtonInteraction, + type GuildMember +} from 'discord.js'; +import { t, tf } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { + accountAgeDays, + createCaptchaChallenge, + getVerificationConfig, + parseVerificationButton, + verificationButtonId, + verificationCaptchaButtonId +} from './service.js'; +import { env } from '../../env.js'; +import { logger } from '../../logger.js'; + +async function applyFailAction( + member: GuildMember, + failAction: string, + reason: string +): Promise { + const me = member.guild.members.me; + if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) { + await member.kick(reason); + return; + } + if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) { + await member.guild.members.ban(member.id, { reason }); + } +} + +export async function completeVerification( + context: BotContext, + guildId: string, + userId: string +): Promise<{ ok: boolean; reason?: string }> { + const config = await getVerificationConfig(context.prisma, guildId); + if (!config.enabled || !config.verifiedRoleId) { + return { ok: false, reason: 'not_configured' }; + } + + const guild = await context.client.guilds.fetch(guildId); + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) { + return { ok: false, reason: 'member_not_found' }; + } + + const ageDays = accountAgeDays(member.user.createdAt); + if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) { + await applyFailAction( + member, + config.failAction, + `Verification failed: account too young (${ageDays}d)` + ); + return { ok: false, reason: 'account_too_young' }; + } + + const me = guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return { ok: false, reason: 'missing_permissions' }; + } + + const verifiedRole = guild.roles.cache.get(config.verifiedRoleId); + if (!verifiedRole || verifiedRole.position >= me.roles.highest.position) { + return { ok: false, reason: 'invalid_role' }; + } + + if (config.unverifiedRoleId && member.roles.cache.has(config.unverifiedRoleId)) { + await member.roles.remove(config.unverifiedRoleId).catch((error) => { + logger.warn({ error }, 'Failed to remove unverified role'); + }); + } + + if (!member.roles.cache.has(config.verifiedRoleId)) { + await member.roles.add(config.verifiedRoleId); + } + + return { ok: true }; +} + +export function buildVerificationPanel(config: Awaited>) { + const embed = new EmbedBuilder() + .setTitle('Verification') + .setDescription('Click the button below to verify and gain access to the server.') + .setColor(0x6366f1); + + const customId = + config.mode === 'CAPTCHA' + ? verificationCaptchaButtonId(config.guildId) + : verificationButtonId(config.guildId); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(customId) + .setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify') + .setStyle(ButtonStyle.Success) + ); + + return { embeds: [embed], components: [row] }; +} + +export async function handleVerificationButton( + interaction: ButtonInteraction, + context: BotContext +): Promise { + const parsed = parseVerificationButton(interaction.customId); + if (!parsed || !interaction.guild) { + return; + } + + const locale = await getGuildLocale(context.prisma, interaction.guild.id); + const config = await getVerificationConfig(context.prisma, parsed.guildId); + + if (!config.enabled) { + await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true }); + return; + } + + if (parsed.mode === 'captcha') { + const challenge = await createCaptchaChallenge( + context.redis, + parsed.guildId, + interaction.user.id + ); + const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`; + await interaction.reply({ + content: tf(locale, 'verification.captchaLink', { url }), + ephemeral: true + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + const result = await completeVerification(context, parsed.guildId, interaction.user.id); + if (result.ok) { + await interaction.editReply({ content: t(locale, 'verification.success') }); + return; + } + + const key = `verification.error.${result.reason ?? 'generic'}`; + await interaction.editReply({ content: t(locale, key) }); +} diff --git a/apps/bot/src/modules/verification/service.ts b/apps/bot/src/modules/verification/service.ts new file mode 100644 index 0000000..9f7af36 --- /dev/null +++ b/apps/bot/src/modules/verification/service.ts @@ -0,0 +1,115 @@ +import { randomBytes, randomInt, createHash } from 'node:crypto'; +import type { PrismaClient } from '@prisma/client'; +import type { VerificationFailAction, VerificationMode } from '@nexumi/shared'; +import { ensureGuild } from '../../guild.js'; +import type { Redis } from 'ioredis'; + +const CAPTCHA_PREFIX = 'verify:captcha:'; + +export async function getVerificationConfig(prisma: PrismaClient, guildId: string) { + await ensureGuild(prisma, guildId); + let config = await prisma.verificationConfig.findUnique({ where: { guildId } }); + if (!config) { + config = await prisma.verificationConfig.create({ data: { guildId } }); + } + return config; +} + +export async function updateVerificationConfig( + prisma: PrismaClient, + guildId: string, + data: { + enabled: boolean; + channelId: string; + verifiedRoleId: string; + unverifiedRoleId?: string | null; + mode: VerificationMode; + minAccountAgeDays: number; + failAction: VerificationFailAction; + } +) { + await ensureGuild(prisma, guildId); + return prisma.verificationConfig.upsert({ + where: { guildId }, + update: data, + create: { guildId, ...data } + }); +} + +export function verificationButtonId(guildId: string): string { + return `verify:button:${guildId}`; +} + +export function verificationCaptchaButtonId(guildId: string): string { + return `verify:captcha:${guildId}`; +} + +export function isVerificationButton(customId: string): boolean { + return customId.startsWith('verify:button:') || customId.startsWith('verify:captcha:'); +} + +export function parseVerificationButton(customId: string): { + mode: 'button' | 'captcha'; + guildId: string; +} | null { + if (customId.startsWith('verify:button:')) { + return { mode: 'button', guildId: customId.slice('verify:button:'.length) }; + } + if (customId.startsWith('verify:captcha:')) { + return { mode: 'captcha', guildId: customId.slice('verify:captcha:'.length) }; + } + return null; +} + +export type CaptchaChallenge = { + token: string; + guildId: string; + userId: string; + question: string; + answerHash: string; +}; + +export async function createCaptchaChallenge( + redis: Redis, + guildId: string, + userId: string +): 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 + }; + await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600); + return challenge; +} + +export async function getCaptchaChallenge( + redis: Redis, + token: string +): Promise { + const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`); + if (!raw) { + return null; + } + return JSON.parse(raw) as CaptchaChallenge; +} + +export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise { + await redis.del(`${CAPTCHA_PREFIX}${token}`); +} + +export function hashCaptchaAnswer(answer: string): string { + return createHash('sha256').update(answer.trim()).digest('hex'); +} + +export function accountAgeDays(userCreatedAt: Date): number { + const ms = Date.now() - userCreatedAt.getTime(); + return Math.floor(ms / (1000 * 60 * 60 * 24)); +} diff --git a/apps/bot/src/modules/welcome/command-definitions.ts b/apps/bot/src/modules/welcome/command-definitions.ts new file mode 100644 index 0000000..a4d5e65 --- /dev/null +++ b/apps/bot/src/modules/welcome/command-definitions.ts @@ -0,0 +1,14 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { applyCommandDescription } from '@nexumi/shared'; + +export const welcomeCommandData = applyCommandDescription( + new SlashCommandBuilder() + .setName('welcome') + .addSubcommand((s) => + applyCommandDescription(s.setName('test'), 'welcome.test.description') + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('preview'), 'welcome.preview.description') + ), + 'welcome.description' +); diff --git a/apps/bot/src/modules/welcome/commands.ts b/apps/bot/src/modules/welcome/commands.ts new file mode 100644 index 0000000..650cb22 --- /dev/null +++ b/apps/bot/src/modules/welcome/commands.ts @@ -0,0 +1,56 @@ +import { PermissionFlagsBits } 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 { welcomeCommandData } from './command-definitions.js'; +import { getWelcomeConfig, resolveWelcomePayload } from './service.js'; +import { buildWelcomeMessage } from './renderer.js'; + +const welcomeCommand: SlashCommand = { + data: welcomeCommandData, + 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 }); + return; + } + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { + return; + } + + const config = await getWelcomeConfig(context.prisma, interaction.guildId!); + const sub = interaction.options.getSubcommand(); + const member = interaction.member; + if (!member || !('guild' in member)) { + await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true }); + return; + } + + const guildMember = await interaction.guild!.members.fetch(interaction.user.id); + const payload = resolveWelcomePayload(config); + + if (sub === 'preview') { + const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); + await interaction.reply({ ...message, ephemeral: true }); + return; + } + + if (!config.welcomeChannelId) { + await interaction.reply({ content: t(locale, 'welcome.notConfigured'), ephemeral: true }); + return; + } + + const channel = await interaction.guild!.channels.fetch(config.welcomeChannelId); + if (!channel?.isTextBased() || channel.isDMBased()) { + await interaction.reply({ content: t(locale, 'welcome.invalidChannel'), ephemeral: true }); + return; + } + + const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); + await channel.send(message); + await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true }); + } +}; + +export const welcomeCommands: SlashCommand[] = [welcomeCommand]; diff --git a/apps/bot/src/modules/welcome/events.ts b/apps/bot/src/modules/welcome/events.ts new file mode 100644 index 0000000..c4d04e2 --- /dev/null +++ b/apps/bot/src/modules/welcome/events.ts @@ -0,0 +1,75 @@ +import { PermissionFlagsBits, type GuildMember } from 'discord.js'; +import type { BotContext } from '../../types.js'; +import { getWelcomeConfig, resolveWelcomePayload } from './service.js'; +import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js'; +import { logger } from '../../logger.js'; + +async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise { + const me = member.guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return; + } + const assignable = roleIds.filter((roleId) => { + const role = member.guild.roles.cache.get(roleId); + return role && role.position < me.roles.highest.position; + }); + if (assignable.length === 0) { + return; + } + await member.roles.add(assignable).catch((error) => { + logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles'); + }); +} + +export async function handleMemberWelcome(context: BotContext, member: GuildMember): Promise { + const config = await getWelcomeConfig(context.prisma, member.guild.id); + + const roleIds = member.user.bot ? config.botAutoroleIds : config.userAutoroleIds; + await applyAutoroles(member, roleIds); + + if (config.welcomeEnabled && config.welcomeChannelId) { + const channel = await member.guild.channels.fetch(config.welcomeChannelId).catch(() => null); + if (channel?.isTextBased() && !channel.isDMBased()) { + await sendWelcomeMessage( + channel, + resolveWelcomePayload(config), + member, + member.guild + ); + } + } + + if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) { + await member + .send({ content: config.welcomeDmContent.replace('{user}', `<@${member.id}>`) }) + .catch(() => undefined); + } +} + +export async function handleMemberLeave(context: BotContext, member: GuildMember): Promise { + const config = await getWelcomeConfig(context.prisma, member.guild.id); + if (!config.leaveEnabled || !config.leaveChannelId) { + return; + } + const channel = await member.guild.channels.fetch(config.leaveChannelId).catch(() => null); + if (!channel?.isTextBased() || channel.isDMBased()) { + return; + } + const content = config.leaveContent ?? '{user.tag} left {server}.'; + await sendLeaveMessage(channel, content, member, member.guild); +} + +export function registerWelcomeEvents(context: BotContext): void { + context.client.on('guildMemberAdd', async (member) => { + await handleMemberWelcome(context, member); + const { handleAntiRaidJoin } = await import('../automod/actions.js'); + await handleAntiRaidJoin(context, member); + }); + + context.client.on('guildMemberRemove', async (member) => { + if (!member.guild) { + return; + } + await handleMemberLeave(context, member as GuildMember); + }); +} diff --git a/apps/bot/src/modules/welcome/renderer.ts b/apps/bot/src/modules/welcome/renderer.ts new file mode 100644 index 0000000..9e26d6f --- /dev/null +++ b/apps/bot/src/modules/welcome/renderer.ts @@ -0,0 +1,107 @@ +import { + AttachmentBuilder, + EmbedBuilder, + type Guild, + type GuildMember, + type SendableChannels, + type TextBasedChannel +} from 'discord.js'; +import { createCanvas, loadImage } from '@napi-rs/canvas'; +import type { 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[] }> { + 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] }; + } + + if (payload.type === 'IMAGE') { + const buffer = await renderWelcomeCard(member, guild, payload); + const attachment = new AttachmentBuilder(buffer, { name: 'welcome.png' }); + return { files: [attachment] }; + } + + const content = + payload.content ?? + `Welcome {user} to {server}!`.replace('{user}', `<@${member.id}>`).replace('{server}', guild.name); + return { content: renderWelcomeText(content, member, guild) }; +} + +async function renderWelcomeCard( + member: GuildMember, + guild: Guild, + payload: WelcomePayload +): Promise { + const width = 900; + const height = 300; + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = '#111827'; + ctx.fillRect(0, 0, width, height); + + ctx.fillStyle = '#6366f1'; + ctx.fillRect(0, 0, width, 8); + + const avatar = await loadImage(member.user.displayAvatarURL({ extension: 'png', size: 128 })); + ctx.save(); + ctx.beginPath(); + ctx.arc(120, 150, 64, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(avatar, 56, 86, 128, 128); + ctx.restore(); + + const title = payload.imageTitle ?? 'Welcome!'; + const subtitle = + payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`; + + ctx.fillStyle = '#f9fafb'; + ctx.font = 'bold 36px sans-serif'; + ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130); + + ctx.fillStyle = '#d1d5db'; + ctx.font = '24px sans-serif'; + ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180); + + return canvas.toBuffer('image/png'); +} + +export async function sendWelcomeMessage( + channel: TextBasedChannel, + payload: WelcomePayload, + member: GuildMember, + guild: Guild +): Promise { + const message = await buildWelcomeMessage(payload, member, guild); + if ('send' in channel) { + await (channel as SendableChannels).send(message); + } +} + +export async function sendLeaveMessage( + channel: TextBasedChannel, + content: string, + member: GuildMember, + guild: Guild +): Promise { + if ('send' in channel) { + await (channel as SendableChannels).send({ content: renderWelcomeText(content, member, guild) }); + } +} diff --git a/apps/bot/src/modules/welcome/service.ts b/apps/bot/src/modules/welcome/service.ts new file mode 100644 index 0000000..7ab4e11 --- /dev/null +++ b/apps/bot/src/modules/welcome/service.ts @@ -0,0 +1,56 @@ +import type { PrismaClient } from '@prisma/client'; +import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared'; +import { applyWelcomePlaceholders } from '@nexumi/shared'; +import type { Guild, GuildMember } from 'discord.js'; +import { ensureGuild } from '../../guild.js'; + +export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) { + await ensureGuild(prisma, guildId); + let config = await prisma.welcomeConfig.findUnique({ where: { guildId } }); + if (!config) { + config = await prisma.welcomeConfig.create({ data: { guildId } }); + } + return config; +} + +export function buildPlaceholderVars(member: GuildMember, guild: Guild) { + return { + user: `<@${member.id}>`, + 'user.name': member.displayName, + 'user.tag': member.user.tag, + 'user.id': member.id, + server: guild.name, + memberCount: guild.memberCount + }; +} + +export function renderWelcomeText(template: string, member: GuildMember, guild: Guild): string { + return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild)); +} + +export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null { + if (!raw || typeof raw !== 'object') { + return null; + } + return raw as WelcomeEmbed; +} + +export type WelcomePayload = { + type: WelcomeMessageType; + content?: string | null; + embed?: WelcomeEmbed | null; + imageTitle?: string | null; + imageSubtitle?: string | null; +}; + +export function resolveWelcomePayload( + config: Awaited> +): WelcomePayload { + return { + type: config.welcomeType as WelcomeMessageType, + content: config.welcomeContent, + embed: parseWelcomeEmbed(config.welcomeEmbed), + imageTitle: config.imageTitle, + imageSubtitle: config.imageSubtitle + }; +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts new file mode 100644 index 0000000..2193aba --- /dev/null +++ b/apps/bot/src/queues.ts @@ -0,0 +1,11 @@ +import { Queue } from 'bullmq'; +import { redis } from './redis.js'; + +export const moderationQueueName = 'moderation'; +export const moderationQueue = new Queue(moderationQueueName, { connection: redis }); +export const backupQueueName = 'backups'; +export const backupQueue = new Queue(backupQueueName, { connection: redis }); +export const automodQueueName = 'automod'; +export const automodQueue = new Queue(automodQueueName, { connection: redis }); +export const verificationQueueName = 'verification'; +export const verificationQueue = new Queue(verificationQueueName, { connection: redis }); diff --git a/docker-compose.yml b/docker-compose.yml index 476fc4d..52a13e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,16 @@ services: condition: service_healthy volumes: - backups:/backups + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:8080/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 40s webui: image: node:22-alpine diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index f93ec9d..1961acd 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -1,82 +1,140 @@ -# Nexumi – Phasen-Tracking - -Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jedem Arbeitsfortschritt aktualisiert. - -## Assets - -- Bot-Logo vorhanden: `docs/logo/nexumi-logo.svg` - -## Phase 1 – Fundament (Status: in Arbeit) - -### Bereits abgeschlossen - -- Monorepo-Basis mit Workspaces: - - `apps/bot` - - `packages/shared` -- Basis-Tooling: - - TypeScript strict - - ESLint - - Prettier - - Vitest - - Turbo -- Docker-Compose-Grundstack: - - `postgres` mit Healthcheck - - `redis` mit Healthcheck - - `bot` - - `webui`-Scaffold-Service (temporär) - - `backups` Volume angebunden -- Prisma-Grundmodell inkl. Moderationsdaten: - - `Guild` - - `GuildSettings` - - `User` - - `Case` - - `Warning` - - `ModNote` - - `EscalationRule` -- Bot-Grundarchitektur: - - Sharding-Startpfad - - Command-Registrierung - - Command-Routing - - zentraler Permission-Check - - Basis-i18n in `@nexumi/shared` - - Guild-Locale-Auflösung über DB-Settings mit Fallback auf Env-Default - - Health-Endpoint (`/health`) und token-geschütztes `/metrics`-Scaffold -- BullMQ-Integration: - - Moderation-Queue - - Temp-Ban-Expire-Job - - täglicher Backup-Job (`pg_dump`) - - Backup-Retention-Cleanup -- Moderation als Referenzmodul (implementiert): - - `/ban`, `/unban`, `/kick`, `/timeout`, `/untimeout` - - `/warn add|list|remove|clear` - - `/warn escalation-set|escalation-list|escalation-remove` - - `/purge` (Filter: User, Bots, Links, Attachments, Regex) - - `/slowmode`, `/lock`, `/unlock` - - `/nick set|reset` - - `/case view|edit|delete` - - `/modnote add|list` - - user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`) - - Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations) - - Bestätigungsflow für destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons - - standardisierte Case-Audit-Metadaten über `@nexumi/shared` (`buildCaseMetadata`) -- Prisma-Migrationen versioniert: - - `apps/bot/prisma/migrations/20260722123000_init` -- Tests (aktuell vorhanden): - - `packages/shared/src/i18n.test.ts` - - `packages/shared/src/audit.test.ts` - - `apps/bot/src/modules/moderation/duration.test.ts` - -### Noch offen für Phase 1 - -- `docker compose up -d` End-to-End gegen echte `.env` und Test-Discord-Guild final verifizieren. - -### Verifikation (letzter Lauf) - -- `pnpm prisma:generate` erfolgreich -- `pnpm typecheck` erfolgreich -- `pnpm lint` erfolgreich -- `pnpm test` erfolgreich - -## Nächster geplanter Schritt - -- Phase 1 vollständig abschließen (offene Punkte oben), dann erst nach expliziter Freigabe in Phase 2 wechseln. +# Nexumi – Phasen-Tracking + +Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jedem Arbeitsfortschritt aktualisiert. + +## Assets + +- Bot-Logo vorhanden: `docs/logo/nexumi-logo.svg` + +## Phase 1 – Fundament (Status: abgeschlossen, manuelle Discord-Tests ausstehend) + +### Bereits abgeschlossen + +- Monorepo-Basis mit Workspaces: + - `apps/bot` + - `packages/shared` +- Basis-Tooling: + - TypeScript strict + - ESLint + - Prettier + - Vitest + - Turbo +- Docker-Compose-Grundstack: + - `postgres` mit Healthcheck + - `redis` mit Healthcheck + - `bot` + - `webui`-Scaffold-Service (temporär) + - `backups` Volume angebunden +- Prisma-Grundmodell inkl. Moderationsdaten: + - `Guild` + - `GuildSettings` + - `User` + - `Case` + - `Warning` + - `ModNote` + - `EscalationRule` +- Bot-Grundarchitektur: + - Sharding-Startpfad + - Command-Registrierung + - Command-Routing + - zentraler Permission-Check + - Basis-i18n in `@nexumi/shared` + - Guild-Locale-Auflösung über DB-Settings mit Fallback auf Env-Default + - Health-Endpoint (`/health`) und token-geschütztes `/metrics`-Scaffold +- BullMQ-Integration: + - Moderation-Queue + - Temp-Ban-Expire-Job + - täglicher Backup-Job (`pg_dump`) + - Backup-Retention-Cleanup +- Moderation als Referenzmodul (implementiert): + - `/ban`, `/unban`, `/kick`, `/timeout`, `/untimeout` + - `/warn add|list|remove|clear` + - `/warn escalation-set|escalation-list|escalation-remove` + - `/purge` (Filter: User, Bots, Links, Attachments, Regex) + - `/slowmode`, `/lock`, `/unlock` + - `/nick set|reset` + - `/case view|edit|delete` + - `/modnote add|list` + - user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`) + - Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations) + - Bestätigungsflow für destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons + - standardisierte Case-Audit-Metadaten über `@nexumi/shared` (`buildCaseMetadata`) +- Docker-Build/Runtime-Fixes für E2E: + - `.dockerignore` ergänzt + - Prisma-Migration beim Bot-Start (`prisma migrate deploy`) + - Bot-Healthcheck auf `/health` +- E2E-Verifikation (automatisiert, lokal): + - `docker compose up -d --build` erfolgreich + - Postgres/Redis healthy + - Migration `20260722123000_init` angewendet + - Bot online (`Nexumi#9122`), 13 Slash-Commands registriert + - Bot-Container healthy +- Tests (aktuell vorhanden): + - `packages/shared/src/i18n.test.ts` + - `packages/shared/src/audit.test.ts` + - `apps/bot/src/modules/moderation/duration.test.ts` + +### Manuelle Discord-Tests (noch offen) + +- Bot auf Test-Server einladen (falls noch nicht geschehen) +- Slash-Commands testen: `/ban`, `/warn add`, `/purge`, `/case view` +- Bestätigungsbuttons bei `/ban` und `/purge` prüfen + +## Phase 2 – AutoMod, Logging, Welcome, Verifizierung (Status: implementiert, manuelle Tests ausstehend) + +### Abgeschlossen + +- Prisma-Erweiterung + Migration `20260722140000_phase2_modules`: + - `AutoModConfig`, `AutoModRule` + - `LoggingConfig`, `LogChannel` + - `WelcomeConfig` + - `VerificationConfig` +- Shared-Schemas und Hilfsfunktionen (`packages/shared/src/phase2.ts`) +- **AutoMod** (`apps/bot/src/modules/automod/`): + - Filter: Spam, Massen-Mentions, Caps, Invite-Links, externe Links, Wortfilter, Duplikate, Emoji-Spam, Zalgo, Phishing-Blockliste + - Standardregeln werden pro Guild beim ersten Zugriff angelegt + - Anti-Raid (Join-Rate → Lockdown) + - Anti-Nuke (Massen-Bans/Channel-Löschungen → Rechteentzug) + - `/automod status` + - Phishing-Listen-Refresh via BullMQ (alle 6h + beim Start) +- **Logging** (`apps/bot/src/modules/logging/`): + - Event-Handler für alle SPEC-Events (Nachrichten, Member, Bans, Rollen, Kanäle, Voice, Invites, Emoji/Sticker, Threads, Guild-Update) + - Mod-Action-Log verknüpft mit Case-System (`logModAction` in `createCase`) + - Ignore-Listen (Kanäle, Rollen, Bots) über DB + - Log-Kanal-Zuordnung über `LogChannel`-Tabelle (Konfiguration künftig WebUI) +- **Welcome/Leave** (`apps/bot/src/modules/welcome/`): + - Text-, Embed- und Bild-Karten (Canvas via `@napi-rs/canvas`) + - Platzhalter-System + - Autoroles (User/Bots getrennt) + - Welcome-DM + - `/welcome test`, `/welcome preview` +- **Verifizierung** (`apps/bot/src/modules/verification/`): + - `/verify setup`, `/verify panel` + - Button-Verify und Captcha-Verify (Captcha-Seite unter `/verify/captcha` am Health-Server) + - Mindest-Accountalter, Fehlschlag-Aktionen (Kick/Ban/None) + - Unverified-Rolle bei Join +- Bot-Intents erweitert: + - `GuildMembers`, `GuildMessages`, `MessageContent`, `GuildModeration`, `GuildVoiceStates`, `GuildEmojisAndStickers`, `GuildInvites` +- Neue Env-Variable: `PUBLIC_BASE_URL` (Captcha-Links) +- Tests: `packages/shared/src/phase2.test.ts` + +### Manuelle Discord-Tests (noch offen) + +- **Privileged Intents** im Developer Portal aktivieren: + - `SERVER MEMBERS INTENT` + - `MESSAGE CONTENT INTENT` +- AutoMod: Spam-/Invite-Testnachrichten senden +- `/automod status` prüfen +- Logging: Log-Kanäle per DB/ später WebUI setzen, Events auslösen +- `/welcome preview` und `/welcome test` (Konfiguration zunächst per DB oder später WebUI) +- `/verify setup` + `/verify panel`, Button- und Captcha-Flow testen +- `PUBLIC_BASE_URL` auf erreichbare URL setzen (für Captcha außerhalb localhost) + +### Hinweise + +- Welcome-/Logging-Konfiguration erfolgt in Phase 2 über die Datenbank; die WebUI-Editoren kommen in Phase 7. +- Captcha-Seite läuft am Bot-Health-Port (`HEALTH_PORT`); in Produktion muss `PUBLIC_BASE_URL` darauf zeigen oder über Reverse-Proxy geroutet werden. + +## Nächster geplanter Schritt + +- Manuelle Phase-2-Tests auf Test-Server, dann nach Freigabe Phase 3 (Leveling, Economy, Utility, Fun). diff --git a/packages/shared/src/audit.ts b/packages/shared/src/audit.ts index b5449bc..ca2497f 100644 --- a/packages/shared/src/audit.ts +++ b/packages/shared/src/audit.ts @@ -4,7 +4,8 @@ export const CaseAuditSourceSchema = z.enum([ 'slash_command', 'button_confirmation', 'escalation', - 'job' + 'job', + 'automod' ]); export type CaseAuditSource = z.infer; diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index deb3b27..0be9c46 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -214,6 +214,66 @@ export const commandLocales = { 'moderation.common.options.target': { de: 'Ziel', en: 'Target' + }, + 'automod.description': { + de: 'Auto-Moderation verwalten', + en: 'Manage auto-moderation' + }, + 'automod.status.description': { + de: 'Aktive AutoMod-Regeln anzeigen', + en: 'Show active AutoMod rules' + }, + 'welcome.description': { + de: 'Willkommensnachrichten testen', + en: 'Test welcome messages' + }, + 'welcome.test.description': { + de: 'Test-Willkommensnachricht senden', + en: 'Send a test welcome message' + }, + 'welcome.preview.description': { + de: 'Vorschau der Willkommensnachricht', + en: 'Preview the welcome message' + }, + 'verify.description': { + de: 'Verifizierung einrichten', + en: 'Configure verification' + }, + 'verify.setup.description': { + de: 'Verifizierung konfigurieren', + en: 'Configure verification' + }, + 'verify.setup.options.channel': { + de: 'Verifizierungskanal', + en: 'Verification channel' + }, + 'verify.setup.options.verified_role': { + de: 'Rolle nach Verifizierung', + en: 'Role after verification' + }, + 'verify.setup.options.unverified_role': { + de: 'Rolle vor Verifizierung', + en: 'Role before verification' + }, + 'verify.setup.options.mode': { + de: 'Verifizierungsmodus', + en: 'Verification mode' + }, + 'verify.setup.options.min_account_age_days': { + de: 'Mindest-Accountalter in Tagen', + en: 'Minimum account age in days' + }, + 'verify.setup.options.fail_action': { + de: 'Aktion bei Fehlschlag', + en: 'Action on failure' + }, + 'verify.panel.description': { + de: 'Verifizierungs-Panel posten', + en: 'Post verification panel' + }, + 'verify.panel.options.channel': { + de: 'Optionaler Kanal für das Panel', + en: 'Optional channel for the panel' } } as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4ee6950..aabe670 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; export * from './audit.js'; export * from './command-locales.js'; export * from './discord-localization.js'; +export * from './phase2.js'; export const LocaleSchema = z.enum(['de', 'en']); export type Locale = z.infer; @@ -66,7 +67,32 @@ const de: Dictionary = { 'moderation.confirm.caseDelete': 'Fall #{caseNumber} wirklich löschen?', 'moderation.confirm.expired': 'Bestätigung abgelaufen. Bitte den Befehl erneut ausführen.', 'moderation.confirm.cancelled': 'Aktion abgebrochen.', - 'moderation.confirm.unauthorized': 'Nur der ausführende Moderator kann diese Bestätigung nutzen.' + 'moderation.confirm.unauthorized': 'Nur der ausführende Moderator kann diese Bestätigung nutzen.', + 'automod.status.header': + '**AutoMod enabled:** {enabled}\n**Anti-raid:** {antiRaid}\n**Anti-nuke:** {antiNuke}\n**Lockdown:** {lockdown}', + 'automod.status.ruleLine': '- {type} → {action}', + 'automod.status.noRules': 'Keine aktiven Regeln.', + 'automod.status.yes': 'ja', + 'automod.status.no': 'nein', + 'welcome.notConfigured': 'Willkommensnachricht ist noch nicht konfiguriert.', + 'welcome.invalidChannel': 'Der konfigurierte Willkommenskanal ist ungültig.', + 'welcome.testSent': 'Test-Willkommensnachricht gesendet.', + 'verification.disabled': 'Verifizierung ist deaktiviert.', + 'verification.success': 'Du wurdest erfolgreich verifiziert.', + 'verification.setupDone': 'Verifizierung konfiguriert.', + 'verification.notConfigured': 'Verifizierung ist noch nicht eingerichtet. Nutze `/verify setup`.', + 'verification.invalidChannel': 'Der Verifizierungskanal ist ungültig.', + 'verification.panelPosted': 'Verifizierungs-Panel gepostet.', + 'verification.captchaLink': 'Öffne diesen Link zur Captcha-Verifizierung: {url}', + 'verification.error.not_configured': 'Verifizierung ist nicht konfiguriert.', + 'verification.error.member_not_found': 'Mitglied nicht gefunden.', + 'verification.error.account_too_young': 'Dein Account ist zu jung für die Verifizierung.', + 'verification.error.missing_permissions': 'Dem Bot fehlen Berechtigungen.', + 'verification.error.invalid_role': 'Die Verifizierungsrolle ist ungültig.', + 'verification.error.generic': 'Verifizierung fehlgeschlagen.', + 'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.', + 'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.', + 'verification.captcha.title': 'Nexumi Verifizierung' }; const en: Dictionary = { 'generic.noPermission': 'You do not have permission for this action.', @@ -97,7 +123,32 @@ const en: Dictionary = { 'moderation.confirm.caseDelete': 'Really delete case #{caseNumber}?', 'moderation.confirm.expired': 'Confirmation expired. Please run the command again.', 'moderation.confirm.cancelled': 'Action cancelled.', - 'moderation.confirm.unauthorized': 'Only the moderator who started this action can confirm it.' + 'moderation.confirm.unauthorized': 'Only the moderator who started this action can confirm it.', + 'automod.status.header': + '**AutoMod enabled:** {enabled}\n**Anti-raid:** {antiRaid}\n**Anti-nuke:** {antiNuke}\n**Lockdown:** {lockdown}', + 'automod.status.ruleLine': '- {type} → {action}', + 'automod.status.noRules': 'No active rules.', + 'automod.status.yes': 'yes', + 'automod.status.no': 'no', + 'welcome.notConfigured': 'Welcome message is not configured yet.', + 'welcome.invalidChannel': 'The configured welcome channel is invalid.', + 'welcome.testSent': 'Test welcome message sent.', + 'verification.disabled': 'Verification is disabled.', + 'verification.success': 'You have been verified successfully.', + 'verification.setupDone': 'Verification configured.', + 'verification.notConfigured': 'Verification is not set up yet. Use `/verify setup`.', + 'verification.invalidChannel': 'The verification channel is invalid.', + 'verification.panelPosted': 'Verification panel posted.', + 'verification.captchaLink': 'Open this link to complete captcha verification: {url}', + 'verification.error.not_configured': 'Verification is not configured.', + 'verification.error.member_not_found': 'Member not found.', + 'verification.error.account_too_young': 'Your account is too young to verify.', + 'verification.error.missing_permissions': 'Bot lacks permissions.', + 'verification.error.invalid_role': 'Verification role is invalid.', + 'verification.error.generic': 'Verification failed.', + 'verification.captcha.success': 'Captcha passed. You have been verified.', + 'verification.captcha.invalid': 'Captcha invalid or expired.', + 'verification.captcha.title': 'Nexumi Verification' }; const locales: Record = { de, en }; diff --git a/packages/shared/src/phase2.test.ts b/packages/shared/src/phase2.test.ts new file mode 100644 index 0000000..f820795 --- /dev/null +++ b/packages/shared/src/phase2.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + applyWelcomePlaceholders, + capsRatio, + countEmojis, + hasZalgo, + isDiscordInvite, + normalizeHost +} from './phase2.js'; + +describe('phase2 helpers', () => { + it('replaces welcome placeholders', () => { + const result = applyWelcomePlaceholders('Hello {user} on {server}', { + user: '<@1>', + server: 'Nexumi' + }); + expect(result).toBe('Hello <@1> on Nexumi'); + }); + + it('detects discord invites', () => { + expect(isDiscordInvite('join https://discord.gg/test')).toBe(true); + expect(isDiscordInvite('hello world')).toBe(false); + }); + + it('calculates caps ratio', () => { + expect(capsRatio('HELLO WORLD')).toBeGreaterThan(50); + expect(capsRatio('hello')).toBe(0); + }); + + it('counts emojis', () => { + expect(countEmojis('hello 😀😀')).toBe(2); + }); + + it('detects zalgo text', () => { + expect(hasZalgo('h\u0305e\u0305l\u0305l\u0305o\u0305')).toBe(true); + expect(hasZalgo('hello')).toBe(false); + }); + + it('normalizes hostnames', () => { + expect(normalizeHost('https://www.Example.com/path')).toBe('example.com'); + }); +}); diff --git a/packages/shared/src/phase2.ts b/packages/shared/src/phase2.ts new file mode 100644 index 0000000..852c3bb --- /dev/null +++ b/packages/shared/src/phase2.ts @@ -0,0 +1,211 @@ +import { z } from 'zod'; + +export const AutoModRuleTypeSchema = z.enum([ + 'SPAM', + 'MASS_MENTION', + 'CAPS', + 'INVITE_LINK', + 'EXTERNAL_LINK', + 'WORD_FILTER', + 'DUPLICATE', + 'EMOJI_SPAM', + 'ZALGO', + 'PHISHING' +]); +export type AutoModRuleType = z.infer; + +export const AutoModActionSchema = z.enum(['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN']); +export type AutoModAction = z.infer; + +export const AutoModExceptionsSchema = z.object({ + roleIds: z.array(z.string()).default([]), + channelIds: z.array(z.string()).default([]) +}); +export type AutoModExceptions = z.infer; + +export const AutoModThresholdSchema = z.object({ + maxMentions: z.number().int().positive().optional(), + capsPercent: z.number().min(0).max(100).optional(), + minLength: z.number().int().nonnegative().optional(), + maxMessages: z.number().int().positive().optional(), + windowSeconds: z.number().int().positive().optional(), + maxEmojis: z.number().int().positive().optional(), + duplicateCount: z.number().int().positive().optional(), + linkWhitelist: z.array(z.string()).optional(), + linkBlacklist: z.array(z.string()).optional() +}); +export type AutoModThreshold = z.infer; + +export const AutoModWordListSchema = z.object({ + words: z.array(z.string()).default([]), + regexPatterns: z.array(z.string()).default([]) +}); +export type AutoModWordList = z.infer; + +export const DEFAULT_AUTOMOD_RULES: Array<{ + ruleType: AutoModRuleType; + action: AutoModAction; + threshold?: AutoModThreshold; + wordList?: AutoModWordList; +}> = [ + { + ruleType: 'SPAM', + action: 'DELETE', + threshold: { maxMessages: 5, windowSeconds: 5 } + }, + { + ruleType: 'MASS_MENTION', + action: 'DELETE', + threshold: { maxMentions: 5 } + }, + { + ruleType: 'CAPS', + action: 'DELETE', + threshold: { capsPercent: 70, minLength: 10 } + }, + { + ruleType: 'INVITE_LINK', + action: 'DELETE' + }, + { + ruleType: 'EXTERNAL_LINK', + action: 'DELETE', + threshold: { linkWhitelist: ['discord.com', 'discord.gg'] } + }, + { + ruleType: 'WORD_FILTER', + action: 'DELETE', + wordList: { words: [], regexPatterns: [] } + }, + { + ruleType: 'DUPLICATE', + action: 'DELETE', + threshold: { duplicateCount: 3, windowSeconds: 30 } + }, + { + ruleType: 'EMOJI_SPAM', + action: 'DELETE', + threshold: { maxEmojis: 10 } + }, + { + ruleType: 'ZALGO', + action: 'DELETE' + }, + { + ruleType: 'PHISHING', + action: 'DELETE' + } +]; + +export const LogEventTypeSchema = z.enum([ + 'MESSAGE_EDIT', + 'MESSAGE_DELETE', + 'MESSAGE_BULK_DELETE', + 'MEMBER_JOIN', + 'MEMBER_LEAVE', + 'MEMBER_BAN', + 'MEMBER_UNBAN', + 'MEMBER_UPDATE', + 'CHANNEL_CREATE', + 'CHANNEL_DELETE', + 'CHANNEL_UPDATE', + 'VOICE_JOIN', + 'VOICE_LEAVE', + 'VOICE_MOVE', + 'INVITE_CREATE', + 'EMOJI_CREATE', + 'EMOJI_UPDATE', + 'EMOJI_DELETE', + 'STICKER_CREATE', + 'STICKER_UPDATE', + 'STICKER_DELETE', + 'THREAD_CREATE', + 'THREAD_UPDATE', + 'THREAD_DELETE', + 'GUILD_UPDATE', + 'MOD_ACTION' +]); +export type LogEventType = z.infer; + +export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']); +export type WelcomeMessageType = z.infer; + +export const WelcomeEmbedSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + color: z.number().int().optional() +}); +export type WelcomeEmbed = z.infer; + +export const VerificationModeSchema = z.enum(['BUTTON', 'CAPTCHA']); +export type VerificationMode = z.infer; + +export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']); +export type VerificationFailAction = z.infer; + +export const VerificationSetupSchema = z.object({ + channel: z.string().min(1), + verifiedRole: z.string().min(1), + unverifiedRole: z.string().optional(), + mode: VerificationModeSchema.default('BUTTON'), + minAccountAgeDays: z.number().int().nonnegative().default(0), + failAction: VerificationFailActionSchema.default('KICK') +}); +export type VerificationSetup = z.infer; + +export const WELCOME_PLACEHOLDERS = [ + '{user}', + '{user.name}', + '{user.tag}', + '{user.id}', + '{server}', + '{memberCount}' +] as const; + +export function applyWelcomePlaceholders( + template: string, + vars: Record +): string { + return Object.entries(vars).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + template + ); +} + +export function countEmojis(content: string): number { + const emojiRegex = + /(\p{Extended_Pictographic}|\p{Emoji_Presentation}|)/gu; + return content.match(emojiRegex)?.length ?? 0; +} + +export function capsRatio(content: string): number { + const letters = content.replace(/[^a-zA-Z]/g, ''); + if (letters.length === 0) { + return 0; + } + const caps = letters.replace(/[^A-Z]/g, '').length; + return (caps / letters.length) * 100; +} + +export function hasZalgo(text: string): boolean { + return /[\u0300-\u036f\u0489]/.test(text); +} + +export function extractUrls(content: string): string[] { + const urlRegex = /https?:\/\/[^\s<>]+/gi; + return content.match(urlRegex) ?? []; +} + +export function isDiscordInvite(content: string): boolean { + return /(?:https?:\/\/)?(?:www\.)?(?:discord\.(?:gg|io|me|li)|discordapp\.com\/invite|discord\.com\/invite)\/[^\s]+/i.test( + content + ); +} + +export function normalizeHost(url: string): string | null { + try { + return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); + } catch { + return null; + } +}