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'; /** Audit-log entries can lag a moment behind the gateway event. */ const AUDIT_LOG_MAX_AGE_MS = 15_000; 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; } /** * Discord does not send ban/kick reasons over the gateway. * Reasons must be read from the audit log (requires View Audit Log). */ async function fetchRecentAuditEntry( guild: Guild, type: AuditLogEvent, targetId: string ): Promise<{ reason: string | null; executorId: string | null } | null> { try { const auditLogs = await guild.fetchAuditLogs({ type, limit: 5 }); const entry = auditLogs.entries.find((item) => { if (item.targetId !== targetId) { return false; } return Date.now() - item.createdTimestamp <= AUDIT_LOG_MAX_AGE_MS; }); if (!entry) { return null; } return { reason: entry.reason ?? null, executorId: entry.executorId ?? entry.executor?.id ?? null }; } catch (error) { logger.warn({ error, guildId: guild.id, type }, 'Failed to fetch audit log for logging'); return null; } } export async function getLogChannelId( prisma: BotContext['prisma'], guildId: string, eventType: LogEventType ): Promise { const entry = await prisma.logChannel.findUnique({ where: { guildId_eventType: { guildId, eventType } } }); return entry?.channelId ?? null; } function shouldIgnoreMember( config: Awaited>, 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>, channelId: string | null ): boolean { if (!channelId) { return false; } return config.ignoredChannelIds.includes(channelId); } async function resolveLogChannel( context: BotContext, guild: Guild, eventType: LogEventType ): Promise { 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 { 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 { 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 logVerificationFail( context: BotContext, input: { guildId: string; userId: string; userTag?: string; reason: string; detail?: string; failAction?: string; matchedUserId?: string | null; } ): Promise { const guild = await context.client.guilds.fetch(input.guildId).catch(() => null); if (!guild) { return; } const lines = [ `**User:** ${input.userTag ? `${input.userTag} ` : ''}(<@${input.userId}>) (\`${input.userId}\`)`, `**Reason:** ${input.reason}` ]; if (input.detail) { lines.push(`**Detail:** ${input.detail}`); } if (input.matchedUserId) { lines.push(`**Matched user:** <@${input.matchedUserId}> (\`${input.matchedUserId}\`)`); } if (input.failAction) { lines.push(`**Fail action:** ${input.failAction}`); } await sendLogEmbed( context, guild, 'VERIFICATION_FAIL', 'Verification Failed', lines.join('\n'), 0xf97316 ); } export async function handleChannelDeleteForAntiNuke( context: BotContext, guild: Guild, channelId: string ): Promise { 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 { 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>, 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 { 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; } const kickEntry = await fetchRecentAuditEntry( member.guild, AuditLogEvent.MemberKick, member.id ); if (kickEntry) { await sendLogEmbed( context, member.guild, 'MEMBER_LEAVE', 'Member Kicked', [ `**User:** ${member.user.tag} (<@${member.id}>)`, `**Moderator:** ${kickEntry.executorId ? `<@${kickEntry.executorId}>` : '—'}`, `**Reason:** ${kickEntry.reason ?? '—'}` ].join('\n'), 0xf59e0b ); 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); const audit = await fetchRecentAuditEntry(guild, AuditLogEvent.MemberBanAdd, ban.user.id); const reason = audit?.reason ?? ban.reason ?? '—'; const executorLine = audit?.executorId ? `\n**Moderator:** <@${audit.executorId}>` : ''; await sendLogEmbed( context, guild, 'MEMBER_BAN', 'Member Banned', `**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${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') ); }); }