Enhance bot functionality with AutoMod, Logging, Welcome, and Verification features

- Implemented AutoMod capabilities including spam filtering, anti-raid, and anti-nuke actions.
- Added Logging module to track moderation actions and events across the bot.
- Introduced Welcome module for customizable welcome messages and autoroles.
- Developed Verification system with captcha and role assignment features.
- Updated Prisma schema to include new models for AutoMod, Logging, Welcome, and Verification.
- Enhanced command localization for new features in both German and English.
- Improved health server to handle captcha verification requests.
- Added new environment variable for public base URL to support captcha links.
This commit is contained in:
smueller
2026-07-22 12:28:42 +02:00
parent a44f4d6641
commit 2518119257
37 changed files with 3002 additions and 108 deletions

View File

@@ -1,6 +1,7 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN corepack enable
RUN apk add --no-cache openssl
FROM base AS deps
COPY package.json pnpm-workspace.yaml turbo.json tsconfig.base.json ./
@@ -9,18 +10,19 @@ COPY packages/shared/package.json packages/shared/package.json
RUN pnpm install --frozen-lockfile=false
FROM deps AS build
COPY . .
COPY apps ./apps
COPY packages ./packages
COPY eslint.config.js .eslintrc.cjs .prettierrc ./
RUN pnpm install --frozen-lockfile=false
RUN pnpm --filter @nexumi/shared build
RUN pnpm --filter @nexumi/bot prisma:generate
RUN pnpm --filter @nexumi/bot build
FROM node:22-alpine AS runner
WORKDIR /app
RUN corepack enable
FROM base AS runner
RUN apk add --no-cache postgresql16-client
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/bot/dist ./apps/bot/dist
COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/apps/bot/package.json ./apps/bot/package.json
CMD ["node", "apps/bot/dist/index.js"]
COPY --from=build /app/apps/bot ./apps/bot
COPY --from=build /app/packages/shared ./packages/shared
CMD ["sh", "-c", "pnpm --filter @nexumi/bot prisma:migrate && node apps/bot/dist/index.js"]

View File

@@ -14,6 +14,7 @@
},
"dependencies": {
"@nexumi/shared": "workspace:*",
"@napi-rs/canvas": "^0.1.65",
"@prisma/client": "^5.22.0",
"bullmq": "^5.34.0",
"discord.js": "^14.17.3",

View File

@@ -0,0 +1,148 @@
-- CreateTable
CREATE TABLE "AutoModConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"antiRaidEnabled" BOOLEAN NOT NULL DEFAULT false,
"antiRaidJoinThreshold" INTEGER NOT NULL DEFAULT 10,
"antiRaidWindowSeconds" INTEGER NOT NULL DEFAULT 10,
"antiRaidAction" TEXT NOT NULL DEFAULT 'LOCKDOWN',
"antiNukeEnabled" BOOLEAN NOT NULL DEFAULT false,
"antiNukeBanThreshold" INTEGER NOT NULL DEFAULT 5,
"antiNukeChannelThreshold" INTEGER NOT NULL DEFAULT 3,
"antiNukeWindowSeconds" INTEGER NOT NULL DEFAULT 60,
"lockdownActive" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutoModConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AutoModRule" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"ruleType" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"action" TEXT NOT NULL DEFAULT 'DELETE',
"threshold" JSONB,
"exceptions" JSONB,
"wordList" JSONB,
"durationMs" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutoModRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoggingConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"ignoreBots" BOOLEAN NOT NULL DEFAULT true,
"ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"ignoredRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoggingConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LogChannel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LogChannel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WelcomeConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"welcomeEnabled" BOOLEAN NOT NULL DEFAULT false,
"leaveEnabled" BOOLEAN NOT NULL DEFAULT false,
"welcomeChannelId" TEXT,
"leaveChannelId" TEXT,
"welcomeType" TEXT NOT NULL DEFAULT 'TEXT',
"welcomeContent" TEXT,
"welcomeEmbed" JSONB,
"leaveContent" TEXT,
"leaveEmbed" JSONB,
"welcomeDmEnabled" BOOLEAN NOT NULL DEFAULT false,
"welcomeDmContent" TEXT,
"userAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"botAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"imageTitle" TEXT,
"imageSubtitle" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WelcomeConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"channelId" TEXT,
"verifiedRoleId" TEXT,
"unverifiedRoleId" TEXT,
"mode" TEXT NOT NULL DEFAULT 'BUTTON',
"minAccountAgeDays" INTEGER NOT NULL DEFAULT 0,
"failAction" TEXT NOT NULL DEFAULT 'KICK',
"panelChannelId" TEXT,
"panelMessageId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VerificationConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AutoModConfig_guildId_key" ON "AutoModConfig"("guildId");
-- CreateIndex
CREATE INDEX "AutoModRule_guildId_enabled_idx" ON "AutoModRule"("guildId", "enabled");
-- CreateIndex
CREATE UNIQUE INDEX "AutoModRule_guildId_ruleType_key" ON "AutoModRule"("guildId", "ruleType");
-- CreateIndex
CREATE UNIQUE INDEX "LoggingConfig_guildId_key" ON "LoggingConfig"("guildId");
-- CreateIndex
CREATE INDEX "LogChannel_guildId_idx" ON "LogChannel"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "LogChannel_guildId_eventType_key" ON "LogChannel"("guildId", "eventType");
-- CreateIndex
CREATE UNIQUE INDEX "WelcomeConfig_guildId_key" ON "WelcomeConfig"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationConfig_guildId_key" ON "VerificationConfig"("guildId");
-- AddForeignKey
ALTER TABLE "AutoModConfig" ADD CONSTRAINT "AutoModConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AutoModRule" ADD CONSTRAINT "AutoModRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoggingConfig" ADD CONSTRAINT "LoggingConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LogChannel" ADD CONSTRAINT "LogChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WelcomeConfig" ADD CONSTRAINT "WelcomeConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VerificationConfig" ADD CONSTRAINT "VerificationConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -14,6 +14,12 @@ model Guild {
settings GuildSettings?
cases Case[]
escalationRules EscalationRule[]
autoModConfig AutoModConfig?
autoModRules AutoModRule[]
loggingConfig LoggingConfig?
logChannels LogChannel[]
welcomeConfig WelcomeConfig?
verificationConfig VerificationConfig?
}
model GuildSettings {
@@ -96,3 +102,104 @@ model EscalationRule {
@@unique([guildId, warnCount])
@@index([guildId, enabled])
}
model AutoModConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
antiRaidEnabled Boolean @default(false)
antiRaidJoinThreshold Int @default(10)
antiRaidWindowSeconds Int @default(10)
antiRaidAction String @default("LOCKDOWN")
antiNukeEnabled Boolean @default(false)
antiNukeBanThreshold Int @default(5)
antiNukeChannelThreshold Int @default(3)
antiNukeWindowSeconds Int @default(60)
lockdownActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model AutoModRule {
id String @id @default(cuid())
guildId String
ruleType String
enabled Boolean @default(true)
action String @default("DELETE")
threshold Json?
exceptions Json?
wordList Json?
durationMs Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, ruleType])
@@index([guildId, enabled])
}
model LoggingConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
ignoreBots Boolean @default(true)
ignoredChannelIds String[] @default([])
ignoredRoleIds String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model LogChannel {
id String @id @default(cuid())
guildId String
eventType String
channelId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, eventType])
@@index([guildId])
}
model WelcomeConfig {
id String @id @default(cuid())
guildId String @unique
welcomeEnabled Boolean @default(false)
leaveEnabled Boolean @default(false)
welcomeChannelId String?
leaveChannelId String?
welcomeType String @default("TEXT")
welcomeContent String?
welcomeEmbed Json?
leaveContent String?
leaveEmbed Json?
welcomeDmEnabled Boolean @default(false)
welcomeDmContent String?
userAutoroleIds String[] @default([])
botAutoroleIds String[] @default([])
imageTitle String?
imageSubtitle String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model VerificationConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
channelId String?
verifiedRoleId String?
unverifiedRoleId String?
mode String @default("BUTTON")
minAccountAgeDays Int @default(0)
failAction String @default("KICK")
panelChannelId String?
panelMessageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}

