import { ThreadChannel, Message, User, Invite, GuildMember, GuildScheduledEvent, GuildEmoji, Client, Events, AuditLogEvent, ChannelType, PartialMessage, EmbedBuilder, MessageType } from 'discord.js'; import { AuditLog, db } from './db'; import * as log from './log'; import { formatMessageAsEmbed, getUploadLimitForGuild, shortenStr } from './util'; import { Change, diffWords } from 'diff'; export enum EventType { EmojiCreate = 'EMOJI_CREATE', EmojiRename = 'EMOJI_RENAME', EmojiDelete = 'EMOJI_DELETE', EventCreate = 'EVENT_CREATE', EventDelete = 'EVENT_DELETE', InviteCreate = 'INVITE_CREATE', InviteDelete = 'INVITE_DELETE', MemberBan = 'MEMBER_BAN', MemberUnban = 'MEMBER_UNBAN', MemberKick = 'MEMBER_KICK', MemberNickname = 'MEMBER_NICKNAME', MemberJoin = 'MEMBER_JOIN', MemberLeave = 'MEMBER_LEAVE', MessageDelete = 'MESSAGE_DELETE', MessageEdit = 'MESSAGE_EDIT', //StickerCreate = 'STICKER_CREATE', //StickerRename = 'STICKER_RENAME', //StickerDelete = 'STICKER_DELETE', ThreadCreate = 'THREAD_CREATE', ThreadEdit = 'THREAD_EDIT', ThreadDelete = 'THREAD_DELETE', } export type Event = { type: EventType.EmojiCreate, causer: User | null, emoji: GuildEmoji, } | { type: EventType.EmojiRename, causer: User | null, emoji: GuildEmoji, oldName: string, newName: string, } | { type: EventType.EmojiDelete, causer: User | null, emoji: GuildEmoji, } | { type: EventType.EventCreate, causer: User | null, event: GuildScheduledEvent, } | { type: EventType.EventDelete, causer: User | null, event: GuildScheduledEvent, } | { type: EventType.InviteCreate, causer: User | null, invite: Invite, } | { type: EventType.InviteDelete, causer: User | null, invite: Invite, } | { type: EventType.MemberBan, causer: User | null, member: GuildMember, reason: string | null, } | { type: EventType.MemberUnban, causer: User | null, member: GuildMember, reason: string | null, } | { type: EventType.MemberKick, causer: User | null, member: GuildMember, reason: string | null, } | { type: EventType.MemberNickname, causer: User | null, member: GuildMember, oldNickname: string | null, newNickname: string | null, } | { type: EventType.MemberJoin, causer: User | null, member: GuildMember, } | { type: EventType.MemberLeave, causer: User | null, member: GuildMember, } | { type: EventType.MessageDelete, causer: User | null, message: Message | PartialMessage, } | { type: EventType.MessageEdit, causer: User | null, oldMessage: Message, message: Message, }/* | { type: EventType.StickerCreate, causer: User | null, sticker: Sticker, } | { type: EventType.StickerRename, causer: User | null, oldName: string, newName: string, } | { type: EventType.StickerDelete, causer: User | null, sticker: Sticker, }*/ | { type: EventType.ThreadCreate, causer: User | null, thread: ThreadChannel, } | { type: EventType.ThreadEdit, causer: User | null, oldThread: ThreadChannel, thread: ThreadChannel, } | { type: EventType.ThreadDelete, causer: User | null, thread: ThreadChannel, }; export async function triggerEvent(bot: Client, guildId: string, event: Event) { const type = event.type; log.info(`got event ${event.type}`); const logs = await db('auditLogs') .select('guild', 'channel') .where('guild', guildId) // @ts-expect-error this LITERALLY works .whereLike(db.raw('UPPER(eventTypes)'), `%${type.toUpperCase()}%`); for (const auditLog of logs) { let channel; try { channel = await bot.channels.fetch(auditLog.channel); } catch (err) { log.warn(err); } if (channel && (channel.type === ChannelType.GuildText)) { switch(event.type) { case EventType.EmojiCreate: { const logEmbed = new EmbedBuilder() .setDescription(`Emoji **${event.emoji.name}** ${event.emoji} created by ${event.causer}`) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.EmojiRename: { const logEmbed = new EmbedBuilder() .setDescription(`Emoji **${event.oldName}** ${event.emoji} renamed to **${event.newName}** by ${event.causer}`) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.EmojiDelete: { const logEmbed = new EmbedBuilder() .setDescription(`Emoji **${event.emoji.name}** deleted by ${event.causer}`) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.EventCreate: { const ts = event.event.scheduledStartAt && Math.floor(event.event.scheduledStartAt.getTime() / 1000); const logEmbed = new EmbedBuilder() .setDescription(`Event [**${event.event.name}**](${event.event.url}) created by ${event.causer}` + (ts ? ` for ()` : '')) .setImage(event.event.coverImageURL()) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.EventDelete: { const logEmbed = new EmbedBuilder() .setDescription(`Event **${event.event.name}** cancelled by ${event.causer}`) .setFooter({ text: '\'Cancelled\' is equivalent to \'deleted\' in Discord terms' }) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.InviteCreate: { const logEmbed = new EmbedBuilder() .setDescription(`Invite \`${event.invite.code}\` created by ${event.causer} in <#${event.invite.channelId}>`) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.InviteDelete: { const logEmbed = new EmbedBuilder() .setDescription(`Invite \`${event.invite.code}\` (made by ${event.invite.inviter} in <#${event.invite.channelId}>) deleted by ${event.causer}`) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.MemberBan: { const logEmbed = new EmbedBuilder() .setDescription(`${event.member} banned by ${event.causer}` + (event.reason ? ` (reason: \`${event.reason}\`)` : '')) .setThumbnail(event.member.avatarURL()) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.MemberUnban: { const logEmbed = new EmbedBuilder() .setDescription(`${event.member} unbanned by ${event.causer}` + (event.reason ? ` (reason: \`${event.reason}\`)` : '')) .setThumbnail(event.member.avatarURL()) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.MemberKick: { const logEmbed = new EmbedBuilder() .setDescription(`${event.member} kicked by ${event.causer}` + (event.reason ? ` (reason: \`${event.reason}\`)` : '')) .setThumbnail(event.member.avatarURL()) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.MemberNickname: { const verb = event.newNickname ? (event.oldNickname ? 'changed' : 'set') : 'removed'; const logEmbed = new EmbedBuilder() .setDescription(`${event.member}${(event.causer && event.causer.id !== event.member.id) ? `'s nickname was ${verb} by ${event.causer}` : ` ${verb} their nickname`}${event.newNickname ? ` to **${event.newNickname}**` : ''}`) .setThumbnail(event.member.avatarURL()) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.MemberJoin: { const logEmbed = new EmbedBuilder() .setDescription(`${event.member} joined`) .setThumbnail(event.member.user.avatarURL()) .setTimestamp(); const ts = Math.floor(event.member.user.createdAt.getTime() / 1000); logEmbed.addFields({ name: 'Account Age', value: ``, }); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.MemberLeave: { const logEmbed = new EmbedBuilder() .setDescription(`${event.member} left`) .setThumbnail(event.member.avatarURL()) .setTimestamp(); channel.send({ embeds: [ logEmbed ], }); break; } case EventType.MessageDelete: { const limit = getUploadLimitForGuild(channel.guild); const { embed, attach } = formatMessageAsEmbed(event.message, limit); const logEmbed = new EmbedBuilder() .setDescription(`Message sent by ${event.message.author} in <#${event.message.channelId}> deleted by **${event.causer}**:`) .setTimestamp(); channel.send({ embeds: [ logEmbed, embed ], files: attach ? [{ attachment: attach.url, name: attach.name, description: attach.url, }] : [], }); break; } case EventType.MessageEdit: { const logEmbed = new EmbedBuilder() .setDescription(`[Message](${event.message.url}) sent by ${event.message.author} in <#${event.message.channelId}> edited:`) .setTimestamp(); const diff = diffWords(event.oldMessage.content.replace(/`/g, ''), event.message.content.replace(/`/g, '')); let output = ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff.forEach((part: Change) => { if (part.added) output += `\x1B[2;32m\x1B[1;32m${part.value}\x1B[0m`; if (part.removed) output += `\x1B[2;41m\x1B[1;2m${part.value}\x1B[0m`; if (!part.added && !part.removed) output += part.value; }); const diffMsg = `\`\`\`ansi\n${shortenStr(output, 4000)}\`\`\``; const editEmbed = new EmbedBuilder() .setAuthor({ name: event.message.author.tag, iconURL: event.message.author.displayAvatarURL() }) .setDescription(diffMsg); channel.send({ embeds: [ logEmbed, editEmbed ], }); break; } } } else { log.warn(`Channel ${auditLog.channel} from guild ${auditLog.guild} not found! Deleting audit log`); await db('auditLogs') .where('guild', auditLog.guild) .where('channel', auditLog.channel) .delete(); } } } const DELETE_FETCH_TIMEOUT = 2_000; export function setupListeners(bot: Client) { bot.on(Events.MessageDelete, async message => { if (!message.guild) return; if (!(message.type === MessageType.Default || message.type === MessageType.Reply || message.type === MessageType.ThreadStarterMessage)) return; let causer: User | null = message.author; const audit = await message.guild.fetchAuditLogs({ type: AuditLogEvent.MessageDelete, limit: 1, }); const entry = audit.entries.first(); if (entry && ((Date.now() - entry.createdTimestamp) < DELETE_FETCH_TIMEOUT || (entry.extra.count > 1)) && entry.targetId === message.author?.id) { causer = entry.executor; } triggerEvent(bot, message.guild.id, { type: EventType.MessageDelete, causer, message, }); }); bot.on(Events.MessageUpdate, async (old, message) => { if (!message.guild) return; if (message.content === old.content) return; if (message.partial || old.partial) return; if (message.flags.has('Ephemeral')) return; triggerEvent(bot, message.guild.id, { type: EventType.MessageEdit, causer: message.author, oldMessage: old, message: message, }); }); bot.on(Events.InviteCreate, async invite => { if (!invite.guild || !invite.inviter) return; triggerEvent(bot, invite.guild.id, { type: EventType.InviteCreate, causer: invite.inviter || (invite.inviterId ? await bot.users.fetch(invite.inviterId) : null), invite, }); }); bot.on(Events.GuildScheduledEventCreate, async event => { triggerEvent(bot, event.guildId, { type: EventType.EventCreate, causer: event.creator || (event.creatorId ? await bot.users.fetch(event.creatorId) : null), event, }); }); bot.on(Events.GuildMemberAdd, async member => { triggerEvent(bot, member.guild.id, { type: EventType.MemberJoin, causer: member.user, member, }); }); bot.on(Events.GuildMemberRemove, async member => { if (member.partial) return; const auditKick = await member.guild.fetchAuditLogs({ type: AuditLogEvent.MemberKick, limit: 1, }); const entryKick = auditKick.entries.first(); if ( entryKick && ((Date.now() - entryKick.createdTimestamp) < DELETE_FETCH_TIMEOUT && entryKick.targetId === member.id) ) { return; } const auditBan = await member.guild.fetchAuditLogs({ type: AuditLogEvent.MemberBanAdd, limit: 1, }); const entryBan = auditBan.entries.first(); if ( entryBan && ((Date.now() - entryBan.createdTimestamp) < DELETE_FETCH_TIMEOUT && entryBan.targetId === member.id) ) { return; } triggerEvent(bot, member.guild.id, { type: EventType.MemberLeave, causer: member.user, member, }); }); bot.on(Events.ThreadCreate, async (thread, newly) => { if (!newly) return; triggerEvent(bot, thread.guildId, { type: EventType.ThreadCreate, causer: (await thread.fetchOwner())?.user ?? null, thread, }); }); bot.on(Events.ThreadUpdate, async (oldThread, thread) => { triggerEvent(bot, thread.guildId, { type: EventType.ThreadEdit, causer: null, oldThread, thread, }); }); bot.on(Events.GuildAuditLogEntryCreate, async (entry, guild) => { const executor = entry.executor || (entry.executorId ? await bot.users.fetch(entry.executorId) : null); if (entry.action === AuditLogEvent.InviteDelete) { if (!entry.target) return; triggerEvent(bot, guild.id, { type: EventType.InviteDelete, causer: executor, // @ts-expect-error shut up invite: entry.target }); } else if (entry.action === AuditLogEvent.EmojiCreate) { triggerEvent(bot, guild.id, { type: EventType.EmojiCreate, causer: executor, emoji: await guild.emojis.fetch(entry.targetId!), }); } else if (entry.action === AuditLogEvent.EmojiUpdate) { const nameChange = entry.changes.find(c => c.key === 'name'); if (!nameChange) return; triggerEvent(bot, guild.id, { type: EventType.EmojiRename, causer: executor, // @ts-expect-error dude oldName: nameChange.old!, newName: nameChange.new!, // @ts-expect-error stop emoji: entry.target, }); } else if (entry.action === AuditLogEvent.EmojiDelete) { triggerEvent(bot, guild.id, { type: EventType.EmojiDelete, causer: executor, // @ts-expect-error help emoji: entry.target }); } else if (entry.action === AuditLogEvent.GuildScheduledEventDelete) { triggerEvent(bot, guild.id, { type: EventType.EventDelete, causer: executor, // @ts-expect-error shut the fuck up event: entry.target, }); } else if (entry.action === AuditLogEvent.MemberBanAdd) { triggerEvent(bot, guild.id, { type: EventType.MemberBan, causer: executor, // @ts-expect-error shut the fuck up member: entry.target, reason: entry.reason, }); } else if (entry.action === AuditLogEvent.MemberBanRemove) { triggerEvent(bot, guild.id, { type: EventType.MemberUnban, causer: executor, // @ts-expect-error shut the fuck up member: entry.target, reason: entry.reason, }); } else if (entry.action === AuditLogEvent.MemberKick) { triggerEvent(bot, guild.id, { type: EventType.MemberKick, causer: executor, // @ts-expect-error please member: entry.target, reason: entry.reason, }); } else if (entry.action === AuditLogEvent.ThreadDelete) { triggerEvent(bot, guild.id, { type: EventType.ThreadDelete, causer: executor, // @ts-expect-error be gone thread: entry.target, }); } else if (entry.action === AuditLogEvent.MemberUpdate) { const nameChange = entry.changes.find(c => c.key === 'nick'); if (!nameChange) return; triggerEvent(bot, guild.id, { type: EventType.MemberNickname, causer: executor, member: await guild.members.fetch(entry.targetId!), // @ts-expect-error shut up oldNickname: nameChange.old || null, newNickname: nameChange.new || null, }); } }); }