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:
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
21
apps/bot/src/guild.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
287
apps/bot/src/modules/automod/actions.ts
Normal file
287
apps/bot/src/modules/automod/actions.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
9
apps/bot/src/modules/automod/command-definitions.ts
Normal file
9
apps/bot/src/modules/automod/command-definitions.ts
Normal 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'
|
||||
);
|
||||
57
apps/bot/src/modules/automod/commands.ts
Normal file
57
apps/bot/src/modules/automod/commands.ts
Normal 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];
|
||||
231
apps/bot/src/modules/automod/filters.ts
Normal file
231
apps/bot/src/modules/automod/filters.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
82
apps/bot/src/modules/automod/service.ts
Normal file
82
apps/bot/src/modules/automod/service.ts
Normal 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));
|
||||
}
|
||||
537
apps/bot/src/modules/logging/events.ts
Normal file
537
apps/bot/src/modules/logging/events.ts
Normal 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')
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
53
apps/bot/src/modules/verification/command-definitions.ts
Normal file
53
apps/bot/src/modules/verification/command-definitions.ts
Normal 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'
|
||||
);
|
||||
84
apps/bot/src/modules/verification/commands.ts
Normal file
84
apps/bot/src/modules/verification/commands.ts
Normal 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];
|
||||
27
apps/bot/src/modules/verification/events.ts
Normal file
27
apps/bot/src/modules/verification/events.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
149
apps/bot/src/modules/verification/handlers.ts
Normal file
149
apps/bot/src/modules/verification/handlers.ts
Normal 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) });
|
||||
}
|
||||
115
apps/bot/src/modules/verification/service.ts
Normal file
115
apps/bot/src/modules/verification/service.ts
Normal 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));
|
||||
}
|
||||
14
apps/bot/src/modules/welcome/command-definitions.ts
Normal file
14
apps/bot/src/modules/welcome/command-definitions.ts
Normal 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'
|
||||
);
|
||||
56
apps/bot/src/modules/welcome/commands.ts
Normal file
56
apps/bot/src/modules/welcome/commands.ts
Normal 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];
|
||||
75
apps/bot/src/modules/welcome/events.ts
Normal file
75
apps/bot/src/modules/welcome/events.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
107
apps/bot/src/modules/welcome/renderer.ts
Normal file
107
apps/bot/src/modules/welcome/renderer.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
56
apps/bot/src/modules/welcome/service.ts
Normal file
56
apps/bot/src/modules/welcome/service.ts
Normal 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
11
apps/bot/src/queues.ts
Normal 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 });
|
||||
Reference in New Issue
Block a user