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.
This commit is contained in:
smueller
2026-07-22 12:28:42 +02:00
parent a44f4d6641
commit 2518119257
37 changed files with 3002 additions and 108 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.git
.turbo
dist
coverage
.env
.env.local
apps/*/dist
apps/*/.next
packages/*/dist
**/*.md
.cursor

View File

@@ -12,3 +12,4 @@ BACKUP_RETENTION_DAYS=14
BACKUP_CRON=0 3 * * *
BACKUP_DIR=/backups
HEALTH_PORT=8080
PUBLIC_BASE_URL=http://localhost:8080

View File

@@ -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"]

View File

@@ -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",

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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);

21
apps/bot/src/guild.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { PrismaClient } from '@prisma/client';
export async function ensureGuild(prisma: PrismaClient, guildId: string): Promise<void> {
await prisma.guild.upsert({
where: { id: guildId },
update: {},
create: { id: guildId }
});
}
export async function ensureGuildSettings(
prisma: PrismaClient,
guildId: string
): Promise<void> {
await ensureGuild(prisma, guildId);
await prisma.guildSettings.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}

View File

@@ -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<string> {
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<string, string> {
return Object.fromEntries(new URLSearchParams(body));
}
function renderCaptchaPage(token: string, question: string, error?: string): string {
const errorBlock = error ? `<p style="color:#ef4444">${error}</p>` : '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nexumi Verification</title>
<style>
body { font-family: Inter, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; }
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
</style>
</head>
<body>
<form method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>Solve: <strong>${question} = ?</strong></p>
${errorBlock}
<input type="hidden" name="token" value="${token}" />
<input type="number" name="answer" required autofocus />
<button type="submit">Verify</button>
</form>
</body>
</html>`;
}
async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
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<void> {
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('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
}
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');
});

View File

@@ -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);

View File

@@ -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<TempBanJob>(
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<VerificationCompleteJob>(
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<void> {
@@ -69,6 +118,18 @@ export async function ensureRecurringJobs(): Promise<void> {
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<void> {

View File

@@ -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<void> {
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<FilterResult | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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 }
});
}

View File

@@ -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'
);

View File

@@ -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];

View File

@@ -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<string>;
};
export type FilterResult = {
matched: boolean;
ruleType: AutoModRuleType;
reason: string;
};
export async function loadPhishingDomains(redis: Redis): Promise<Set<string>> {
const cached = await redis.smembers(PHISHING_CACHE_KEY);
if (cached.length > 0) {
return new Set(cached);
}
return new Set<string>();
}
export async function refreshPhishingDomains(redis: Redis): Promise<number> {
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<FilterResult | null> {
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<FilterResult | null> {
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<string>): 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<FilterResult | null> {
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;
}
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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));
}

View File

@@ -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<string | null> {
const entry = await prisma.logChannel.findUnique({
where: { guildId_eventType: { guildId, eventType } }
});
return entry?.channelId ?? null;
}
function shouldIgnoreMember(
config: Awaited<ReturnType<typeof getLoggingConfig>>,
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<ReturnType<typeof getLoggingConfig>>,
channelId: string | null
): boolean {
if (!channelId) {
return false;
}
return config.ignoredChannelIds.includes(channelId);
}
async function resolveLogChannel(
context: BotContext,
guild: Guild,
eventType: LogEventType
): Promise<TextChannel | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<ReturnType<typeof getLoggingConfig>>,
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<void> {
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')
);
});
}

View File

@@ -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;
});
}

View File

@@ -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'
);

View File

@@ -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];

View File

@@ -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');
});
});
}

View File

@@ -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<void> {
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<ReturnType<typeof getVerificationConfig>>) {
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<ButtonBuilder>().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<void> {
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) });
}

View File

@@ -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<CaptchaChallenge> {
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<CaptchaChallenge | null> {
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<void> {
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));
}

View File

@@ -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'
);

View File

@@ -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];

View File

@@ -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<void> {
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<void> {
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<void> {
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);
});
}

View File

@@ -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<Buffer> {
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<void> {
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<void> {
if ('send' in channel) {
await (channel as SendableChannels).send({ content: renderWelcomeText(content, member, guild) });
}
}

View File

@@ -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<ReturnType<typeof getWelcomeConfig>>
): WelcomePayload {
return {
type: config.welcomeType as WelcomeMessageType,
content: config.welcomeContent,
embed: parseWelcomeEmbed(config.welcomeEmbed),
imageTitle: config.imageTitle,
imageSubtitle: config.imageSubtitle
};
}

11
apps/bot/src/queues.ts Normal file
View File

@@ -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 });

View File

@@ -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

View File

@@ -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

View File

@@ -4,7 +4,8 @@ export const CaseAuditSourceSchema = z.enum([
'slash_command',
'button_confirmation',
'escalation',
'job'
'job',
'automod'
]);
export type CaseAuditSource = z.infer<typeof CaseAuditSourceSchema>;

View File

@@ -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;

View File

@@ -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<typeof LocaleSchema>;
@@ -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<Locale, Dictionary> = { de, en };

View File

@@ -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');
});
});

View File

@@ -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<typeof AutoModRuleTypeSchema>;
export const AutoModActionSchema = z.enum(['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN']);
export type AutoModAction = z.infer<typeof AutoModActionSchema>;
export const AutoModExceptionsSchema = z.object({
roleIds: z.array(z.string()).default([]),
channelIds: z.array(z.string()).default([])
});
export type AutoModExceptions = z.infer<typeof AutoModExceptionsSchema>;
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<typeof AutoModThresholdSchema>;
export const AutoModWordListSchema = z.object({
words: z.array(z.string()).default([]),
regexPatterns: z.array(z.string()).default([])
});
export type AutoModWordList = z.infer<typeof AutoModWordListSchema>;
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<typeof LogEventTypeSchema>;
export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>;
export const WelcomeEmbedSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
color: z.number().int().optional()
});
export type WelcomeEmbed = z.infer<typeof WelcomeEmbedSchema>;
export const VerificationModeSchema = z.enum(['BUTTON', 'CAPTCHA']);
export type VerificationMode = z.infer<typeof VerificationModeSchema>;
export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']);
export type VerificationFailAction = z.infer<typeof VerificationFailActionSchema>;
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<typeof VerificationSetupSchema>;
export const WELCOME_PLACEHOLDERS = [
'{user}',
'{user.name}',
'{user.tag}',
'{user.id}',
'{server}',
'{memberCount}'
] as const;
export function applyWelcomePlaceholders(
template: string,
vars: Record<string, string | number>
): 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}|<a?:\w+:\d+>)/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;
}
}