View File

@@ -4,9 +4,17 @@ import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/commands.js';
import { welcomeCommands } from './modules/welcome/commands.js';
import { verificationCommands } from './modules/verification/commands.js';
import type { BotContext, SlashCommand } from './types.js';
const commands: SlashCommand[] = [...moderationCommands];
const commands: SlashCommand[] = [
...moderationCommands,
...automodCommands,
...welcomeCommands,
...verificationCommands
];
const map = new Map(commands.map((c) => [c.data.name, c]));
export async function registerCommands() {

View File

@@ -15,7 +15,8 @@ const EnvSchema = z.object({
BACKUP_CRON: z.string().default('0 3 * * *'),
BACKUP_DIR: z.string().default('/backups'),
METRICS_TOKEN: z.string().optional(),
HEALTH_PORT: z.coerce.number().int().positive().default(8080)
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080')
});
export const env = EnvSchema.parse(process.env);

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

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

View File

@@ -1,19 +1,120 @@
import { createServer } from 'node:http';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { env } from './env.js';
import { redis } from './redis.js';
import {
deleteCaptchaChallenge,
getCaptchaChallenge,
hashCaptchaAnswer
} from './modules/verification/service.js';
import { verificationQueue } from './queues.js';
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function parseFormBody(body: string): Record<string, string> {
return Object.fromEntries(new URLSearchParams(body));
}
function renderCaptchaPage(token: string, question: string, error?: string): string {
const errorBlock = error ? `<p style="color:#ef4444">${error}</p>` : '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nexumi Verification</title>
<style>
body { font-family: Inter, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; }
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
</style>
</head>
<body>
<form method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>Solve: <strong>${question} = ?</strong></p>
${errorBlock}
<input type="hidden" name="token" value="${token}" />
<input type="number" name="answer" required autofocus />
<button type="submit">Verify</button>
</form>
</body>
</html>`;
}
async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
const token = url.searchParams.get('token');
if (!token) {
res.statusCode = 400;
res.end('Missing token');
return;
}
const challenge = await getCaptchaChallenge(redis, token);
if (!challenge) {
res.statusCode = 404;
res.end('Captcha expired');
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question));
}
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readBody(req);
const form = parseFormBody(body);
const token = form.token;
const answer = form.answer;
if (!token || !answer) {
res.statusCode = 400;
res.end('Missing fields');
return;
}
const challenge = await getCaptchaChallenge(redis, token);
if (!challenge) {
res.statusCode = 404;
res.end('Captcha expired');
return;
}
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
return;
}
await deleteCaptchaChallenge(redis, token);
await verificationQueue.add(
'verificationComplete',
{ guildId: challenge.guildId, userId: challenge.userId },
{ removeOnComplete: 1000, removeOnFail: 500 }
);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
}
export function startHealthServer(): void {
const server = createServer((req, res) => {
const server = createServer(async (req, res) => {
if (!req.url) {
res.statusCode = 400;
res.end('Bad Request');
return;
}
if (req.url === '/health') {
const url = new URL(req.url, `http://127.0.0.1:${env.HEALTH_PORT}`);
if (url.pathname === '/health') {
res.statusCode = 200;
res.end('ok');
return;
}
if (req.url === '/metrics') {
if (url.pathname === '/metrics') {
const auth = req.headers.authorization;
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
res.statusCode = 401;
@@ -24,6 +125,17 @@ export function startHealthServer(): void {
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
return;
}
if (url.pathname === '/verify/captcha' && req.method === 'GET') {
await handleCaptchaGet(url, res);
return;
}
if (url.pathname === '/verify/captcha' && req.method === 'POST') {
await handleCaptchaPost(req, res);
return;
}
res.statusCode = 404;
res.end('Not Found');
});

View File

@@ -20,6 +20,12 @@ import {
} from './modules/moderation/confirmations.js';
import { getGuildLocale } from './i18n.js';
import { t } from '@nexumi/shared';
import { handleAutoModMessage } from './modules/automod/actions.js';
import { registerLoggingEvents } from './modules/logging/events.js';
import { registerWelcomeEvents } from './modules/welcome/events.js';
import { registerVerificationEvents } from './modules/verification/events.js';
import { handleVerificationButton } from './modules/verification/handlers.js';
import { isVerificationButton } from './modules/verification/service.js';
const isShard = process.argv.includes('--shard');
@@ -38,20 +44,35 @@ if (!isShard) {
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildEmojisAndStickers,
GatewayIntentBits.GuildInvites,
GatewayIntentBits.MessageContent
],
partials: [Partials.Channel]
partials: [Partials.Channel, Partials.Message, Partials.GuildMember]
});
const context: BotContext = { client, prisma, redis };
startWorkers(context);
await ensureRecurringJobs();
registerLoggingEvents(context);
registerWelcomeEvents(context);
registerVerificationEvents(context);
client.on(Events.ClientReady, async () => {
logger.info({ tag: client.user?.tag }, 'Bot ready');
await registerCommands();
});
client.on(Events.MessageCreate, async (message) => {
try {
await handleAutoModMessage(context, message);
} catch (error) {
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
}
});
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
try {
if (interaction.isChatInputCommand()) {
@@ -63,6 +84,11 @@ if (!isShard) {
await handleModerationConfirmation(interaction, context);
return;
}
if (interaction.isButton() && isVerificationButton(interaction.customId)) {
await handleVerificationButton(interaction, context);
return;
}
} catch (error) {
logger.error({ error }, 'Interaction handling failed');
const locale = await getGuildLocale(context.prisma, interaction.guildId);

View File

@@ -1,4 +1,4 @@
import { Queue, Worker } from 'bullmq';
import { Worker } from 'bullmq';
import { PermissionFlagsBits } from 'discord.js';
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
import { basename, join } from 'node:path';
@@ -7,11 +7,24 @@ import { redis } from './redis.js';
import { logger } from './logger.js';
import type { BotContext } from './types.js';
import { env } from './env.js';
import { refreshPhishingDomains } from './modules/automod/filters.js';
import { completeVerification } from './modules/verification/handlers.js';
import {
automodQueue,
automodQueueName,
backupQueue,
backupQueueName,
moderationQueueName,
verificationQueueName
} from './queues.js';
export const moderationQueueName = 'moderation';
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
export const backupQueueName = 'backups';
export const backupQueue = new Queue(backupQueueName, { connection: redis });
export {
automodQueue,
backupQueue,
moderationQueue,
moderationQueueName,
verificationQueue
} from './queues.js';
type TempBanJob = {
guildId: string;
@@ -19,6 +32,11 @@ type TempBanJob = {
reason: string | null;
};
type VerificationCompleteJob = {
guildId: string;
userId: string;
};
export function startWorkers(context: BotContext): Worker[] {
const moderationWorker = new Worker<TempBanJob>(
moderationQueueName,
@@ -56,7 +74,38 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Backup job failed');
});
return [moderationWorker, backupWorker];
const automodWorker = new Worker(
automodQueueName,
async (job) => {
if (job.name !== 'refreshPhishingList') {
return;
}
const count = await refreshPhishingDomains(redis);
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
},
{ connection: redis }
);
automodWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
});
const verificationWorker = new Worker<VerificationCompleteJob>(
verificationQueueName,
async (job) => {
if (job.name !== 'verificationComplete') {
return;
}
await completeVerification(context, job.data.guildId, job.data.userId);
},
{ connection: redis }
);
verificationWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Verification job failed');
});
return [moderationWorker, backupWorker, automodWorker, verificationWorker];
}
export async function ensureRecurringJobs(): Promise<void> {
@@ -69,6 +118,18 @@ export async function ensureRecurringJobs(): Promise<void> {
removeOnFail: 100
}
);
await automodQueue.add(
'refreshPhishingList',
{},
{
repeat: { pattern: '0 */6 * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
await automodQueue.add('refreshPhishingList', {}, { removeOnComplete: 20, removeOnFail: 20 });
}
async function runPgDumpBackup(): Promise<void> {

View File

@@ -0,0 +1,287 @@
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
import type { BotContext } from '../../types.js';
import { createCase } from '../moderation/service.js';
import {
getAutoModConfig,
getEnabledAutoModRules,
isExcepted,
parseExceptions,
parseThreshold,
parseWordList
} from './service.js';
import { evaluateRule, loadPhishingDomains, type FilterResult } from './filters.js';
import type { AutoModRuleType } from '@nexumi/shared';
import { logger } from '../../logger.js';
async function applyAutoModAction(
context: BotContext,
message: Message,
ruleType: AutoModRuleType,
action: string,
reason: string,
durationMs?: number | null
): Promise<void> {
const guild = message.guild;
if (!guild || !message.member) {
return;
}
const me = guild.members.me;
if (!me) {
return;
}
if (me.permissions.has(PermissionFlagsBits.ManageMessages) && message.deletable) {
await message.delete().catch((error) => {
logger.warn({ error, messageId: message.id }, 'AutoMod failed to delete message');
});
}
const moderatorId = context.client.user?.id ?? guild.client.user?.id ?? '0';
const baseReason = `AutoMod (${ruleType}): ${reason}`;
if (action === 'WARN') {
await context.prisma.warning.create({
data: {
guildId: guild.id,
userId: message.author.id,
moderatorId,
reason: baseReason
}
});
await createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'WARN_ADD',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
metadata: { ruleType, automod: true }
});
return;
}
if (action === 'TIMEOUT' && durationMs && durationMs > 0) {
if (me.permissions.has(PermissionFlagsBits.ModerateMembers)) {
await message.member.timeout(durationMs, baseReason);
await createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'TIMEOUT',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
metadata: { ruleType, automod: true, durationMs }
});
}
return;
}
if (action === 'KICK') {
if (me.permissions.has(PermissionFlagsBits.KickMembers)) {
await message.member.kick(baseReason);
await createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'KICK',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
metadata: { ruleType, automod: true }
});
}
return;
}
if (action === 'BAN') {
if (me.permissions.has(PermissionFlagsBits.BanMembers)) {
await guild.members.ban(message.author.id, { reason: baseReason });
await createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'BAN',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
metadata: { ruleType, automod: true }
});
}
}
}
export async function handleAutoModMessage(
context: BotContext,
message: Message
): Promise<FilterResult | null> {
if (!message.guild || message.author.bot || !message.content) {
return null;
}
const config = await getAutoModConfig(context.prisma, message.guild.id);
if (!config.enabled) {
return null;
}
const member = message.member ?? (await message.guild.members.fetch(message.author.id));
const roleIds = member.roles.cache.map((role) => role.id);
const rules = await getEnabledAutoModRules(context.prisma, message.guild.id);
const phishingDomains = await loadPhishingDomains(context.redis);
const filterCtx = {
content: message.content,
authorId: message.author.id,
channelId: message.channel.id,
roleIds,
redis: context.redis,
phishingDomains
};
for (const rule of rules) {
const exceptions = parseExceptions(rule.exceptions);
if (isExcepted(exceptions, message.channel.id, roleIds)) {
continue;
}
const threshold = parseThreshold(rule.threshold);
const wordList = parseWordList(rule.wordList);
const result = await evaluateRule(
rule.ruleType as AutoModRuleType,
filterCtx,
threshold,
wordList
);
if (result) {
await applyAutoModAction(
context,
message,
result.ruleType,
rule.action,
result.reason,
rule.durationMs
);
return result;
}
}
return null;
}
export async function activateLockdown(guild: Guild): Promise<void> {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
const everyone = guild.roles.everyone;
await channel.permissionOverwrites.edit(everyone, {
SendMessages: false
});
}
}
export async function deactivateLockdown(guild: Guild): Promise<void> {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
const everyone = guild.roles.everyone;
await channel.permissionOverwrites.edit(everyone, {
SendMessages: null
});
}
}
export async function handleAntiRaidJoin(
context: BotContext,
member: GuildMember
): Promise<void> {
const config = await getAutoModConfig(context.prisma, member.guild.id);
if (!config.antiRaidEnabled) {
return;
}
const key = `automod:raid:${member.guild.id}`;
const count = await context.redis.incr(key);
if (count === 1) {
await context.redis.expire(key, config.antiRaidWindowSeconds);
}
if (count < config.antiRaidJoinThreshold) {
return;
}
if (config.lockdownActive) {
return;
}
await context.prisma.autoModConfig.update({
where: { guildId: member.guild.id },
data: { lockdownActive: true }
});
if (config.antiRaidAction === 'LOCKDOWN') {
await activateLockdown(member.guild);
}
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
if (alertChannel && alertChannel.isTextBased()) {
await (alertChannel as TextChannel).send(
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s.`
);
}
}
export async function handleAntiNukeAction(
context: BotContext,
guildId: string,
executorId: string,
actionType: 'ban' | 'channel_delete'
): Promise<void> {
const config = await getAutoModConfig(context.prisma, guildId);
if (!config.antiNukeEnabled) {
return;
}
const botId = context.client.user?.id;
if (!botId || executorId === botId) {
return;
}
const key = `automod:nuke:${guildId}:${executorId}:${actionType}`;
const count = await context.redis.incr(key);
if (count === 1) {
await context.redis.expire(key, config.antiNukeWindowSeconds);
}
const threshold =
actionType === 'ban' ? config.antiNukeBanThreshold : config.antiNukeChannelThreshold;
if (count < threshold) {
return;
}
const guild = await context.client.guilds.fetch(guildId);
const executor = await guild.members.fetch(executorId).catch(() => null);
if (!executor) {
return;
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.Administrator)) {
return;
}
await executor.roles.set([], 'Anti-nuke: mass destructive action detected');
await createCase(context, {
guildId,
targetUserId: executorId,
moderatorId: botId,
action: 'ANTI_NUKE',
reason: `Anti-nuke triggered (${actionType}: ${count} in ${config.antiNukeWindowSeconds}s)`,
source: 'automod',
metadata: { actionType, count, antiNuke: true }
});
}

View File

@@ -0,0 +1,9 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription } from '@nexumi/shared';
export const automodStatusCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('automod').addSubcommand((s) =>
applyCommandDescription(s.setName('status'), 'automod.status.description')
),
'automod.description'
);

View File

@@ -0,0 +1,57 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { automodStatusCommandData } from './command-definitions.js';
import { getAutoModConfig, getEnabledAutoModRules } from './service.js';
const automodCommand: SlashCommand = {
data: automodStatusCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const sub = interaction.options.getSubcommand();
if (sub !== 'status') {
return;
}
const guildId = interaction.guildId!;
const config = await getAutoModConfig(context.prisma, guildId);
const rules = await getEnabledAutoModRules(context.prisma, guildId);
const lines = rules.map((rule) =>
tf(locale, 'automod.status.ruleLine', {
type: rule.ruleType,
action: rule.action
})
);
const body = [
tf(locale, 'automod.status.header', {
enabled: config.enabled ? t(locale, 'automod.status.yes') : t(locale, 'automod.status.no'),
antiRaid: config.antiRaidEnabled
? t(locale, 'automod.status.yes')
: t(locale, 'automod.status.no'),
antiNuke: config.antiNukeEnabled
? t(locale, 'automod.status.yes')
: t(locale, 'automod.status.no'),
lockdown: config.lockdownActive
? t(locale, 'automod.status.yes')
: t(locale, 'automod.status.no')
}),
lines.length > 0 ? lines.join('\n') : t(locale, 'automod.status.noRules')
].join('\n\n');
await interaction.reply({ content: body, ephemeral: true });
}
};
export const automodCommands: SlashCommand[] = [automodCommand];

View File

@@ -0,0 +1,231 @@
import type { Redis } from 'ioredis';
import {
capsRatio,
countEmojis,
extractUrls,
hasZalgo,
isDiscordInvite,
normalizeHost,
type AutoModRuleType,
type AutoModThreshold,
type AutoModWordList
} from '@nexumi/shared';
const PHISHING_CACHE_KEY = 'automod:phishing-domains';
export type FilterContext = {
content: string;
authorId: string;
channelId: string;
roleIds: string[];
redis: Redis;
phishingDomains: Set<string>;
};
export type FilterResult = {
matched: boolean;
ruleType: AutoModRuleType;
reason: string;
};
export async function loadPhishingDomains(redis: Redis): Promise<Set<string>> {
const cached = await redis.smembers(PHISHING_CACHE_KEY);
if (cached.length > 0) {
return new Set(cached);
}
return new Set<string>();
}
export async function refreshPhishingDomains(redis: Redis): Promise<number> {
const response = await fetch(
'https://raw.githubusercontent.com/Discord-AntiScam/scam-links/main/list.txt'
);
if (!response.ok) {
throw new Error(`Failed to fetch phishing list: ${response.status}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter((line) => line.length > 0 && !line.startsWith('#'));
const pipeline = redis.pipeline();
pipeline.del(PHISHING_CACHE_KEY);
if (domains.length > 0) {
pipeline.sadd(PHISHING_CACHE_KEY, ...domains);
}
pipeline.expire(PHISHING_CACHE_KEY, 60 * 60 * 24);
await pipeline.exec();
return domains.length;
}
async function checkSpam(
ctx: FilterContext,
threshold: AutoModThreshold
): Promise<FilterResult | null> {
const maxMessages = threshold.maxMessages ?? 5;
const windowSeconds = threshold.windowSeconds ?? 5;
const key = `automod:spam:${ctx.authorId}:${ctx.channelId}`;
const count = await ctx.redis.incr(key);
if (count === 1) {
await ctx.redis.expire(key, windowSeconds);
}
if (count > maxMessages) {
return { matched: true, ruleType: 'SPAM', reason: 'Message rate exceeded' };
}
return null;
}
function checkMassMention(content: string, threshold: AutoModThreshold): FilterResult | null {
const maxMentions = threshold.maxMentions ?? 5;
const mentions = (content.match(/<@[!&]?\d+>/g) ?? []).length;
if (mentions >= maxMentions) {
return { matched: true, ruleType: 'MASS_MENTION', reason: `${mentions} mentions` };
}
return null;
}
function checkCaps(content: string, threshold: AutoModThreshold): FilterResult | null {
const minLength = threshold.minLength ?? 10;
const capsPercent = threshold.capsPercent ?? 70;
if (content.length < minLength) {
return null;
}
if (capsRatio(content) >= capsPercent) {
return { matched: true, ruleType: 'CAPS', reason: 'Excessive caps' };
}
return null;
}
function checkInviteLink(content: string): FilterResult | null {
if (isDiscordInvite(content)) {
return { matched: true, ruleType: 'INVITE_LINK', reason: 'Discord invite link' };
}
return null;
}
function checkExternalLink(content: string, threshold: AutoModThreshold): FilterResult | null {
const whitelist = (threshold.linkWhitelist ?? []).map((h) => h.toLowerCase());
const blacklist = (threshold.linkBlacklist ?? []).map((h) => h.toLowerCase());
const urls = extractUrls(content);
for (const url of urls) {
const host = normalizeHost(url);
if (!host) {
continue;
}
if (blacklist.some((entry) => host.includes(entry))) {
return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Blocked host: ${host}` };
}
if (whitelist.length > 0 && !whitelist.some((entry) => host.includes(entry))) {
return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Non-whitelisted host: ${host}` };
}
}
return null;
}
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
const lower = content.toLowerCase();
for (const word of wordList.words) {
if (word.length > 0 && lower.includes(word.toLowerCase())) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
}
}
for (const pattern of wordList.regexPatterns) {
try {
const regex = new RegExp(pattern, 'i');
if (regex.test(content)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
}
} catch {
continue;
}
}
return null;
}
async function checkDuplicate(
ctx: FilterContext,
threshold: AutoModThreshold
): Promise<FilterResult | null> {
const duplicateCount = threshold.duplicateCount ?? 3;
const windowSeconds = threshold.windowSeconds ?? 30;
const normalized = ctx.content.trim().toLowerCase();
if (normalized.length < 3) {
return null;
}
const key = `automod:dup:${ctx.authorId}:${ctx.channelId}`;
const count = await ctx.redis.incr(`${key}:${normalized}`);
if (count === 1) {
await ctx.redis.expire(`${key}:${normalized}`, windowSeconds);
}
if (count >= duplicateCount) {
return { matched: true, ruleType: 'DUPLICATE', reason: 'Duplicate message' };
}
return null;
}
function checkEmojiSpam(content: string, threshold: AutoModThreshold): FilterResult | null {
const maxEmojis = threshold.maxEmojis ?? 10;
const emojiCount = countEmojis(content);
if (emojiCount >= maxEmojis) {
return { matched: true, ruleType: 'EMOJI_SPAM', reason: `${emojiCount} emojis` };
}
return null;
}
function checkZalgo(content: string): FilterResult | null {
if (hasZalgo(content)) {
return { matched: true, ruleType: 'ZALGO', reason: 'Zalgo text detected' };
}
return null;
}
function checkPhishing(content: string, domains: Set<string>): FilterResult | null {
if (domains.size === 0) {
return null;
}
for (const url of extractUrls(content)) {
const host = normalizeHost(url);
if (!host) {
continue;
}
for (const domain of domains) {
if (host === domain || host.endsWith(`.${domain}`)) {
return { matched: true, ruleType: 'PHISHING', reason: `Phishing domain: ${host}` };
}
}
}
return null;
}
export async function evaluateRule(
ruleType: AutoModRuleType,
ctx: FilterContext,
threshold: AutoModThreshold,
wordList: AutoModWordList
): Promise<FilterResult | null> {
switch (ruleType) {
case 'SPAM':
return checkSpam(ctx, threshold);
case 'MASS_MENTION':
return checkMassMention(ctx.content, threshold);
case 'CAPS':
return checkCaps(ctx.content, threshold);
case 'INVITE_LINK':
return checkInviteLink(ctx.content);
case 'EXTERNAL_LINK':
return checkExternalLink(ctx.content, threshold);
case 'WORD_FILTER':
return checkWordFilter(ctx.content, wordList);
case 'DUPLICATE':
return checkDuplicate(ctx, threshold);
case 'EMOJI_SPAM':
return checkEmojiSpam(ctx.content, threshold);
case 'ZALGO':
return checkZalgo(ctx.content);
case 'PHISHING':
return checkPhishing(ctx.content, ctx.phishingDomains);
default:
return null;
}
}

