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:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.turbo
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
apps/*/dist
|
||||||
|
apps/*/.next
|
||||||
|
packages/*/dist
|
||||||
|
**/*.md
|
||||||
|
.cursor
|
||||||
@@ -12,3 +12,4 @@ BACKUP_RETENTION_DAYS=14
|
|||||||
BACKUP_CRON=0 3 * * *
|
BACKUP_CRON=0 3 * * *
|
||||||
BACKUP_DIR=/backups
|
BACKUP_DIR=/backups
|
||||||
HEALTH_PORT=8080
|
HEALTH_PORT=8080
|
||||||
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM node:22-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json pnpm-workspace.yaml turbo.json tsconfig.base.json ./
|
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
|
RUN pnpm install --frozen-lockfile=false
|
||||||
|
|
||||||
FROM deps AS build
|
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/shared build
|
||||||
RUN pnpm --filter @nexumi/bot prisma:generate
|
RUN pnpm --filter @nexumi/bot prisma:generate
|
||||||
RUN pnpm --filter @nexumi/bot build
|
RUN pnpm --filter @nexumi/bot build
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
|
||||||
RUN corepack enable
|
|
||||||
RUN apk add --no-cache postgresql16-client
|
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/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/bot/dist ./apps/bot/dist
|
COPY --from=build /app/apps/bot ./apps/bot
|
||||||
COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma
|
COPY --from=build /app/packages/shared ./packages/shared
|
||||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
CMD ["sh", "-c", "pnpm --filter @nexumi/bot prisma:migrate && node apps/bot/dist/index.js"]
|
||||||
COPY --from=build /app/apps/bot/package.json ./apps/bot/package.json
|
|
||||||
CMD ["node", "apps/bot/dist/index.js"]
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nexumi/shared": "workspace:*",
|
"@nexumi/shared": "workspace:*",
|
||||||
|
"@napi-rs/canvas": "^0.1.65",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"discord.js": "^14.17.3",
|
"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?
|
settings GuildSettings?
|
||||||
cases Case[]
|
cases Case[]
|
||||||
escalationRules EscalationRule[]
|
escalationRules EscalationRule[]
|
||||||
|
autoModConfig AutoModConfig?
|
||||||
|
autoModRules AutoModRule[]
|
||||||
|
loggingConfig LoggingConfig?
|
||||||
|
logChannels LogChannel[]
|
||||||
|
welcomeConfig WelcomeConfig?
|
||||||
|
verificationConfig VerificationConfig?
|
||||||
}
|
}
|
||||||
|
|
||||||
model GuildSettings {
|
model GuildSettings {
|
||||||
@@ -96,3 +102,104 @@ model EscalationRule {
|
|||||||
@@unique([guildId, warnCount])
|
@@unique([guildId, warnCount])
|
||||||
@@index([guildId, enabled])
|
@@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 { logger } from './logger.js';
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
import { moderationCommands } from './modules/moderation/commands.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';
|
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]));
|
const map = new Map(commands.map((c) => [c.data.name, c]));
|
||||||
|
|
||||||
export async function registerCommands() {
|
export async function registerCommands() {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const EnvSchema = z.object({
|
|||||||
BACKUP_CRON: z.string().default('0 3 * * *'),
|
BACKUP_CRON: z.string().default('0 3 * * *'),
|
||||||
BACKUP_DIR: z.string().default('/backups'),
|
BACKUP_DIR: z.string().default('/backups'),
|
||||||
METRICS_TOKEN: z.string().optional(),
|
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);
|
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 { 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 {
|
export function startHealthServer(): void {
|
||||||
const server = createServer((req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
if (!req.url) {
|
if (!req.url) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Bad Request');
|
res.end('Bad Request');
|
||||||
return;
|
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.statusCode = 200;
|
||||||
res.end('ok');
|
res.end('ok');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.url === '/metrics') {
|
|
||||||
|
if (url.pathname === '/metrics') {
|
||||||
const auth = req.headers.authorization;
|
const auth = req.headers.authorization;
|
||||||
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
|
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
@@ -24,6 +125,17 @@ export function startHealthServer(): void {
|
|||||||
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
|
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
|
||||||
return;
|
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.statusCode = 404;
|
||||||
res.end('Not Found');
|
res.end('Not Found');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
} from './modules/moderation/confirmations.js';
|
} from './modules/moderation/confirmations.js';
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
import { t } from '@nexumi/shared';
|
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');
|
const isShard = process.argv.includes('--shard');
|
||||||
|
|
||||||
@@ -38,20 +44,35 @@ if (!isShard) {
|
|||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.GuildModeration,
|
||||||
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
|
GatewayIntentBits.GuildEmojisAndStickers,
|
||||||
|
GatewayIntentBits.GuildInvites,
|
||||||
GatewayIntentBits.MessageContent
|
GatewayIntentBits.MessageContent
|
||||||
],
|
],
|
||||||
partials: [Partials.Channel]
|
partials: [Partials.Channel, Partials.Message, Partials.GuildMember]
|
||||||
});
|
});
|
||||||
|
|
||||||
const context: BotContext = { client, prisma, redis };
|
const context: BotContext = { client, prisma, redis };
|
||||||
startWorkers(context);
|
startWorkers(context);
|
||||||
await ensureRecurringJobs();
|
await ensureRecurringJobs();
|
||||||
|
registerLoggingEvents(context);
|
||||||
|
registerWelcomeEvents(context);
|
||||||
|
registerVerificationEvents(context);
|
||||||
|
|
||||||
client.on(Events.ClientReady, async () => {
|
client.on(Events.ClientReady, async () => {
|
||||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
||||||
await registerCommands();
|
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) => {
|
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
||||||
try {
|
try {
|
||||||
if (interaction.isChatInputCommand()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
@@ -63,6 +84,11 @@ if (!isShard) {
|
|||||||
await handleModerationConfirmation(interaction, context);
|
await handleModerationConfirmation(interaction, context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (interaction.isButton() && isVerificationButton(interaction.customId)) {
|
||||||
|
await handleVerificationButton(interaction, context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Interaction handling failed');
|
logger.error({ error }, 'Interaction handling failed');
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
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 { PermissionFlagsBits } from 'discord.js';
|
||||||
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
||||||
import { basename, join } from 'node:path';
|
import { basename, join } from 'node:path';
|
||||||
@@ -7,11 +7,24 @@ import { redis } from './redis.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
import { env } from './env.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 {
|
||||||
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
|
automodQueue,
|
||||||
export const backupQueueName = 'backups';
|
backupQueue,
|
||||||
export const backupQueue = new Queue(backupQueueName, { connection: redis });
|
moderationQueue,
|
||||||
|
moderationQueueName,
|
||||||
|
verificationQueue
|
||||||
|
} from './queues.js';
|
||||||
|
|
||||||
type TempBanJob = {
|
type TempBanJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -19,6 +32,11 @@ type TempBanJob = {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type VerificationCompleteJob = {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function startWorkers(context: BotContext): Worker[] {
|
export function startWorkers(context: BotContext): Worker[] {
|
||||||
const moderationWorker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
@@ -56,7 +74,38 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Backup job failed');
|
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> {
|
export async function ensureRecurringJobs(): Promise<void> {
|
||||||
@@ -69,6 +118,18 @@ export async function ensureRecurringJobs(): Promise<void> {
|
|||||||
removeOnFail: 100
|
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> {
|
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 type { BotContext } from '../../types.js';
|
||||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
@@ -43,6 +43,17 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
metadata: metadata as Prisma.InputJsonValue
|
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 });
|
||||||
@@ -36,6 +36,16 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- backups:/backups
|
- backups:/backups
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"node -e \"fetch('http://127.0.0.1:8080/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""
|
||||||
|
]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Phase 1 – Fundament (Status: in Arbeit)
|
## Assets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -59,24 +59,82 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
|
|||||||
- `GuildSettings`
|
- `GuildSettings`
|
||||||
|
|
||||||
- `User`
|
- `User`
|
||||||
- Prisma-Migrationen versioniert:
|
|
||||||
- `apps/bot/prisma/migrations/20260722123000_init`
|
- `Case`
|
||||||
|
|
||||||
|
- `Warning`
|
||||||
|
|
||||||
|
- `ModNote`
|
||||||
|
|
||||||
|
- `EscalationRule`
|
||||||
|
|
||||||
|
- Bot-Grundarchitektur:
|
||||||
|
|
||||||
- Sharding-Startpfad
|
- Sharding-Startpfad
|
||||||
|
|
||||||
- Command-Registrierung
|
- Command-Registrierung
|
||||||
|
|
||||||
### Noch offen für Phase 1
|
- Command-Routing
|
||||||
|
|
||||||
- `docker compose up -d` End-to-End gegen echte `.env` und Test-Discord-Guild final verifizieren.
|
- zentraler Permission-Check
|
||||||
|
|
||||||
|
- Basis-i18n in `@nexumi/shared`
|
||||||
|
|
||||||
### Verifikation (letzter Lauf)
|
- Guild-Locale-Auflösung über DB-Settings mit Fallback auf Env-Default
|
||||||
|
|
||||||
- `pnpm prisma:generate` erfolgreich
|
- Health-Endpoint (`/health`) und token-geschütztes `/metrics`-Scaffold
|
||||||
- `pnpm typecheck` erfolgreich
|
|
||||||
- `pnpm lint` erfolgreich
|
- BullMQ-Integration:
|
||||||
- `pnpm test` erfolgreich
|
|
||||||
|
- Moderation-Queue
|
||||||
|
|
||||||
|
- Temp-Ban-Expire-Job
|
||||||
|
|
||||||
|
- täglicher Backup-Job (`pg_dump`)
|
||||||
|
|
||||||
|
- Backup-Retention-Cleanup
|
||||||
|
|
||||||
|
- Moderation als Referenzmodul (implementiert):
|
||||||
|
|
||||||
|
- `/ban`, `/unban`, `/kick`, `/timeout`, `/untimeout`
|
||||||
|
|
||||||
|
- `/warn add|list|remove|clear`
|
||||||
|
|
||||||
|
- `/warn escalation-set|escalation-list|escalation-remove`
|
||||||
|
|
||||||
|
- `/purge` (Filter: User, Bots, Links, Attachments, Regex)
|
||||||
|
|
||||||
|
- `/slowmode`, `/lock`, `/unlock`
|
||||||
|
|
||||||
|
- `/nick set|reset`
|
||||||
|
|
||||||
|
- `/case view|edit|delete`
|
||||||
|
|
||||||
|
- `/modnote add|list`
|
||||||
|
|
||||||
|
- user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`)
|
||||||
|
|
||||||
|
- Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations)
|
||||||
|
|
||||||
|
- Bestätigungsflow für destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons
|
||||||
|
|
||||||
|
- standardisierte Case-Audit-Metadaten über `@nexumi/shared` (`buildCaseMetadata`)
|
||||||
|
|
||||||
|
- Docker-Build/Runtime-Fixes für E2E:
|
||||||
|
|
||||||
|
- `.dockerignore` ergänzt
|
||||||
|
|
||||||
|
- Prisma-Migration beim Bot-Start (`prisma migrate deploy`)
|
||||||
|
|
||||||
|
- Bot-Healthcheck auf `/health`
|
||||||
|
|
||||||
|
- E2E-Verifikation (automatisiert, lokal):
|
||||||
|
|
||||||
|
- `docker compose up -d --build` erfolgreich
|
||||||
|
|
||||||
|
- Postgres/Redis healthy
|
||||||
|
|
||||||
- Migration `20260722123000_init` angewendet
|
- Migration `20260722123000_init` angewendet
|
||||||
|
|
||||||
- Bot online (`Nexumi#9122`), 13 Slash-Commands registriert
|
- Bot online (`Nexumi#9122`), 13 Slash-Commands registriert
|
||||||
- Phase 1 vollständig abschließen (offene Punkte oben), dann erst nach expliziter Freigabe in Phase 2 wechseln.
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ export const CaseAuditSourceSchema = z.enum([
|
|||||||
'slash_command',
|
'slash_command',
|
||||||
'button_confirmation',
|
'button_confirmation',
|
||||||
'escalation',
|
'escalation',
|
||||||
'job'
|
'job',
|
||||||
|
'automod'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type CaseAuditSource = z.infer<typeof CaseAuditSourceSchema>;
|
export type CaseAuditSource = z.infer<typeof CaseAuditSourceSchema>;
|
||||||
|
|||||||
@@ -214,6 +214,66 @@ export const commandLocales = {
|
|||||||
'moderation.common.options.target': {
|
'moderation.common.options.target': {
|
||||||
de: 'Ziel',
|
de: 'Ziel',
|
||||||
en: 'Target'
|
en: 'Target'
|
||||||
|
},
|
||||||
|
'automod.description': {
|
||||||
|
de: 'Auto-Moderation verwalten',
|
||||||
|
en: 'Manage auto-moderation'
|
||||||
|
},
|
||||||
|
'automod.status.description': {
|
||||||
|
de: 'Aktive AutoMod-Regeln anzeigen',
|
||||||
|
en: 'Show active AutoMod rules'
|
||||||
|
},
|
||||||
|
'welcome.description': {
|
||||||
|
de: 'Willkommensnachrichten testen',
|
||||||
|
en: 'Test welcome messages'
|
||||||
|
},
|
||||||
|
'welcome.test.description': {
|
||||||
|
de: 'Test-Willkommensnachricht senden',
|
||||||
|
en: 'Send a test welcome message'
|
||||||
|
},
|
||||||
|
'welcome.preview.description': {
|
||||||
|
de: 'Vorschau der Willkommensnachricht',
|
||||||
|
en: 'Preview the welcome message'
|
||||||
|
},
|
||||||
|
'verify.description': {
|
||||||
|
de: 'Verifizierung einrichten',
|
||||||
|
en: 'Configure verification'
|
||||||
|
},
|
||||||
|
'verify.setup.description': {
|
||||||
|
de: 'Verifizierung konfigurieren',
|
||||||
|
en: 'Configure verification'
|
||||||
|
},
|
||||||
|
'verify.setup.options.channel': {
|
||||||
|
de: 'Verifizierungskanal',
|
||||||
|
en: 'Verification channel'
|
||||||
|
},
|
||||||
|
'verify.setup.options.verified_role': {
|
||||||
|
de: 'Rolle nach Verifizierung',
|
||||||
|
en: 'Role after verification'
|
||||||
|
},
|
||||||
|
'verify.setup.options.unverified_role': {
|
||||||
|
de: 'Rolle vor Verifizierung',
|
||||||
|
en: 'Role before verification'
|
||||||
|
},
|
||||||
|
'verify.setup.options.mode': {
|
||||||
|
de: 'Verifizierungsmodus',
|
||||||
|
en: 'Verification mode'
|
||||||
|
},
|
||||||
|
'verify.setup.options.min_account_age_days': {
|
||||||
|
de: 'Mindest-Accountalter in Tagen',
|
||||||
|
en: 'Minimum account age in days'
|
||||||
|
},
|
||||||
|
'verify.setup.options.fail_action': {
|
||||||
|
de: 'Aktion bei Fehlschlag',
|
||||||
|
en: 'Action on failure'
|
||||||
|
},
|
||||||
|
'verify.panel.description': {
|
||||||
|
de: 'Verifizierungs-Panel posten',
|
||||||
|
en: 'Post verification panel'
|
||||||
|
},
|
||||||
|
'verify.panel.options.channel': {
|
||||||
|
de: 'Optionaler Kanal für das Panel',
|
||||||
|
en: 'Optional channel for the panel'
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
export * from './audit.js';
|
export * from './audit.js';
|
||||||
export * from './command-locales.js';
|
export * from './command-locales.js';
|
||||||
export * from './discord-localization.js';
|
export * from './discord-localization.js';
|
||||||
|
export * from './phase2.js';
|
||||||
|
|
||||||
export const LocaleSchema = z.enum(['de', 'en']);
|
export const LocaleSchema = z.enum(['de', 'en']);
|
||||||
export type Locale = z.infer<typeof LocaleSchema>;
|
export type Locale = z.infer<typeof LocaleSchema>;
|
||||||
@@ -66,7 +67,32 @@ const de: Dictionary = {
|
|||||||
'moderation.confirm.caseDelete': 'Fall #{caseNumber} wirklich löschen?',
|
'moderation.confirm.caseDelete': 'Fall #{caseNumber} wirklich löschen?',
|
||||||
'moderation.confirm.expired': 'Bestätigung abgelaufen. Bitte den Befehl erneut ausführen.',
|
'moderation.confirm.expired': 'Bestätigung abgelaufen. Bitte den Befehl erneut ausführen.',
|
||||||
'moderation.confirm.cancelled': 'Aktion abgebrochen.',
|
'moderation.confirm.cancelled': 'Aktion abgebrochen.',
|
||||||
'moderation.confirm.unauthorized': 'Nur der ausführende Moderator kann diese Bestätigung nutzen.'
|
'moderation.confirm.unauthorized': 'Nur der ausführende Moderator kann diese Bestätigung nutzen.',
|
||||||
|
'automod.status.header':
|
||||||
|
'**AutoMod enabled:** {enabled}\n**Anti-raid:** {antiRaid}\n**Anti-nuke:** {antiNuke}\n**Lockdown:** {lockdown}',
|
||||||
|
'automod.status.ruleLine': '- {type} → {action}',
|
||||||
|
'automod.status.noRules': 'Keine aktiven Regeln.',
|
||||||
|
'automod.status.yes': 'ja',
|
||||||
|
'automod.status.no': 'nein',
|
||||||
|
'welcome.notConfigured': 'Willkommensnachricht ist noch nicht konfiguriert.',
|
||||||
|
'welcome.invalidChannel': 'Der konfigurierte Willkommenskanal ist ungültig.',
|
||||||
|
'welcome.testSent': 'Test-Willkommensnachricht gesendet.',
|
||||||
|
'verification.disabled': 'Verifizierung ist deaktiviert.',
|
||||||
|
'verification.success': 'Du wurdest erfolgreich verifiziert.',
|
||||||
|
'verification.setupDone': 'Verifizierung konfiguriert.',
|
||||||
|
'verification.notConfigured': 'Verifizierung ist noch nicht eingerichtet. Nutze `/verify setup`.',
|
||||||
|
'verification.invalidChannel': 'Der Verifizierungskanal ist ungültig.',
|
||||||
|
'verification.panelPosted': 'Verifizierungs-Panel gepostet.',
|
||||||
|
'verification.captchaLink': 'Öffne diesen Link zur Captcha-Verifizierung: {url}',
|
||||||
|
'verification.error.not_configured': 'Verifizierung ist nicht konfiguriert.',
|
||||||
|
'verification.error.member_not_found': 'Mitglied nicht gefunden.',
|
||||||
|
'verification.error.account_too_young': 'Dein Account ist zu jung für die Verifizierung.',
|
||||||
|
'verification.error.missing_permissions': 'Dem Bot fehlen Berechtigungen.',
|
||||||
|
'verification.error.invalid_role': 'Die Verifizierungsrolle ist ungültig.',
|
||||||
|
'verification.error.generic': 'Verifizierung fehlgeschlagen.',
|
||||||
|
'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.',
|
||||||
|
'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.',
|
||||||
|
'verification.captcha.title': 'Nexumi Verifizierung'
|
||||||
};
|
};
|
||||||
const en: Dictionary = {
|
const en: Dictionary = {
|
||||||
'generic.noPermission': 'You do not have permission for this action.',
|
'generic.noPermission': 'You do not have permission for this action.',
|
||||||
@@ -97,7 +123,32 @@ const en: Dictionary = {
|
|||||||
'moderation.confirm.caseDelete': 'Really delete case #{caseNumber}?',
|
'moderation.confirm.caseDelete': 'Really delete case #{caseNumber}?',
|
||||||
'moderation.confirm.expired': 'Confirmation expired. Please run the command again.',
|
'moderation.confirm.expired': 'Confirmation expired. Please run the command again.',
|
||||||
'moderation.confirm.cancelled': 'Action cancelled.',
|
'moderation.confirm.cancelled': 'Action cancelled.',
|
||||||
'moderation.confirm.unauthorized': 'Only the moderator who started this action can confirm it.'
|
'moderation.confirm.unauthorized': 'Only the moderator who started this action can confirm it.',
|
||||||
|
'automod.status.header':
|
||||||
|
'**AutoMod enabled:** {enabled}\n**Anti-raid:** {antiRaid}\n**Anti-nuke:** {antiNuke}\n**Lockdown:** {lockdown}',
|
||||||
|
'automod.status.ruleLine': '- {type} → {action}',
|
||||||
|
'automod.status.noRules': 'No active rules.',
|
||||||
|
'automod.status.yes': 'yes',
|
||||||
|
'automod.status.no': 'no',
|
||||||
|
'welcome.notConfigured': 'Welcome message is not configured yet.',
|
||||||
|
'welcome.invalidChannel': 'The configured welcome channel is invalid.',
|
||||||
|
'welcome.testSent': 'Test welcome message sent.',
|
||||||
|
'verification.disabled': 'Verification is disabled.',
|
||||||
|
'verification.success': 'You have been verified successfully.',
|
||||||
|
'verification.setupDone': 'Verification configured.',
|
||||||
|
'verification.notConfigured': 'Verification is not set up yet. Use `/verify setup`.',
|
||||||
|
'verification.invalidChannel': 'The verification channel is invalid.',
|
||||||
|
'verification.panelPosted': 'Verification panel posted.',
|
||||||
|
'verification.captchaLink': 'Open this link to complete captcha verification: {url}',
|
||||||
|
'verification.error.not_configured': 'Verification is not configured.',
|
||||||
|
'verification.error.member_not_found': 'Member not found.',
|
||||||
|
'verification.error.account_too_young': 'Your account is too young to verify.',
|
||||||
|
'verification.error.missing_permissions': 'Bot lacks permissions.',
|
||||||
|
'verification.error.invalid_role': 'Verification role is invalid.',
|
||||||
|
'verification.error.generic': 'Verification failed.',
|
||||||
|
'verification.captcha.success': 'Captcha passed. You have been verified.',
|
||||||
|
'verification.captcha.invalid': 'Captcha invalid or expired.',
|
||||||
|
'verification.captcha.title': 'Nexumi Verification'
|
||||||
};
|
};
|
||||||
|
|
||||||
const locales: Record<Locale, Dictionary> = { de, en };
|
const locales: Record<Locale, Dictionary> = { de, en };
|
||||||
|
|||||||
42
packages/shared/src/phase2.test.ts
Normal file
42
packages/shared/src/phase2.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
applyWelcomePlaceholders,
|
||||||
|
capsRatio,
|
||||||
|
countEmojis,
|
||||||
|
hasZalgo,
|
||||||
|
isDiscordInvite,
|
||||||
|
normalizeHost
|
||||||
|
} from './phase2.js';
|
||||||
|
|
||||||
|
describe('phase2 helpers', () => {
|
||||||
|
it('replaces welcome placeholders', () => {
|
||||||
|
const result = applyWelcomePlaceholders('Hello {user} on {server}', {
|
||||||
|
user: '<@1>',
|
||||||
|
server: 'Nexumi'
|
||||||
|
});
|
||||||
|
expect(result).toBe('Hello <@1> on Nexumi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects discord invites', () => {
|
||||||
|
expect(isDiscordInvite('join https://discord.gg/test')).toBe(true);
|
||||||
|
expect(isDiscordInvite('hello world')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates caps ratio', () => {
|
||||||
|
expect(capsRatio('HELLO WORLD')).toBeGreaterThan(50);
|
||||||
|
expect(capsRatio('hello')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts emojis', () => {
|
||||||
|
expect(countEmojis('hello 😀😀')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects zalgo text', () => {
|
||||||
|
expect(hasZalgo('h\u0305e\u0305l\u0305l\u0305o\u0305')).toBe(true);
|
||||||
|
expect(hasZalgo('hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes hostnames', () => {
|
||||||
|
expect(normalizeHost('https://www.Example.com/path')).toBe('example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
211
packages/shared/src/phase2.ts
Normal file
211
packages/shared/src/phase2.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const AutoModRuleTypeSchema = z.enum([
|
||||||
|
'SPAM',
|
||||||
|
'MASS_MENTION',
|
||||||
|
'CAPS',
|
||||||
|
'INVITE_LINK',
|
||||||
|
'EXTERNAL_LINK',
|
||||||
|
'WORD_FILTER',
|
||||||
|
'DUPLICATE',
|
||||||
|
'EMOJI_SPAM',
|
||||||
|
'ZALGO',
|
||||||
|
'PHISHING'
|
||||||
|
]);
|
||||||
|
export type AutoModRuleType = z.infer<typeof AutoModRuleTypeSchema>;
|
||||||
|
|
||||||
|
export const AutoModActionSchema = z.enum(['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN']);
|
||||||
|
export type AutoModAction = z.infer<typeof AutoModActionSchema>;
|
||||||
|
|
||||||
|
export const AutoModExceptionsSchema = z.object({
|
||||||
|
roleIds: z.array(z.string()).default([]),
|
||||||
|
channelIds: z.array(z.string()).default([])
|
||||||
|
});
|
||||||
|
export type AutoModExceptions = z.infer<typeof AutoModExceptionsSchema>;
|
||||||
|
|
||||||
|
export const AutoModThresholdSchema = z.object({
|
||||||
|
maxMentions: z.number().int().positive().optional(),
|
||||||
|
capsPercent: z.number().min(0).max(100).optional(),
|
||||||
|
minLength: z.number().int().nonnegative().optional(),
|
||||||
|
maxMessages: z.number().int().positive().optional(),
|
||||||
|
windowSeconds: z.number().int().positive().optional(),
|
||||||
|
maxEmojis: z.number().int().positive().optional(),
|
||||||
|
duplicateCount: z.number().int().positive().optional(),
|
||||||
|
linkWhitelist: z.array(z.string()).optional(),
|
||||||
|
linkBlacklist: z.array(z.string()).optional()
|
||||||
|
});
|
||||||
|
export type AutoModThreshold = z.infer<typeof AutoModThresholdSchema>;
|
||||||
|
|
||||||
|
export const AutoModWordListSchema = z.object({
|
||||||
|
words: z.array(z.string()).default([]),
|
||||||
|
regexPatterns: z.array(z.string()).default([])
|
||||||
|
});
|
||||||
|
export type AutoModWordList = z.infer<typeof AutoModWordListSchema>;
|
||||||
|
|
||||||
|
export const DEFAULT_AUTOMOD_RULES: Array<{
|
||||||
|
ruleType: AutoModRuleType;
|
||||||
|
action: AutoModAction;
|
||||||
|
threshold?: AutoModThreshold;
|
||||||
|
wordList?: AutoModWordList;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
ruleType: 'SPAM',
|
||||||
|
action: 'DELETE',
|
||||||
|
threshold: { maxMessages: 5, windowSeconds: 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'MASS_MENTION',
|
||||||
|
action: 'DELETE',
|
||||||
|
threshold: { maxMentions: 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'CAPS',
|
||||||
|
action: 'DELETE',
|
||||||
|
threshold: { capsPercent: 70, minLength: 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'INVITE_LINK',
|
||||||
|
action: 'DELETE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'EXTERNAL_LINK',
|
||||||
|
action: 'DELETE',
|
||||||
|
threshold: { linkWhitelist: ['discord.com', 'discord.gg'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'WORD_FILTER',
|
||||||
|
action: 'DELETE',
|
||||||
|
wordList: { words: [], regexPatterns: [] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'DUPLICATE',
|
||||||
|
action: 'DELETE',
|
||||||
|
threshold: { duplicateCount: 3, windowSeconds: 30 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'EMOJI_SPAM',
|
||||||
|
action: 'DELETE',
|
||||||
|
threshold: { maxEmojis: 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'ZALGO',
|
||||||
|
action: 'DELETE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleType: 'PHISHING',
|
||||||
|
action: 'DELETE'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LogEventTypeSchema = z.enum([
|
||||||
|
'MESSAGE_EDIT',
|
||||||
|
'MESSAGE_DELETE',
|
||||||
|
'MESSAGE_BULK_DELETE',
|
||||||
|
'MEMBER_JOIN',
|
||||||
|
'MEMBER_LEAVE',
|
||||||
|
'MEMBER_BAN',
|
||||||
|
'MEMBER_UNBAN',
|
||||||
|
'MEMBER_UPDATE',
|
||||||
|
'CHANNEL_CREATE',
|
||||||
|
'CHANNEL_DELETE',
|
||||||
|
'CHANNEL_UPDATE',
|
||||||
|
'VOICE_JOIN',
|
||||||
|
'VOICE_LEAVE',
|
||||||
|
'VOICE_MOVE',
|
||||||
|
'INVITE_CREATE',
|
||||||
|
'EMOJI_CREATE',
|
||||||
|
'EMOJI_UPDATE',
|
||||||
|
'EMOJI_DELETE',
|
||||||
|
'STICKER_CREATE',
|
||||||
|
'STICKER_UPDATE',
|
||||||
|
'STICKER_DELETE',
|
||||||
|
'THREAD_CREATE',
|
||||||
|
'THREAD_UPDATE',
|
||||||
|
'THREAD_DELETE',
|
||||||
|
'GUILD_UPDATE',
|
||||||
|
'MOD_ACTION'
|
||||||
|
]);
|
||||||
|
export type LogEventType = z.infer<typeof LogEventTypeSchema>;
|
||||||
|
|
||||||
|
export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
|
||||||
|
export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>;
|
||||||
|
|
||||||
|
export const WelcomeEmbedSchema = z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
color: z.number().int().optional()
|
||||||
|
});
|
||||||
|
export type WelcomeEmbed = z.infer<typeof WelcomeEmbedSchema>;
|
||||||
|
|
||||||
|
export const VerificationModeSchema = z.enum(['BUTTON', 'CAPTCHA']);
|
||||||
|
export type VerificationMode = z.infer<typeof VerificationModeSchema>;
|
||||||
|
|
||||||
|
export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']);
|
||||||
|
export type VerificationFailAction = z.infer<typeof VerificationFailActionSchema>;
|
||||||
|
|
||||||
|
export const VerificationSetupSchema = z.object({
|
||||||
|
channel: z.string().min(1),
|
||||||
|
verifiedRole: z.string().min(1),
|
||||||
|
unverifiedRole: z.string().optional(),
|
||||||
|
mode: VerificationModeSchema.default('BUTTON'),
|
||||||
|
minAccountAgeDays: z.number().int().nonnegative().default(0),
|
||||||
|
failAction: VerificationFailActionSchema.default('KICK')
|
||||||
|
});
|
||||||
|
export type VerificationSetup = z.infer<typeof VerificationSetupSchema>;
|
||||||
|
|
||||||
|
export const WELCOME_PLACEHOLDERS = [
|
||||||
|
'{user}',
|
||||||
|
'{user.name}',
|
||||||
|
'{user.tag}',
|
||||||
|
'{user.id}',
|
||||||
|
'{server}',
|
||||||
|
'{memberCount}'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function applyWelcomePlaceholders(
|
||||||
|
template: string,
|
||||||
|
vars: Record<string, string | number>
|
||||||
|
): string {
|
||||||
|
return Object.entries(vars).reduce(
|
||||||
|
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||||
|
template
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countEmojis(content: string): number {
|
||||||
|
const emojiRegex =
|
||||||
|
/(\p{Extended_Pictographic}|\p{Emoji_Presentation}|<a?:\w+:\d+>)/gu;
|
||||||
|
return content.match(emojiRegex)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capsRatio(content: string): number {
|
||||||
|
const letters = content.replace(/[^a-zA-Z]/g, '');
|
||||||
|
if (letters.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const caps = letters.replace(/[^A-Z]/g, '').length;
|
||||||
|
return (caps / letters.length) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasZalgo(text: string): boolean {
|
||||||
|
return /[\u0300-\u036f\u0489]/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractUrls(content: string): string[] {
|
||||||
|
const urlRegex = /https?:\/\/[^\s<>]+/gi;
|
||||||
|
return content.match(urlRegex) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDiscordInvite(content: string): boolean {
|
||||||
|
return /(?:https?:\/\/)?(?:www\.)?(?:discord\.(?:gg|io|me|li)|discordapp\.com\/invite|discord\.com\/invite)\/[^\s]+/i.test(
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHost(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '').toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user