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,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')
);
});
}