View File

@@ -0,0 +1,82 @@
import type { PrismaClient } from '@prisma/client';
import {
DEFAULT_AUTOMOD_RULES,
type AutoModExceptions,
type AutoModThreshold,
type AutoModWordList
} from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.autoModConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.autoModConfig.create({ data: { guildId } });
}
return config;
}
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
const existing = await prisma.autoModRule.count({ where: { guildId } });
if (existing > 0) {
return;
}
await prisma.autoModRule.createMany({
data: DEFAULT_AUTOMOD_RULES.map((rule) => ({
guildId,
ruleType: rule.ruleType,
action: rule.action,
threshold: rule.threshold ?? undefined,
wordList: rule.wordList ?? undefined,
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
}))
});
}
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureDefaultAutoModRules(prisma, guildId);
return prisma.autoModRule.findMany({
where: { guildId, enabled: true }
});
}
export function parseExceptions(raw: unknown): AutoModExceptions {
if (!raw || typeof raw !== 'object') {
return { roleIds: [], channelIds: [] };
}
const obj = raw as Record<string, unknown>;
return {
roleIds: Array.isArray(obj.roleIds) ? obj.roleIds.map(String) : [],
channelIds: Array.isArray(obj.channelIds) ? obj.channelIds.map(String) : []
};
}
export function parseThreshold(raw: unknown): AutoModThreshold {
if (!raw || typeof raw !== 'object') {
return {};
}
return raw as AutoModThreshold;
}
export function parseWordList(raw: unknown): AutoModWordList {
if (!raw || typeof raw !== 'object') {
return { words: [], regexPatterns: [] };
}
const obj = raw as Record<string, unknown>;
return {
words: Array.isArray(obj.words) ? obj.words.map(String) : [],
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : []
};
}
export function isExcepted(
exceptions: AutoModExceptions,
channelId: string,
roleIds: string[]
): boolean {
if (exceptions.channelIds.includes(channelId)) {
return true;
}
return roleIds.some((roleId) => exceptions.roleIds.includes(roleId));
}

