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

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