import { Sticker, 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', EventEdit = 'EVENT_EDIT', EventDelete = 'EVENT_DELETE', InviteCreate = 'INVITE_CREATE', InviteDelete = 'INVITE_DELETE', MemberBan = 'MEMBER_BAN', MemberUnban = 'MEMBER_UNBAN', MemberKick = 'MEMBER_KICK', MemberDisconnect = 'MEMBER_DISCONNECT', 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.EventEdit, causer: User | null, oldEvent: GuildScheduledEvent, 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, } | { type: EventType.MemberUnban, causer: User | null, member: GuildMember, reason: string, } | { type: EventType.MemberKick, causer: User | null, member: GuildMember, reason: string, } | { type: EventType.MemberDisconnect, causer: User | null, member: GuildMember, } | { type: EventType.MemberNickname, causer: User | null, member: GuildMember, oldNickname: string, newNickname: string, } | { 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.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 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; } 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; } } } 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; 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, }); }); bot.on(Events.GuildAuditLogEntryCreate, async (entry, guild) => { if (entry.action === AuditLogEvent.InviteDelete) { if (!entry.target) return; triggerEvent(bot, guild.id, { type: EventType.InviteDelete, causer: entry.executor, // @ts-expect-error shut up invite: entry.target }); } else if (entry.action === AuditLogEvent.EmojiCreate) { } }); }