View File

@@ -0,0 +1,537 @@
import {
AuditLogEvent,
EmbedBuilder,
type Guild,
type GuildMember,
type TextChannel
} from 'discord.js';
import type { BotContext } from '../../types.js';
import type { LogEventType } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.loggingConfig.create({ data: { guildId } });
}
return config;
}
export async function getLogChannelId(
prisma: BotContext['prisma'],
guildId: string,
eventType: LogEventType
): Promise<string | null> {
const entry = await prisma.logChannel.findUnique({
where: { guildId_eventType: { guildId, eventType } }
});
return entry?.channelId ?? null;
}
function shouldIgnoreMember(
config: Awaited<ReturnType<typeof getLoggingConfig>>,
member: GuildMember | null,
isBot: boolean
): boolean {
if (isBot && config.ignoreBots) {
return true;
}
if (!member) {
return false;
}
return member.roles.cache.some((role) => config.ignoredRoleIds.includes(role.id));
}
function shouldIgnoreChannel(
config: Awaited<ReturnType<typeof getLoggingConfig>>,
channelId: string | null
): boolean {
if (!channelId) {
return false;
}
return config.ignoredChannelIds.includes(channelId);
}
async function resolveLogChannel(
context: BotContext,
guild: Guild,
eventType: LogEventType
): Promise<TextChannel | null> {
const config = await getLoggingConfig(context.prisma, guild.id);
if (!config.enabled) {
return null;
}
const channelId = await getLogChannelId(context.prisma, guild.id, eventType);
if (!channelId) {
return null;
}
if (shouldIgnoreChannel(config, channelId)) {
return null;
}
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
return null;
}
return channel as TextChannel;
}
export async function sendLogEmbed(
context: BotContext,
guild: Guild,
eventType: LogEventType,
title: string,
description: string,
color = 0x6366f1
): Promise<void> {
const channel = await resolveLogChannel(context, guild, eventType);
if (!channel) {
return;
}
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description.slice(0, 4096))
.setColor(color)
.setTimestamp();
await channel.send({ embeds: [embed] }).catch((error) => {
logger.warn({ error, guildId: guild.id, eventType }, 'Failed to send log embed');
});
}
export async function logModAction(
context: BotContext,
input: {
guildId: string;
caseNumber: number;
action: string;
targetUserId: string;
moderatorId: string;
reason?: string | null;
}
): Promise<void> {
const guild = await context.client.guilds.fetch(input.guildId).catch(() => null);
if (!guild) {
return;
}
const description = [
`**Case:** #${input.caseNumber}`,
`**Action:** ${input.action}`,
`**Target:** <@${input.targetUserId}> (\`${input.targetUserId}\`)`,
`**Moderator:** <@${input.moderatorId}> (\`${input.moderatorId}\`)`,
`**Reason:** ${input.reason ?? '—'}`
].join('\n');
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444);
}
export async function handleChannelDeleteForAntiNuke(
context: BotContext,
guild: Guild,
channelId: string
): Promise<void> {
const config = await getLoggingConfig(context.prisma, guild.id);
if (shouldIgnoreChannel(config, channelId)) {
return;
}
const auditLogs = await guild.fetchAuditLogs({
type: AuditLogEvent.ChannelDelete,
limit: 1
});
const entry = auditLogs.entries.first();
if (!entry?.executor) {
return;
}
const { handleAntiNukeAction } = await import('../automod/actions.js');
await handleAntiNukeAction(context, guild.id, entry.executor.id, 'channel_delete');
}
export async function handleBanForAntiNuke(
context: BotContext,
guild: Guild
): Promise<void> {
const auditLogs = await guild.fetchAuditLogs({
type: AuditLogEvent.MemberBanAdd,
limit: 1
});
const entry = auditLogs.entries.first();
if (!entry?.executor) {
return;
}
const { handleAntiNukeAction } = await import('../automod/actions.js');
await handleAntiNukeAction(context, guild.id, entry.executor.id, 'ban');
}
export function memberIgnored(
config: Awaited<ReturnType<typeof getLoggingConfig>>,
member: GuildMember | null,
isBot: boolean,
channelId?: string | null
): boolean {
if (shouldIgnoreChannel(config, channelId ?? null)) {
return true;
}
return shouldIgnoreMember(config, member, isBot);
}
export async function registerLoggingEvents(context: BotContext): Promise<void> {
const { client } = context;
client.on('messageUpdate', async (oldMessage, newMessage) => {
if (!newMessage.guild || newMessage.author?.bot) {
return;
}
const config = await getLoggingConfig(context.prisma, newMessage.guild.id);
if (memberIgnored(config, newMessage.member, newMessage.author?.bot ?? false, newMessage.channel.id)) {
return;
}
const before = oldMessage.content ?? '—';
const after = newMessage.content ?? '—';
if (before === after) {
return;
}
await sendLogEmbed(
context,
newMessage.guild,
'MESSAGE_EDIT',
'Message Edited',
`**Author:** <@${newMessage.author?.id}>\n**Channel:** <#${newMessage.channel.id}>\n**Before:** ${before}\n**After:** ${after}`
);
});
client.on('messageDelete', async (message) => {
if (!message.guild || message.author?.bot) {
return;
}
const config = await getLoggingConfig(context.prisma, message.guild.id);
if (memberIgnored(config, message.member, message.author?.bot ?? false, message.channel.id)) {
return;
}
await sendLogEmbed(
context,
message.guild,
'MESSAGE_DELETE',
'Message Deleted',
`**Author:** <@${message.author?.id}>\n**Channel:** <#${message.channel.id}>\n**Content:** ${message.content ?? '—'}`
);
});
client.on('messageDeleteBulk', async (messages, channel) => {
if (!channel.guild) {
return;
}
await sendLogEmbed(
context,
channel.guild,
'MESSAGE_BULK_DELETE',
'Bulk Delete',
`**Channel:** <#${channel.id}>\n**Count:** ${messages.size}`
);
});
client.on('guildMemberAdd', async (member) => {
const config = await getLoggingConfig(context.prisma, member.guild.id);
if (memberIgnored(config, member, member.user.bot)) {
return;
}
await sendLogEmbed(
context,
member.guild,
'MEMBER_JOIN',
'Member Joined',
`**User:** ${member.user.tag} (<@${member.id}>)`
);
});
client.on('guildMemberRemove', async (member) => {
if (!member.guild) {
return;
}
const config = await getLoggingConfig(context.prisma, member.guild.id);
if (memberIgnored(config, member as GuildMember, member.user.bot)) {
return;
}
await sendLogEmbed(
context,
member.guild,
'MEMBER_LEAVE',
'Member Left',
`**User:** ${member.user.tag} (<@${member.id}>)`
);
});
client.on('guildBanAdd', async (ban) => {
const guild = ban.guild;
await handleBanForAntiNuke(context, guild);
await sendLogEmbed(
context,
guild,
'MEMBER_BAN',
'Member Banned',
`**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}`
);
});
client.on('guildBanRemove', async (ban) => {
await sendLogEmbed(
context,
ban.guild,
'MEMBER_UNBAN',
'Member Unbanned',
`**User:** ${ban.user.tag} (<@${ban.user.id}>)`
);
});
client.on('guildMemberUpdate', async (oldMember, newMember) => {
const config = await getLoggingConfig(context.prisma, newMember.guild.id);
if (memberIgnored(config, newMember, newMember.user.bot)) {
return;
}
const changes: string[] = [];
if (oldMember.nickname !== newMember.nickname) {
changes.push(`**Nickname:** ${oldMember.nickname ?? '—'}${newMember.nickname ?? '—'}`);
}
const addedRoles = newMember.roles.cache.filter((role) => !oldMember.roles.cache.has(role.id));
const removedRoles = oldMember.roles.cache.filter((role) => !newMember.roles.cache.has(role.id));
if (addedRoles.size > 0) {
changes.push(`**Roles added:** ${addedRoles.map((r) => r.name).join(', ')}`);
}
if (removedRoles.size > 0) {
changes.push(`**Roles removed:** ${removedRoles.map((r) => r.name).join(', ')}`);
}
if (changes.length === 0) {
return;
}
await sendLogEmbed(
context,
newMember.guild,
'MEMBER_UPDATE',
'Member Updated',
`**User:** <@${newMember.id}>\n${changes.join('\n')}`
);
});
client.on('channelCreate', async (channel) => {
if (!channel.guild) {
return;
}
await sendLogEmbed(
context,
channel.guild,
'CHANNEL_CREATE',
'Channel Created',
`**Channel:** ${channel.name} (<#${channel.id}>)`
);
});
client.on('channelDelete', async (channel) => {
if (!('guild' in channel) || !channel.guild) {
return;
}
await handleChannelDeleteForAntiNuke(context, channel.guild, channel.id);
await sendLogEmbed(
context,
channel.guild,
'CHANNEL_DELETE',
'Channel Deleted',
`**Channel:** ${'name' in channel ? channel.name : 'Unknown'} (\`${channel.id}\`)`
);
});
client.on('channelUpdate', async (oldChannel, newChannel) => {
if (!('guild' in newChannel) || !newChannel.guild) {
return;
}
await sendLogEmbed(
context,
newChannel.guild,
'CHANNEL_UPDATE',
'Channel Updated',
`**Channel:** <#${newChannel.id}>\n**Before:** ${'name' in oldChannel ? oldChannel.name : '—'}\n**After:** ${'name' in newChannel ? newChannel.name : '—'}`
);
});
client.on('voiceStateUpdate', async (oldState, newState) => {
const guild = newState.guild;
const member = newState.member;
if (!member || member.user.bot) {
return;
}
const config = await getLoggingConfig(context.prisma, guild.id);
if (memberIgnored(config, member, member.user.bot)) {
return;
}
if (!oldState.channelId && newState.channelId) {
await sendLogEmbed(
context,
guild,
'VOICE_JOIN',
'Voice Join',
`**User:** <@${member.id}>\n**Channel:** <#${newState.channelId}>`
);
return;
}
if (oldState.channelId && !newState.channelId) {
await sendLogEmbed(
context,
guild,
'VOICE_LEAVE',
'Voice Leave',
`**User:** <@${member.id}>\n**Channel:** <#${oldState.channelId}>`
);
return;
}
if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
await sendLogEmbed(
context,
guild,
'VOICE_MOVE',
'Voice Move',
`**User:** <@${member.id}>\n**From:** <#${oldState.channelId}>\n**To:** <#${newState.channelId}>`
);
}
});
client.on('inviteCreate', async (invite) => {
if (!invite.guild || !('members' in invite.guild)) {
return;
}
await sendLogEmbed(
context,
invite.guild,
'INVITE_CREATE',
'Invite Created',
`**Code:** ${invite.code}\n**Channel:** <#${invite.channelId}>\n**Creator:** ${invite.inviter ? `<@${invite.inviter.id}>` : '—'}`
);
});
client.on('emojiCreate', async (emoji) => {
await sendLogEmbed(
context,
emoji.guild!,
'EMOJI_CREATE',
'Emoji Created',
`**Name:** ${emoji.name}`
);
});
client.on('emojiUpdate', async (oldEmoji, newEmoji) => {
await sendLogEmbed(
context,
newEmoji.guild!,
'EMOJI_UPDATE',
'Emoji Updated',
`**Before:** ${oldEmoji.name}\n**After:** ${newEmoji.name}`
);
});
client.on('emojiDelete', async (emoji) => {
await sendLogEmbed(
context,
emoji.guild!,
'EMOJI_DELETE',
'Emoji Deleted',
`**Name:** ${emoji.name}`
);
});
client.on('stickerCreate', async (sticker) => {
if (!sticker.guild) {
return;
}
await sendLogEmbed(
context,
sticker.guild,
'STICKER_CREATE',
'Sticker Created',
`**Name:** ${sticker.name}`
);
});
client.on('stickerUpdate', async (oldSticker, newSticker) => {
if (!newSticker.guild) {
return;
}
await sendLogEmbed(
context,
newSticker.guild,
'STICKER_UPDATE',
'Sticker Updated',
`**Before:** ${oldSticker.name}\n**After:** ${newSticker.name}`
);
});
client.on('stickerDelete', async (sticker) => {
if (!sticker.guild) {
return;
}
await sendLogEmbed(
context,
sticker.guild,
'STICKER_DELETE',
'Sticker Deleted',
`**Name:** ${sticker.name}`
);
});
client.on('threadCreate', async (thread) => {
if (!thread.guild) {
return;
}
await sendLogEmbed(
context,
thread.guild,
'THREAD_CREATE',
'Thread Created',
`**Thread:** ${thread.name} (<#${thread.id}>)`
);
});
client.on('threadUpdate', async (oldThread, newThread) => {
if (!newThread.guild) {
return;
}
await sendLogEmbed(
context,
newThread.guild,
'THREAD_UPDATE',
'Thread Updated',
`**Before:** ${oldThread.name}\n**After:** ${newThread.name}`
);
});
client.on('threadDelete', async (thread) => {
if (!thread.guild) {
return;
}
await sendLogEmbed(
context,
thread.guild,
'THREAD_DELETE',
'Thread Deleted',
`**Thread:** ${thread.name} (\`${thread.id}\`)`
);
});
client.on('guildUpdate', async (oldGuild, newGuild) => {
const changes: string[] = [];
if (oldGuild.name !== newGuild.name) {
changes.push(`**Name:** ${oldGuild.name}${newGuild.name}`);
}
if (changes.length === 0) {
return;
}
await sendLogEmbed(
context,
newGuild,
'GUILD_UPDATE',
'Guild Updated',
changes.join('\n')
);
});
}

