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:
537
apps/bot/src/modules/logging/events.ts
Normal file
537
apps/bot/src/modules/logging/events.ts
Normal 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')
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user