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:
53
apps/bot/src/modules/verification/command-definitions.ts
Normal file
53
apps/bot/src/modules/verification/command-definitions.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ChannelType } from 'discord.js';
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const verifyCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('verify')
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('setup'), 'verify.setup.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||
'verify.setup.options.channel'
|
||||
)
|
||||
)
|
||||
.addRoleOption((o) =>
|
||||
applyOptionDescription(o.setName('verified_role').setRequired(true), 'verify.setup.options.verified_role')
|
||||
)
|
||||
.addRoleOption((o) =>
|
||||
applyOptionDescription(o.setName('unverified_role'), 'verify.setup.options.unverified_role')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('mode'), 'verify.setup.options.mode')
|
||||
.addChoices(
|
||||
{ name: 'Button', value: 'BUTTON' },
|
||||
{ name: 'Captcha', value: 'CAPTCHA' }
|
||||
)
|
||||
)
|
||||
.addIntegerOption((o) =>
|
||||
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
|
||||
.setMinValue(0)
|
||||
.setMaxValue(365)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('fail_action'), 'verify.setup.options.fail_action')
|
||||
.addChoices(
|
||||
{ name: 'Kick', value: 'KICK' },
|
||||
{ name: 'Ban', value: 'BAN' },
|
||||
{ name: 'None', value: 'NONE' }
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('channel').addChannelTypes(ChannelType.GuildText),
|
||||
'verify.panel.options.channel'
|
||||
)
|
||||
)
|
||||
),
|
||||
'verify.description'
|
||||
);
|
||||
84
apps/bot/src/modules/verification/commands.ts
Normal file
84
apps/bot/src/modules/verification/commands.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { verifyCommandData } from './command-definitions.js';
|
||||
import {
|
||||
getVerificationConfig,
|
||||
updateVerificationConfig
|
||||
} from './service.js';
|
||||
import { buildVerificationPanel } from './handlers.js';
|
||||
|
||||
const verifyCommand: SlashCommand = {
|
||||
data: verifyCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = interaction.guildId!;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'setup') {
|
||||
const channel = interaction.options.getChannel('channel', true);
|
||||
const verifiedRole = interaction.options.getRole('verified_role', true);
|
||||
const unverifiedRole = interaction.options.getRole('unverified_role');
|
||||
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
|
||||
const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
|
||||
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
|
||||
| 'KICK'
|
||||
| 'BAN'
|
||||
| 'NONE';
|
||||
|
||||
await updateVerificationConfig(context.prisma, guildId, {
|
||||
enabled: true,
|
||||
channelId: channel.id,
|
||||
verifiedRoleId: verifiedRole.id,
|
||||
unverifiedRoleId: unverifiedRole?.id ?? null,
|
||||
mode,
|
||||
minAccountAgeDays,
|
||||
failAction
|
||||
});
|
||||
|
||||
await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getVerificationConfig(context.prisma, guildId);
|
||||
if (!config.enabled || !config.verifiedRoleId) {
|
||||
await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const panelChannelOption = interaction.options.getChannel('channel');
|
||||
const panelChannel = panelChannelOption
|
||||
? await interaction.guild!.channels.fetch(panelChannelOption.id)
|
||||
: config.channelId
|
||||
? await interaction.guild!.channels.fetch(config.channelId)
|
||||
: null;
|
||||
|
||||
if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) {
|
||||
await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = buildVerificationPanel(config);
|
||||
const message = await panelChannel.send(panel);
|
||||
await context.prisma.verificationConfig.update({
|
||||
where: { guildId },
|
||||
data: {
|
||||
panelChannelId: panelChannel.id,
|
||||
panelMessageId: message.id
|
||||
}
|
||||
});
|
||||
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true });
|
||||
}
|
||||
};
|
||||
|
||||
export const verificationCommands: SlashCommand[] = [verifyCommand];
|
||||
27
apps/bot/src/modules/verification/events.ts
Normal file
27
apps/bot/src/modules/verification/events.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getVerificationConfig } from './service.js';
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
export function registerVerificationEvents(context: BotContext): void {
|
||||
context.client.on('guildMemberAdd', async (member) => {
|
||||
const config = await getVerificationConfig(context.prisma, member.guild.id);
|
||||
if (!config.enabled || !config.unverifiedRoleId) {
|
||||
return;
|
||||
}
|
||||
if (config.verifiedRoleId && member.roles.cache.has(config.verifiedRoleId)) {
|
||||
return;
|
||||
}
|
||||
const me = member.guild.members.me;
|
||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||
return;
|
||||
}
|
||||
const role = member.guild.roles.cache.get(config.unverifiedRoleId);
|
||||
if (!role || role.position >= me.roles.highest.position) {
|
||||
return;
|
||||
}
|
||||
await member.roles.add(role).catch((error) => {
|
||||
logger.warn({ error, memberId: member.id }, 'Failed to assign unverified role');
|
||||
});
|
||||
});
|
||||
}
|
||||
149
apps/bot/src/modules/verification/handlers.ts
Normal file
149
apps/bot/src/modules/verification/handlers.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits,
|
||||
type ButtonInteraction,
|
||||
type GuildMember
|
||||
} from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
accountAgeDays,
|
||||
createCaptchaChallenge,
|
||||
getVerificationConfig,
|
||||
parseVerificationButton,
|
||||
verificationButtonId,
|
||||
verificationCaptchaButtonId
|
||||
} from './service.js';
|
||||
import { env } from '../../env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
async function applyFailAction(
|
||||
member: GuildMember,
|
||||
failAction: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const me = member.guild.members.me;
|
||||
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||
await member.kick(reason);
|
||||
return;
|
||||
}
|
||||
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||
await member.guild.members.ban(member.id, { reason });
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeVerification(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
userId: string
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const config = await getVerificationConfig(context.prisma, guildId);
|
||||
if (!config.enabled || !config.verifiedRoleId) {
|
||||
return { ok: false, reason: 'not_configured' };
|
||||
}
|
||||
|
||||
const guild = await context.client.guilds.fetch(guildId);
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
return { ok: false, reason: 'member_not_found' };
|
||||
}
|
||||
|
||||
const ageDays = accountAgeDays(member.user.createdAt);
|
||||
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
|
||||
await applyFailAction(
|
||||
member,
|
||||
config.failAction,
|
||||
`Verification failed: account too young (${ageDays}d)`
|
||||
);
|
||||
return { ok: false, reason: 'account_too_young' };
|
||||
}
|
||||
|
||||
const me = guild.members.me;
|
||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||
return { ok: false, reason: 'missing_permissions' };
|
||||
}
|
||||
|
||||
const verifiedRole = guild.roles.cache.get(config.verifiedRoleId);
|
||||
if (!verifiedRole || verifiedRole.position >= me.roles.highest.position) {
|
||||
return { ok: false, reason: 'invalid_role' };
|
||||
}
|
||||
|
||||
if (config.unverifiedRoleId && member.roles.cache.has(config.unverifiedRoleId)) {
|
||||
await member.roles.remove(config.unverifiedRoleId).catch((error) => {
|
||||
logger.warn({ error }, 'Failed to remove unverified role');
|
||||
});
|
||||
}
|
||||
|
||||
if (!member.roles.cache.has(config.verifiedRoleId)) {
|
||||
await member.roles.add(config.verifiedRoleId);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVerificationConfig>>) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Verification')
|
||||
.setDescription('Click the button below to verify and gain access to the server.')
|
||||
.setColor(0x6366f1);
|
||||
|
||||
const customId =
|
||||
config.mode === 'CAPTCHA'
|
||||
? verificationCaptchaButtonId(config.guildId)
|
||||
: verificationButtonId(config.guildId);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(customId)
|
||||
.setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
export async function handleVerificationButton(
|
||||
interaction: ButtonInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const parsed = parseVerificationButton(interaction.customId);
|
||||
if (!parsed || !interaction.guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guild.id);
|
||||
const config = await getVerificationConfig(context.prisma, parsed.guildId);
|
||||
|
||||
if (!config.enabled) {
|
||||
await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.mode === 'captcha') {
|
||||
const challenge = await createCaptchaChallenge(
|
||||
context.redis,
|
||||
parsed.guildId,
|
||||
interaction.user.id
|
||||
);
|
||||
const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`;
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'verification.captchaLink', { url }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
|
||||
if (result.ok) {
|
||||
await interaction.editReply({ content: t(locale, 'verification.success') });
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `verification.error.${result.reason ?? 'generic'}`;
|
||||
await interaction.editReply({ content: t(locale, key) });
|
||||
}
|
||||
115
apps/bot/src/modules/verification/service.ts
Normal file
115
apps/bot/src/modules/verification/service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { randomBytes, randomInt, createHash } from 'node:crypto';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { VerificationFailAction, VerificationMode } from '@nexumi/shared';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
const CAPTCHA_PREFIX = 'verify:captcha:';
|
||||
|
||||
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
let config = await prisma.verificationConfig.findUnique({ where: { guildId } });
|
||||
if (!config) {
|
||||
config = await prisma.verificationConfig.create({ data: { guildId } });
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function updateVerificationConfig(
|
||||
prisma: PrismaClient,
|
||||
guildId: string,
|
||||
data: {
|
||||
enabled: boolean;
|
||||
channelId: string;
|
||||
verifiedRoleId: string;
|
||||
unverifiedRoleId?: string | null;
|
||||
mode: VerificationMode;
|
||||
minAccountAgeDays: number;
|
||||
failAction: VerificationFailAction;
|
||||
}
|
||||
) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
return prisma.verificationConfig.upsert({
|
||||
where: { guildId },
|
||||
update: data,
|
||||
create: { guildId, ...data }
|
||||
});
|
||||
}
|
||||
|
||||
export function verificationButtonId(guildId: string): string {
|
||||
return `verify:button:${guildId}`;
|
||||
}
|
||||
|
||||
export function verificationCaptchaButtonId(guildId: string): string {
|
||||
return `verify:captcha:${guildId}`;
|
||||
}
|
||||
|
||||
export function isVerificationButton(customId: string): boolean {
|
||||
return customId.startsWith('verify:button:') || customId.startsWith('verify:captcha:');
|
||||
}
|
||||
|
||||
export function parseVerificationButton(customId: string): {
|
||||
mode: 'button' | 'captcha';
|
||||
guildId: string;
|
||||
} | null {
|
||||
if (customId.startsWith('verify:button:')) {
|
||||
return { mode: 'button', guildId: customId.slice('verify:button:'.length) };
|
||||
}
|
||||
if (customId.startsWith('verify:captcha:')) {
|
||||
return { mode: 'captcha', guildId: customId.slice('verify:captcha:'.length) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type CaptchaChallenge = {
|
||||
token: string;
|
||||
guildId: string;
|
||||
userId: string;
|
||||
question: string;
|
||||
answerHash: string;
|
||||
};
|
||||
|
||||
export async function createCaptchaChallenge(
|
||||
redis: Redis,
|
||||
guildId: string,
|
||||
userId: string
|
||||
): Promise<CaptchaChallenge> {
|
||||
const a = randomInt(2, 12);
|
||||
const b = randomInt(2, 12);
|
||||
const answer = String(a + b);
|
||||
const token = randomBytes(24).toString('hex');
|
||||
const answerHash = createHash('sha256').update(answer).digest('hex');
|
||||
const challenge: CaptchaChallenge = {
|
||||
token,
|
||||
guildId,
|
||||
userId,
|
||||
question: `${a} + ${b}`,
|
||||
answerHash
|
||||
};
|
||||
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
|
||||
return challenge;
|
||||
}
|
||||
|
||||
export async function getCaptchaChallenge(
|
||||
redis: Redis,
|
||||
token: string
|
||||
): Promise<CaptchaChallenge | null> {
|
||||
const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw) as CaptchaChallenge;
|
||||
}
|
||||
|
||||
export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise<void> {
|
||||
await redis.del(`${CAPTCHA_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
export function hashCaptchaAnswer(answer: string): string {
|
||||
return createHash('sha256').update(answer.trim()).digest('hex');
|
||||
}
|
||||
|
||||
export function accountAgeDays(userCreatedAt: Date): number {
|
||||
const ms = Date.now() - userCreatedAt.getTime();
|
||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
Reference in New Issue
Block a user