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

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