jillo-bot/src/lib/events.ts

574 lines
17 KiB
TypeScript

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<AuditLog>('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 <t:${ts}:R> (<t:${ts}>)` : ''))
.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: `<t:${ts}:R>`,
});
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<AuditLog>('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,
});
}
});
}