View File

@@ -1,4 +1,4 @@
import { moderationQueue } from '../../jobs.js';
import { moderationQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import type { Prisma } from '@prisma/client';
@@ -43,6 +43,17 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
reason: input.reason,
metadata: metadata as Prisma.InputJsonValue
}
}).then(async (created) => {
const { logModAction } = await import('../logging/events.js');
await logModAction(context, {
guildId: input.guildId,
caseNumber: created.caseNumber,
action: input.action,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
reason: input.reason
});
return created;
});
}

View File

@@ -0,0 +1,53 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const verifyCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('verify')
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'verify.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'verify.setup.options.channel'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('verified_role').setRequired(true), 'verify.setup.options.verified_role')
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('unverified_role'), 'verify.setup.options.unverified_role')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('mode'), 'verify.setup.options.mode')
.addChoices(
{ name: 'Button', value: 'BUTTON' },
{ name: 'Captcha', value: 'CAPTCHA' }
)
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
.setMinValue(0)
.setMaxValue(365)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('fail_action'), 'verify.setup.options.fail_action')
.addChoices(
{ name: 'Kick', value: 'KICK' },
{ name: 'Ban', value: 'BAN' },
{ name: 'None', value: 'NONE' }
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText),
'verify.panel.options.channel'
)
)
),
'verify.description'
);

View File

@@ -0,0 +1,84 @@
import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { verifyCommandData } from './command-definitions.js';
import {
getVerificationConfig,
updateVerificationConfig
} from './service.js';
import { buildVerificationPanel } from './handlers.js';
const verifyCommand: SlashCommand = {
data: verifyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
const channel = interaction.options.getChannel('channel', true);
const verifiedRole = interaction.options.getRole('verified_role', true);
const unverifiedRole = interaction.options.getRole('unverified_role');
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
| 'KICK'
| 'BAN'
| 'NONE';
await updateVerificationConfig(context.prisma, guildId, {
enabled: true,
channelId: channel.id,
verifiedRoleId: verifiedRole.id,
unverifiedRoleId: unverifiedRole?.id ?? null,
mode,
minAccountAgeDays,
failAction
});
await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true });
return;
}
const config = await getVerificationConfig(context.prisma, guildId);
if (!config.enabled || !config.verifiedRoleId) {
await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true });
return;
}
const panelChannelOption = interaction.options.getChannel('channel');
const panelChannel = panelChannelOption
? await interaction.guild!.channels.fetch(panelChannelOption.id)
: config.channelId
? await interaction.guild!.channels.fetch(config.channelId)
: null;
if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) {
await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true });
return;
}
const panel = buildVerificationPanel(config);
const message = await panelChannel.send(panel);
await context.prisma.verificationConfig.update({
where: { guildId },
data: {
panelChannelId: panelChannel.id,
panelMessageId: message.id
}
});
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true });
}
};
export const verificationCommands: SlashCommand[] = [verifyCommand];

View File

@@ -0,0 +1,27 @@
import type { BotContext } from '../../types.js';
import { getVerificationConfig } from './service.js';
import { PermissionFlagsBits } from 'discord.js';
import { logger } from '../../logger.js';
export function registerVerificationEvents(context: BotContext): void {
context.client.on('guildMemberAdd', async (member) => {
const config = await getVerificationConfig(context.prisma, member.guild.id);
if (!config.enabled || !config.unverifiedRoleId) {
return;
}
if (config.verifiedRoleId && member.roles.cache.has(config.verifiedRoleId)) {
return;
}
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return;
}
const role = member.guild.roles.cache.get(config.unverifiedRoleId);
if (!role || role.position >= me.roles.highest.position) {
return;
}
await member.roles.add(role).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to assign unverified role');
});
});
}

View File

@@ -0,0 +1,149 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
PermissionFlagsBits,
type ButtonInteraction,
type GuildMember
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
accountAgeDays,
createCaptchaChallenge,
getVerificationConfig,
parseVerificationButton,
verificationButtonId,
verificationCaptchaButtonId
} from './service.js';
import { env } from '../../env.js';
import { logger } from '../../logger.js';
async function applyFailAction(
member: GuildMember,
failAction: string,
reason: string
): Promise<void> {
const me = member.guild.members.me;
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
await member.kick(reason);
return;
}
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
await member.guild.members.ban(member.id, { reason });
}
}
export async function completeVerification(
context: BotContext,
guildId: string,
userId: string
): Promise<{ ok: boolean; reason?: string }> {
const config = await getVerificationConfig(context.prisma, guildId);
if (!config.enabled || !config.verifiedRoleId) {
return { ok: false, reason: 'not_configured' };
}
const guild = await context.client.guilds.fetch(guildId);
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
return { ok: false, reason: 'member_not_found' };
}
const ageDays = accountAgeDays(member.user.createdAt);
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
await applyFailAction(
member,
config.failAction,
`Verification failed: account too young (${ageDays}d)`
);
return { ok: false, reason: 'account_too_young' };
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return { ok: false, reason: 'missing_permissions' };
}
const verifiedRole = guild.roles.cache.get(config.verifiedRoleId);
if (!verifiedRole || verifiedRole.position >= me.roles.highest.position) {
return { ok: false, reason: 'invalid_role' };
}
if (config.unverifiedRoleId && member.roles.cache.has(config.unverifiedRoleId)) {
await member.roles.remove(config.unverifiedRoleId).catch((error) => {
logger.warn({ error }, 'Failed to remove unverified role');
});
}
if (!member.roles.cache.has(config.verifiedRoleId)) {
await member.roles.add(config.verifiedRoleId);
}
return { ok: true };
}
export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVerificationConfig>>) {
const embed = new EmbedBuilder()
.setTitle('Verification')
.setDescription('Click the button below to verify and gain access to the server.')
.setColor(0x6366f1);
const customId =
config.mode === 'CAPTCHA'
? verificationCaptchaButtonId(config.guildId)
: verificationButtonId(config.guildId);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(customId)
.setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify')
.setStyle(ButtonStyle.Success)
);
return { embeds: [embed], components: [row] };
}
export async function handleVerificationButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const parsed = parseVerificationButton(interaction.customId);
if (!parsed || !interaction.guild) {
return;
}
const locale = await getGuildLocale(context.prisma, interaction.guild.id);
const config = await getVerificationConfig(context.prisma, parsed.guildId);
if (!config.enabled) {
await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true });
return;
}
if (parsed.mode === 'captcha') {
const challenge = await createCaptchaChallenge(
context.redis,
parsed.guildId,
interaction.user.id
);
const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`;
await interaction.reply({
content: tf(locale, 'verification.captchaLink', { url }),
ephemeral: true
});
return;
}
await interaction.deferReply({ ephemeral: true });
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
if (result.ok) {
await interaction.editReply({ content: t(locale, 'verification.success') });
return;
}
const key = `verification.error.${result.reason ?? 'generic'}`;
await interaction.editReply({ content: t(locale, key) });
}

View File

@@ -0,0 +1,115 @@
import { randomBytes, randomInt, createHash } from 'node:crypto';
import type { PrismaClient } from '@prisma/client';
import type { VerificationFailAction, VerificationMode } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import type { Redis } from 'ioredis';
const CAPTCHA_PREFIX = 'verify:captcha:';
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.verificationConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.verificationConfig.create({ data: { guildId } });
}
return config;
}
export async function updateVerificationConfig(
prisma: PrismaClient,
guildId: string,
data: {
enabled: boolean;
channelId: string;
verifiedRoleId: string;
unverifiedRoleId?: string | null;
mode: VerificationMode;
minAccountAgeDays: number;
failAction: VerificationFailAction;
}
) {
await ensureGuild(prisma, guildId);
return prisma.verificationConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export function verificationButtonId(guildId: string): string {
return `verify:button:${guildId}`;
}
export function verificationCaptchaButtonId(guildId: string): string {
return `verify:captcha:${guildId}`;
}
export function isVerificationButton(customId: string): boolean {
return customId.startsWith('verify:button:') || customId.startsWith('verify:captcha:');
}
export function parseVerificationButton(customId: string): {
mode: 'button' | 'captcha';
guildId: string;
} | null {
if (customId.startsWith('verify:button:')) {
return { mode: 'button', guildId: customId.slice('verify:button:'.length) };
}
if (customId.startsWith('verify:captcha:')) {
return { mode: 'captcha', guildId: customId.slice('verify:captcha:'.length) };
}
return null;
}
export type CaptchaChallenge = {
token: string;
guildId: string;
userId: string;
question: string;
answerHash: string;
};
export async function createCaptchaChallenge(
redis: Redis,
guildId: string,
userId: string
): Promise<CaptchaChallenge> {
const a = randomInt(2, 12);
const b = randomInt(2, 12);
const answer = String(a + b);
const token = randomBytes(24).toString('hex');
const answerHash = createHash('sha256').update(answer).digest('hex');
const challenge: CaptchaChallenge = {
token,
guildId,
userId,
question: `${a} + ${b}`,
answerHash
};
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
return challenge;
}
export async function getCaptchaChallenge(
redis: Redis,
token: string
): Promise<CaptchaChallenge | null> {
const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`);
if (!raw) {
return null;
}
return JSON.parse(raw) as CaptchaChallenge;
}
export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise<void> {
await redis.del(`${CAPTCHA_PREFIX}${token}`);
}
export function hashCaptchaAnswer(answer: string): string {
return createHash('sha256').update(answer.trim()).digest('hex');
}
export function accountAgeDays(userCreatedAt: Date): number {
const ms = Date.now() - userCreatedAt.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}

View File

@@ -0,0 +1,14 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription } from '@nexumi/shared';
export const welcomeCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('welcome')
.addSubcommand((s) =>
applyCommandDescription(s.setName('test'), 'welcome.test.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('preview'), 'welcome.preview.description')
),
'welcome.description'
);

View File

@@ -0,0 +1,56 @@
import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { welcomeCommandData } from './command-definitions.js';
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
import { buildWelcomeMessage } from './renderer.js';
const welcomeCommand: SlashCommand = {
data: welcomeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const config = await getWelcomeConfig(context.prisma, interaction.guildId!);
const sub = interaction.options.getSubcommand();
const member = interaction.member;
if (!member || !('guild' in member)) {
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
return;
}
const guildMember = await interaction.guild!.members.fetch(interaction.user.id);
const payload = resolveWelcomePayload(config);
if (sub === 'preview') {
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
await interaction.reply({ ...message, ephemeral: true });
return;
}
if (!config.welcomeChannelId) {
await interaction.reply({ content: t(locale, 'welcome.notConfigured'), ephemeral: true });
return;
}
const channel = await interaction.guild!.channels.fetch(config.welcomeChannelId);
if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({ content: t(locale, 'welcome.invalidChannel'), ephemeral: true });
return;
}
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
await channel.send(message);
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
}
};
export const welcomeCommands: SlashCommand[] = [welcomeCommand];

View File

@@ -0,0 +1,75 @@
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
import type { BotContext } from '../../types.js';
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
import { logger } from '../../logger.js';
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return;
}
const assignable = roleIds.filter((roleId) => {
const role = member.guild.roles.cache.get(roleId);
return role && role.position < me.roles.highest.position;
});
if (assignable.length === 0) {
return;
}
await member.roles.add(assignable).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles');
});
}
export async function handleMemberWelcome(context: BotContext, member: GuildMember): Promise<void> {
const config = await getWelcomeConfig(context.prisma, member.guild.id);
const roleIds = member.user.bot ? config.botAutoroleIds : config.userAutoroleIds;
await applyAutoroles(member, roleIds);
if (config.welcomeEnabled && config.welcomeChannelId) {
const channel = await member.guild.channels.fetch(config.welcomeChannelId).catch(() => null);
if (channel?.isTextBased() && !channel.isDMBased()) {
await sendWelcomeMessage(
channel,
resolveWelcomePayload(config),
member,
member.guild
);
}
}
if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) {
await member
.send({ content: config.welcomeDmContent.replace('{user}', `<@${member.id}>`) })
.catch(() => undefined);
}
}
export async function handleMemberLeave(context: BotContext, member: GuildMember): Promise<void> {
const config = await getWelcomeConfig(context.prisma, member.guild.id);
if (!config.leaveEnabled || !config.leaveChannelId) {
return;
}
const channel = await member.guild.channels.fetch(config.leaveChannelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
return;
}
const content = config.leaveContent ?? '{user.tag} left {server}.';
await sendLeaveMessage(channel, content, member, member.guild);
}
export function registerWelcomeEvents(context: BotContext): void {
context.client.on('guildMemberAdd', async (member) => {
await handleMemberWelcome(context, member);
const { handleAntiRaidJoin } = await import('../automod/actions.js');
await handleAntiRaidJoin(context, member);
});
context.client.on('guildMemberRemove', async (member) => {
if (!member.guild) {
return;
}
await handleMemberLeave(context, member as GuildMember);
});
}

View File

@@ -0,0 +1,107 @@
import {
AttachmentBuilder,
EmbedBuilder,
type Guild,
type GuildMember,
type SendableChannels,
type TextBasedChannel
} from 'discord.js';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import type { WelcomePayload } from './service.js';
import { renderWelcomeText } from './service.js';
export async function buildWelcomeMessage(
payload: WelcomePayload,
member: GuildMember,
guild: Guild
): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> {
if (payload.type === 'EMBED') {
const embed = new EmbedBuilder();
const data = payload.embed;
if (data?.title) {
embed.setTitle(renderWelcomeText(data.title, member, guild));
}
if (data?.description) {
embed.setDescription(renderWelcomeText(data.description, member, guild));
}
if (data?.color !== undefined) {
embed.setColor(data.color);
}
embed.setThumbnail(member.user.displayAvatarURL({ size: 256 }));
return { embeds: [embed] };
}
if (payload.type === 'IMAGE') {
const buffer = await renderWelcomeCard(member, guild, payload);
const attachment = new AttachmentBuilder(buffer, { name: 'welcome.png' });
return { files: [attachment] };
}
const content =
payload.content ??
`Welcome {user} to {server}!`.replace('{user}', `<@${member.id}>`).replace('{server}', guild.name);
return { content: renderWelcomeText(content, member, guild) };
}
async function renderWelcomeCard(
member: GuildMember,
guild: Guild,
payload: WelcomePayload
): Promise<Buffer> {
const width = 900;
const height = 300;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#6366f1';
ctx.fillRect(0, 0, width, 8);
const avatar = await loadImage(member.user.displayAvatarURL({ extension: 'png', size: 128 }));
ctx.save();
ctx.beginPath();
ctx.arc(120, 150, 64, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatar, 56, 86, 128, 128);
ctx.restore();
const title = payload.imageTitle ?? 'Welcome!';
const subtitle =
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
ctx.fillStyle = '#f9fafb';
ctx.font = 'bold 36px sans-serif';
ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130);
ctx.fillStyle = '#d1d5db';
ctx.font = '24px sans-serif';
ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180);
return canvas.toBuffer('image/png');
}
export async function sendWelcomeMessage(
channel: TextBasedChannel,
payload: WelcomePayload,
member: GuildMember,
guild: Guild
): Promise<void> {
const message = await buildWelcomeMessage(payload, member, guild);
if ('send' in channel) {
await (channel as SendableChannels).send(message);
}
}
export async function sendLeaveMessage(
channel: TextBasedChannel,
content: string,
member: GuildMember,
guild: Guild
): Promise<void> {
if ('send' in channel) {
await (channel as SendableChannels).send({ content: renderWelcomeText(content, member, guild) });
}
}

View File

@@ -0,0 +1,56 @@
import type { PrismaClient } from '@prisma/client';
import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared';
import { applyWelcomePlaceholders } from '@nexumi/shared';
import type { Guild, GuildMember } from 'discord.js';
import { ensureGuild } from '../../guild.js';
export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.welcomeConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.welcomeConfig.create({ data: { guildId } });
}
return config;
}
export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
return {
user: `<@${member.id}>`,
'user.name': member.displayName,
'user.tag': member.user.tag,
'user.id': member.id,
server: guild.name,
memberCount: guild.memberCount
};
}
export function renderWelcomeText(template: string, member: GuildMember, guild: Guild): string {
return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild));
}
export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null {
if (!raw || typeof raw !== 'object') {
return null;
}
return raw as WelcomeEmbed;
}
export type WelcomePayload = {
type: WelcomeMessageType;
content?: string | null;
embed?: WelcomeEmbed | null;
imageTitle?: string | null;
imageSubtitle?: string | null;
};
export function resolveWelcomePayload(
config: Awaited<ReturnType<typeof getWelcomeConfig>>
): WelcomePayload {
return {
type: config.welcomeType as WelcomeMessageType,
content: config.welcomeContent,
embed: parseWelcomeEmbed(config.welcomeEmbed),
imageTitle: config.imageTitle,
imageSubtitle: config.imageSubtitle
};
}

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

@@ -0,0 +1,11 @@
import { Queue } from 'bullmq';
import { redis } from './redis.js';
export const moderationQueueName = 'moderation';
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
export const backupQueueName = 'backups';
export const backupQueue = new Queue(backupQueueName, { connection: redis });
export const automodQueueName = 'automod';
export const automodQueue = new Queue(automodQueueName, { connection: redis });
export const verificationQueueName = 'verification';
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });