diff --git a/src/events/guild/guildAuditLogEntryCreate.ts b/src/events/guild/guildAuditLogEntryCreate.ts new file mode 100644 index 0000000..ca24628 --- /dev/null +++ b/src/events/guild/guildAuditLogEntryCreate.ts @@ -0,0 +1,98 @@ +import { APIEmbedField, APIRole, AuditLogEvent, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js'; +import { Database } from '../../shared/Database'; +import { CreateDefaultEmbed, formatChanges, formatExtras, formatSnowflake, getTargetType } from '../../utils'; + +const IGNORED_ACTIONS = [ + AuditLogEvent.MemberMove, + AuditLogEvent.AutoModerationFlagToChannel // Handled by automod. +]; + +async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise { + if (!entry.executorId) return true; + + if (IGNORED_ACTIONS.some(a => entry.action == a)) return false; + + if (entry.action == AuditLogEvent.MemberRoleUpdate) { + return await shouldLogRoleChanges(entry as GuildAuditLogsEntry, guild); + } + + return true; +} + +async function shouldLogRoleChanges(entry: GuildAuditLogsEntry, guild: Guild): Promise { + for (const change of entry.changes) { + // Get role changes from the log. + const roles = (await Promise.all((change.new! as Pick[]).map(c => guild.roles.fetch(c.id)))); + + // Check if role is part of onboarding. + for (const role of roles) { + if (role && !role.flags.has(RoleFlags.InPrompt)) return true; + } + } + + return false; +} + +export default { + event: 'guildAuditLogEntryCreate', + + handler: async (entry: GuildAuditLogsEntry, guild: Guild) => { + if (!await shouldLog(entry, guild)) return; + + const settings = await Database.getOrCreateSettings(guild.id); + if (!settings.audit_logs_channel_id) return; + + const channel = await guild.channels.fetch(settings.audit_logs_channel_id); + if (!channel || !channel.isSendable()) return; + + const fields: APIEmbedField[] = [ + { + name: 'Actor', + value: `<@${entry.executorId}>`, + inline: true + } + ]; + + if (entry.targetId) { + const targetType = getTargetType(entry.action); + fields.push({ + name: 'Target', + value: formatSnowflake(entry.targetId, targetType), + inline: true + }); + } + + if (entry.reason) { + fields.push({ + name: 'Reason', + value: entry.reason, + inline: true + }); + } + + if (entry.changes && entry.changes.length > 0) { + fields.push({ + name: 'Changes', + value: formatChanges(entry), + inline: false + }); + } + + if (entry.extra) { + fields.push({ + name: 'Options', + value: formatExtras(entry, guild), + inline: false + }); + } + + await channel.send({ + embeds: [{ + ...CreateDefaultEmbed(guild.client), + title: Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)], + timestamp: new Date(Number(SnowflakeUtil.decode(entry.id).timestamp)).toISOString(), + fields: fields, + }] + }); + } +}; diff --git a/src/events/guild/guildBanRemove.ts b/src/events/guild/guildBanRemove.ts new file mode 100644 index 0000000..918d6a2 --- /dev/null +++ b/src/events/guild/guildBanRemove.ts @@ -0,0 +1,10 @@ +import { GuildBan } from 'discord.js'; +import { Database } from '../../shared/Database'; + +export default { + event: 'guildBanRemove', + + handler: async (ban: GuildBan) => { + await Database.removeBan(ban.user.id); + } +}; diff --git a/src/events/guild/guildCreate.ts b/src/events/guild/guildCreate.ts new file mode 100644 index 0000000..37c4144 --- /dev/null +++ b/src/events/guild/guildCreate.ts @@ -0,0 +1,10 @@ +import { Guild } from 'discord.js'; +import { Database } from '../../shared/Database'; + +export default { + event: 'guildCreate', + + handler: async (guild: Guild) => { + await Database.getOrCreateSettings(guild.id); + } +}; diff --git a/src/events/guild/guildMemberAdd.ts b/src/events/guild/guildMemberAdd.ts new file mode 100644 index 0000000..d77b9f8 --- /dev/null +++ b/src/events/guild/guildMemberAdd.ts @@ -0,0 +1,27 @@ +import { GuildMember, GuildTextBasedChannel } from 'discord.js'; +import { Database } from '../../shared/Database'; +import { getE621Alts } from '../../utils'; + +export default { + event: 'guildMemberAdd', + + handler: async (member: GuildMember) => { + const settings = await Database.getOrCreateSettings(member.guild.id); + + if (settings.new_member_channel_id) { + const channel = await member.guild.channels.fetch(settings.new_member_channel_id) as GuildTextBasedChannel; + + if (channel) { + const content = `${member.toString()}'s (${member.id}) e621 and discord account(s):\n${await getE621Alts(member.id, member.guild)}`; + + channel.send(content).catch(console.error); + + if (settings.moderator_channel_id && content.includes('[BANNED]')) { + const modChannel = await member.guild.channels.fetch(settings.moderator_channel_id) as GuildTextBasedChannel; + + if (modChannel) modChannel.send(`Member joined with banned alts:\n${content}`).catch(console.error); + } + } + } + } +}; diff --git a/src/events/handle-audit-log-create.ts b/src/events/handle-audit-log-create.ts deleted file mode 100644 index 9022fbf..0000000 --- a/src/events/handle-audit-log-create.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { APIEmbedField, APIRole, AuditLogEvent, EmbedBuilder, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js'; -import { Database } from '../shared/Database'; -import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils'; - -const IGNORED_ACTIONS = [ - AuditLogEvent.MemberMove, - // Handled by automod. - AuditLogEvent.AutoModerationFlagToChannel -]; - -export async function handleAuditLogCreate(entry: GuildAuditLogsEntry, guild: Guild) { - if (!await shouldLog(entry, guild)) return; - - const settings = await Database.getOrCreateSettings(guild.id); - if (!settings.audit_logs_channel_id) return; - - const channel = await guild.channels.fetch(settings.audit_logs_channel_id); - if (!channel || !channel.isSendable()) return; - - const fields: APIEmbedField[] = [ - { - name: 'Actor', - value: `<@${entry.executorId}>`, - inline: true - } - ]; - - if (entry.targetId) { - const targetType = getTargetType(entry.action); - fields.push({ - name: 'Target', - value: formatSnowflake(entry.targetId, targetType), - inline: true - }); - } - - if (entry.reason) { - fields.push({ - name: 'Reason', - value: entry.reason, - inline: true - }); - } - - if (entry.changes && entry.changes.length > 0) { - fields.push({ - name: 'Changes', - value: formatChanges(entry), - inline: false - }); - } - - if (entry.extra) { - fields.push({ - name: 'Options', - value: formatExtras(entry, guild), - inline: false - }); - } - - const embed = new EmbedBuilder() - .setTitle(Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)]) - .setTimestamp(Number(SnowflakeUtil.decode(entry.id).timestamp)) - .addFields(...fields); - - channel.send({ embeds: [embed] }); -} - -async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise { - if (!entry.executorId) return true; - - if (IGNORED_ACTIONS.some(a => entry.action == a)) return false; - - if (entry.action == AuditLogEvent.MemberRoleUpdate) { - return await shouldLogRoleChanges(entry as GuildAuditLogsEntry, guild); - } - - return true; -} - -async function shouldLogRoleChanges(entry: GuildAuditLogsEntry, guild: Guild): Promise { - for (const change of entry.changes) { - // Get role changes from the log. - const roles = (await Promise.all((change.new! as Pick[]).map(c => guild.roles.fetch(c.id)))); - - // Check if role is part of onboarding. - for (const role of roles) { - if (role && !role.flags.has(RoleFlags.InPrompt)) return true; - } - } - - return false; -} \ No newline at end of file diff --git a/src/events/handle-ban-remove.ts b/src/events/handle-ban-remove.ts deleted file mode 100644 index edc9888..0000000 --- a/src/events/handle-ban-remove.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { GuildBan } from 'discord.js'; -import { Database } from '../shared/Database'; - -export async function handleBanRemove(ban: GuildBan) { - await Database.removeBan(ban.user.id); -} \ No newline at end of file diff --git a/src/events/handle-guild-create.ts b/src/events/handle-guild-create.ts deleted file mode 100644 index 954a97c..0000000 --- a/src/events/handle-guild-create.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Guild } from 'discord.js'; -import { Database } from '../shared/Database'; - -export async function handleGuildCreate(guild: Guild) { - await Database.getOrCreateSettings(guild.id); -} diff --git a/src/events/handle-member-join.ts b/src/events/handle-member-join.ts deleted file mode 100644 index e1e860d..0000000 --- a/src/events/handle-member-join.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { GuildMember, GuildTextBasedChannel } from 'discord.js'; -import { Database } from '../shared/Database'; -import { getE621Alts } from '../utils'; - -export async function handleMemberJoin(member: GuildMember) { - const guildSettings = await Database.getOrCreateSettings(member.guild.id); - - if (guildSettings.new_member_channel_id) { - const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel; - - if (channel) { - const content = `${member.toString()}'s (${member.id}) e621 and discord account(s):\n${await getE621Alts(member.id, member.guild)}`; - - channel.send(content).catch(console.error); - - if (guildSettings.moderator_channel_id && content.includes('[BANNED]')) { - const modChannel = await member.guild.channels.fetch(guildSettings.moderator_channel_id) as GuildTextBasedChannel; - - if (modChannel) modChannel.send(`Member joined with banned alts:\n${content}`).catch(console.error); - } - } - } -} \ No newline at end of file diff --git a/src/events/handle-thread-create.ts b/src/events/handle-thread-create.ts deleted file mode 100644 index a3729da..0000000 --- a/src/events/handle-thread-create.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AnyThreadChannel } from 'discord.js'; - -export async function handleThreadCreate(thread: AnyThreadChannel, newlyCreated: boolean) { - try { - await thread.join(); - } catch (e) { - console.error('Failed to join thread:'); - console.error(e); - } - -} \ No newline at end of file diff --git a/src/events/handle-voice-state-update.ts b/src/events/handle-voice-state-update.ts deleted file mode 100644 index 9c31f3c..0000000 --- a/src/events/handle-voice-state-update.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Guild, GuildMember, GuildTextBasedChannel, time, VoiceBasedChannel, VoiceState } from 'discord.js'; -import { Database } from '../shared/Database'; - -export async function handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { - // The logChannel declaration being inside is purposeful, as this event is fired a lot for users talking. - if (newState.channelId != null && oldState.channelId != null && newState.channelId != oldState.channelId) { - const logChannel = await getVoiceLogsChannel(newState.guild); - if (!logChannel) return; - - await sendMovedMessage(logChannel, newState.member!, oldState.channel!, newState.channel!); - } else if (oldState.channelId == null && newState.channelId != null) { - const logChannel = await getVoiceLogsChannel(newState.guild); - if (!logChannel) return; - - await sendJoinMessage(logChannel, newState.member!, newState.channel!); - } else if (newState.channelId == null && oldState.channelId != null) { - const logChannel = await getVoiceLogsChannel(newState.guild); - if (!logChannel) return; - - await sendLeftMessage(logChannel, newState.member!, oldState.channel!); - } -} - -async function sendJoinMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { - await channel.send(`${member} joined ${voiceChannel} at ${time()}`); -} - -async function sendLeftMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { - await channel.send(`${member} left ${voiceChannel} at ${time()}`); -} - -async function sendMovedMessage(channel: GuildTextBasedChannel, member: GuildMember, oldVoiceChannel: VoiceBasedChannel, newVoiceChannel: VoiceBasedChannel) { - await channel.send(`${member} moved from ${oldVoiceChannel} to ${newVoiceChannel} at ${time()}`); -} - -async function getVoiceLogsChannel(guild: Guild): Promise { - const settings = await Database.getOrCreateSettings(guild.id); - - if (!settings || !settings.voice_logs_channel_id) return; - - const channel = await guild.channels.fetch(settings.voice_logs_channel_id); - - if (!channel || !channel.isSendable()) return; - - return channel as GuildTextBasedChannel; -} \ No newline at end of file diff --git a/src/events/index.ts b/src/events/index.ts index dca7ff2..1a69bbc 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,7 +1,23 @@ -export * from './handle-audit-log-create'; -export * from './handle-ban-remove'; -export * from './handle-guild-create'; -export * from './handle-member-join'; -export * from './handle-message'; -export * from './handle-thread-create'; -export * from './handle-voice-state-update'; +import auditLogCreate from './guild/guildAuditLogEntryCreate'; +import guildBanRemove from './guild/guildBanRemove'; +import guildCreate from './guild/guildCreate'; +import guildMemberAdd from './guild/guildMemberAdd'; +import messageCreate from './message/messageCreate'; +import messageDelete from './message/messageDelete'; +import messageDeleteBulk from './message/messageDeleteBulk'; +import messageUpdate from './message/messageUpdate'; +import threadCreate from './thread/threadCreate'; +import voiceStateUpdate from './voice/voiceStateUpdate'; + +export default [ + auditLogCreate, + guildBanRemove, + guildCreate, + guildMemberAdd, + messageCreate, + messageDelete, + messageDeleteBulk, + messageUpdate, + threadCreate, + voiceStateUpdate +]; diff --git a/src/events/message/messageCreate.ts b/src/events/message/messageCreate.ts new file mode 100644 index 0000000..ef56945 --- /dev/null +++ b/src/events/message/messageCreate.ts @@ -0,0 +1,69 @@ +import { config } from '../../config'; +import { Database } from '../../shared/Database'; +import { ALLOWED_MIMETYPES, blacklistIfNecessary, calculateMD5FromURL, getE621PostByMd5, getPostUrl, md5Regex, Message, regexTesters, uniqueRegexMatches } from '../../utils'; + +export default { + event: 'messageCreate', + + handler: async (message: Message) => { + if (message.author.bot) return; + if (message.inGuild()) await Database.putMessage(message); + + const responses: string[] = []; + + for (const test of regexTesters) { + if (config.DEV_MODE && !test.runInDev) continue; + + const hasMatches = test.regex.test(message.content); + test.regex.lastIndex = 0; + + if (hasMatches) { + const matches: RegExpExecArray[] = []; + let match: RegExpExecArray | null; + + while ((match = test.regex.exec(message.content)) != null) { + matches.push(match); + } + test.regex.lastIndex = 0; + + const response = await test.handler(message, matches.filter(uniqueRegexMatches)); + + if (response === false) return; + + if (response !== true) responses.push(response as string); + } + } + + for (const attachment of message.attachments.values()) { + const match = md5Regex.exec(attachment.name); + md5Regex.lastIndex = 0; + + const md5s: string[] = []; + + if (match) md5s.push(match[1]); + else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) { + const md5Data = await calculateMD5FromURL(attachment.url); + if (!md5Data) continue; + md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5); + } + + if (md5s.length == 0) continue; + + for (const md5 of md5s) { + const post = await getE621PostByMd5(md5, false); + + if (post) { + if (await blacklistIfNecessary(message, [post])) return; + + responses.push(`<${getPostUrl(post)}>`); + + continue; + } + } + } + + if (responses.length > 0) { + await message.reply(responses.join('\n')); + } + } +}; \ No newline at end of file diff --git a/src/events/message/messageDelete.ts b/src/events/message/messageDelete.ts new file mode 100644 index 0000000..4802ecf --- /dev/null +++ b/src/events/message/messageDelete.ts @@ -0,0 +1,16 @@ +import { Message, PartialMessage } from 'discord.js'; +import { Database } from '../../shared/Database'; +import { logDeletion } from '../../utils'; + +export default { + event: 'messageDelete', + + handler: async (message: Message | PartialMessage) => { + const loggedMessage = await Database.getMessageWithRetry(message.id); + if (!loggedMessage) return; + + await Database.removeMessge(message.id); + + if (message.inGuild()) await logDeletion(loggedMessage, message); + } +}; diff --git a/src/events/message/messageDeleteBulk.ts b/src/events/message/messageDeleteBulk.ts new file mode 100644 index 0000000..6b45067 --- /dev/null +++ b/src/events/message/messageDeleteBulk.ts @@ -0,0 +1,18 @@ +import { GuildTextBasedChannel, Message, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection } from 'discord.js'; +import { Database } from '../../shared/Database'; +import { logDeletion } from '../../utils'; + +export default { + event: 'messageDeleteBulk', + + handler: async (messages: ReadonlyCollection>, channel: GuildTextBasedChannel) => { + for (const message of messages.values()) { + const loggedMessage = await Database.getMessageWithRetry(message.id); + if (!loggedMessage) continue; + + await Database.removeMessge(message.id); + + if (message.inGuild()) await logDeletion(loggedMessage, message); + } + } +}; diff --git a/src/events/message/messageUpdate.ts b/src/events/message/messageUpdate.ts new file mode 100644 index 0000000..376d6c1 --- /dev/null +++ b/src/events/message/messageUpdate.ts @@ -0,0 +1,69 @@ +import { PartialMessage } from 'discord.js'; +import { config } from '../../config'; +import { Database } from '../../shared/Database'; +import { isEdited, logEdit, Message, regexTesters, uniqueRegexMatches } from '../../utils'; + +export default { + event: 'messageUpdate', + + handler: async (oldMessage: Message | PartialMessage, newMessage: Message) => { + if (newMessage.author.bot) return; + + const loggedMessage = await Database.getMessageWithRetry(newMessage.id); + + if (!loggedMessage) { + if (newMessage.inGuild()) await Database.putMessage(newMessage); + return; + } + + if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) { + await Database.putMessage(newMessage); + await logEdit(loggedMessage, newMessage); + } + + if (loggedMessage.content == newMessage.content) return; + + const responses: string[] = []; + + for (const test of regexTesters) { + if (config.DEV_MODE && !test.runInDev) continue; + + const hasMatches = test.regex.test(newMessage.content); + test.regex.lastIndex = 0; + + if (hasMatches) { + const oldMatches: RegExpExecArray[] = []; + const newMatches: RegExpExecArray[] = []; + let match: RegExpExecArray | null; + + while ((match = test.regex.exec(newMessage.content)) != null) { + newMatches.push(match); + } + test.regex.lastIndex = 0; + + while ((match = test.regex.exec(loggedMessage.content)) != null) { + oldMatches.push(match); + } + test.regex.lastIndex = 0; + + const properMatches: RegExpExecArray[] = []; + + for (const newMatch of newMatches) { + if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch); + } + + if (properMatches.length == 0) continue; + + const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches)); + + if (response === false) return; + + if (response !== true) responses.push(response as string); + } + } + + if (responses.length > 0) { + await newMessage.reply(responses.join('\n')); + } + } +}; \ No newline at end of file diff --git a/src/events/thread/threadCreate.ts b/src/events/thread/threadCreate.ts new file mode 100644 index 0000000..acdfa8d --- /dev/null +++ b/src/events/thread/threadCreate.ts @@ -0,0 +1,13 @@ +import { AnyThreadChannel } from 'discord.js'; + +export default { + event: 'threadCreate', + + handler: async (thread: AnyThreadChannel, newlyCreated: boolean) => { + try { + await thread.join(); + } catch (e) { + console.error(`Failed to join thread: ${thread.name} (${thread.id})`, e); + } + } +}; diff --git a/src/events/voice/voiceStateUpdate.ts b/src/events/voice/voiceStateUpdate.ts new file mode 100644 index 0000000..3f1daf4 --- /dev/null +++ b/src/events/voice/voiceStateUpdate.ts @@ -0,0 +1,87 @@ +import { Guild, GuildTextBasedChannel, VoiceState } from 'discord.js'; +import { Database } from '../../shared/Database'; +import { CreateDefaultEmbed, SetSeverity } from '../../utils'; + +async function getVoiceLogsChannel(guild: Guild): Promise { + const settings = await Database.getOrCreateSettings(guild.id); + if (!settings || !settings.voice_logs_channel_id) return; + + const channel = await guild.channels.fetch(settings.voice_logs_channel_id); + if (!channel || !channel.isSendable()) return; + + return channel as GuildTextBasedChannel; +} + +async function memberJoinedChannel(_: VoiceState, state: VoiceState) { + const channel = await getVoiceLogsChannel(state.guild); + if (!channel) return; + + await channel.send({ + embeds: [{ + ...CreateDefaultEmbed(state.client), + ...SetSeverity('success'), + + title: 'Joined Voice Channel', + + fields: [ + { name: 'Member', value: `<@${state.member?.id}>`, inline: false }, + { name: 'Channel', value: `${state.channel?.name} (${state.channelId})`, inline: true }, + { name: 'Mention', value: `<#${state.channelId}>`, inline: true }, + ] + }] + }); +} + +async function memberLeftChannel(state: VoiceState, _: VoiceState) { + const channel = await getVoiceLogsChannel(state.guild); + if (!channel) return; + + await channel.send({ + embeds: [{ + ...CreateDefaultEmbed(state.client), + ...SetSeverity('error'), + + title: 'Left Voice Channel', + fields: [ + { name: 'Member', value: `<@${state.member?.id}>`, inline: false }, + { name: 'Channel', value: `${state.channel?.name} (${state.channelId})`, inline: true }, + { name: 'Mention', value: `<#${state.channelId}>`, inline: true }, + ] + }] + }); +} + +async function memberMovedChannel(oldState: VoiceState, newState: VoiceState) { + const channel = await getVoiceLogsChannel(newState.guild); + if (!channel) return; + + await channel.send({ + embeds: [{ + ...CreateDefaultEmbed(newState.client), + ...SetSeverity('warning'), + + title: 'Moved Voice Channel', + + fields: [ + { name: 'Member', value: `<@${newState.member?.id}>`, inline: false }, + { name: 'Channel', value: `<#${oldState.channelId}> -> <#${newState.channelId}>`, inline: false }, + ] + }] + }); +} + +export default { + event: 'voiceStateUpdate', + + handler: async (oldState: VoiceState, newState: VoiceState) => { + // Ignore non-movement voiceStateUpdate events. + if (oldState.channelId === newState.channelId) return; + + if (newState.channelId != null && oldState.channelId != null && newState.channelId != oldState.channelId) + await memberMovedChannel(oldState, newState); + else if (oldState.channelId == null && newState.channelId != null) + await memberJoinedChannel(oldState, newState); + else if (newState.channelId == null && oldState.channelId != null) + await memberLeftChannel(oldState, newState); + } +}; diff --git a/src/index.ts b/src/index.ts index 24db94c..3fe90fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { Client as DiscordClient, GatewayIntentBits, MessageFlags, Partials } from 'discord.js'; import { config } from './config'; -import { handleAuditLogCreate, handleBanRemove, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events'; +import events from './events'; import { ScheduledTasks, Scheduler } from './scheduler'; import { Database } from './shared/Database'; import { openRedisClient } from './shared/RedisClient'; @@ -9,6 +9,7 @@ import { initIfNecessary, loadHandlersFrom, refreshCommands } from './utils'; import { initializeWebserver } from './webserver'; import { Encrypter } from './shared/Encrypter'; + let ready = false; console.log('Starting...'); @@ -142,16 +143,12 @@ client.on('clientReady', async () => { ScheduledTasks.forEach(task => scheduler.add(task)); - client.on('guildAuditLogEntryCreate', handleAuditLogCreate); - client.on('guildBanRemove', handleBanRemove); - client.on('guildCreate', handleGuildCreate); - client.on('guildMemberAdd', handleMemberJoin); - client.on('messageCreate', handleMessageCreate); - client.on('messageDelete', handleMessageDelete); - client.on('messageDeleteBulk', handleBulkMessageDelete); - client.on('messageUpdate', handleMessageUpdate); - client.on('threadCreate', handleThreadCreate); - client.on('voiceStateUpdate', handleVoiceStateUpdate); + events.forEach(event => + client.on(event.event, (...args) => + Promise.resolve((event.handler as (...args: unknown[]) => Promise)(...args)) + .catch(e => console.error(`Error in ${event.event} handler:`, e)) + ) + ); ready = true; console.log('Ready'); diff --git a/src/shared/Database.ts b/src/shared/Database.ts index 40e51e3..df67766 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -1,10 +1,9 @@ +import { readFileSync } from 'fs'; import path from 'path'; import { open, Database as SqliteDatabase } from 'sqlite'; import sqlite3 from 'sqlite3'; -import { Message } from '../events'; import { AppealMessage, Ban, GithubUserMapping, GuildArraySetting, GuildSettings, KnowledgebaseItem, LoggedMessage, Note, PrivateHelpTicket, RoleButton, TicketMessage, TicketPhrase } from '../types'; -import { deserializeMessage, serializeMessage, wait } from '../utils'; -import { readFileSync } from 'fs'; +import { deserializeMessage, Message, serializeMessage, wait } from '../utils'; import { Encrypter } from './Encrypter'; export const enum PrivateHelpTicketStatus { diff --git a/src/utils/embed-utils.ts b/src/utils/embed-utils.ts index 35aaa96..ac16505 100644 --- a/src/utils/embed-utils.ts +++ b/src/utils/embed-utils.ts @@ -1,7 +1,6 @@ import { APIEmbed, Client } from 'discord.js'; type EmbedSeverity = - | 'default' | 'info' | 'warning' | 'error' @@ -11,6 +10,8 @@ export function CreateDefaultEmbed(context: Client): APIEmbed { if (!context.user) return {}; return { + color: 0x014995, + footer: { icon_url: context.user.avatarURL()!, text: context.user.username @@ -22,7 +23,6 @@ export function CreateDefaultEmbed(context: Client): APIEmbed { export function SetSeverity(severity: EmbedSeverity): APIEmbed { switch (severity) { - case 'default': return { color: 0x014995 }; case 'info': return { color: 0x5865F2 }; case 'warning': return { color: 0xFEE75C }; case 'error': return { color: 0xED4245 }; diff --git a/src/utils/event-log-utils.ts b/src/utils/event-log-utils.ts index eefb42e..32c224c 100644 --- a/src/utils/event-log-utils.ts +++ b/src/utils/event-log-utils.ts @@ -1,7 +1,7 @@ import { APIEmbedField, AttachmentBuilder, EmbedBuilder, Guild, GuildBasedChannel, GuildTextBasedChannel, MessageCreateOptions } from 'discord.js'; -import { Message } from '../events'; import { Database } from '../shared/Database'; import { LoggedMessage } from '../types'; +import { Message } from '../utils'; import { channelIsInStaffCategory } from './channel-utils'; import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils'; @@ -216,4 +216,4 @@ function getEditEmbeds(loggedMessage: LoggedMessage, newMessage: Message, } return fields; -} \ No newline at end of file +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 00332f9..7c28121 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -17,6 +17,7 @@ export * from './file-utils'; export * from './github-user-utils'; export * from './interaction-utils'; export * from './message-matcher-regex'; +export * from './message-handler-utils'; export * from './message-utils'; export * from './modal-utils'; export * from './ms-to-human'; diff --git a/src/events/handle-message.ts b/src/utils/message-handler-utils.ts similarity index 66% rename from src/events/handle-message.ts rename to src/utils/message-handler-utils.ts index 546bed0..62adc97 100644 --- a/src/events/handle-message.ts +++ b/src/utils/message-handler-utils.ts @@ -1,13 +1,10 @@ -import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection, spoiler } from 'discord.js'; +import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, spoiler, OmitPartialGroupDMChannel } from 'discord.js'; import { config } from '../config'; -import { Database } from '../shared/Database'; -import { E621Pool, E621Post } from '../types'; -import { ALLOWED_MIMETYPES, appealIDRegex, artistIDRegex, blipIDRegex, calculateMD5FromURL, channelIgnoresLinks, channelIsInStaffCategory, channelIsSafe, commentIDRegex, flagIDRegex, forumTopicIDRegex, getE621Pool, getE621Post, getE621PostByMd5, getManyE621Posts, getPoolUrl, getPostUrl, isEdited, isInSpoilerTags, issueRegex, logDeletion, logEdit, poolIDRegex, PostAction, postIDRegex, prRegex, recordIDRegex, searchLinkRegex, setIDRegex, spoilerOrBlacklist, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from '../utils'; +import { appealIDRegex, artistIDRegex, blipIDRegex, channelIgnoresLinks, channelIsInStaffCategory, channelIsSafe, commentIDRegex, flagIDRegex, forumTopicIDRegex, getE621Pool, getE621Post, getE621PostByMd5, getManyE621Posts, getPoolUrl, getPostUrl, isInSpoilerTags, issueRegex, poolIDRegex, PostAction, postIDRegex, prRegex, recordIDRegex, searchLinkRegex, setIDRegex, spoilerOrBlacklist, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from '.'; +import { E621Post, E621Pool } from '../types'; export type Message = OmitPartialGroupDMChannel>; -export type Partial = OmitPartialGroupDMChannel; -// TODO: I don't know of any good way to not hardcode this regex for e621 links. So I've provided two that may need to have the port altered. const postRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+posts/+([0-9]+)', 'gi'); const postShareRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+p/+([a-z0-9]+)', 'gi'); const imageRegex = new RegExp('!?https?://(?:.*@)?static[0-9]*\\.(?:e621|e926)\\.net/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi'); @@ -17,9 +14,9 @@ const postRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+posts/+([0- const imageRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi'); const poolRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+pools/+([0-9]+)', 'gi'); -const md5Regex = new RegExp('^([a-f0-9]{32}).(?:png|apng|jpg|jpeg|gif|webm|mp4)$', 'gi'); +export const md5Regex = new RegExp('^([a-f0-9]{32}).(?:png|apng|jpg|jpeg|gif|webm|mp4)$', 'gi'); -const regexTesters = [ +export const regexTesters = [ { runInDev: false, regex: postRegex, handler: postHandler.bind(null, null) }, { runInDev: false, regex: postShareRegex, handler: postHandler.bind(null, (idString: string) => { @@ -51,148 +48,7 @@ const regexTesters = [ { runInDev: true, regex: issueRegex, handler: githubIssueHandler }, ]; -const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i; - -export async function handleMessageCreate(message: Message) { - if (message.author.bot) return; - if (message.inGuild()) await Database.putMessage(message); - - const responses: string[] = []; - - for (const test of regexTesters) { - if (config.DEV_MODE && !test.runInDev) continue; - - const hasMatches = test.regex.test(message.content); - test.regex.lastIndex = 0; - - if (hasMatches) { - const matches: RegExpExecArray[] = []; - let match: RegExpExecArray | null; - - while ((match = test.regex.exec(message.content)) != null) { - matches.push(match); - } - test.regex.lastIndex = 0; - - const response = await test.handler(message, matches.filter(uniqueRegexMatches)); - - if (response === false) return; - - if (response !== true) responses.push(response as string); - } - } - - for (const attachment of message.attachments.values()) { - const match = md5Regex.exec(attachment.name); - md5Regex.lastIndex = 0; - - const md5s: string[] = []; - - if (match) md5s.push(match[1]); - else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) { - const md5Data = await calculateMD5FromURL(attachment.url); - if (!md5Data) continue; - md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5); - } - - if (md5s.length == 0) continue; - - for (const md5 of md5s) { - const post = await getE621PostByMd5(md5, false); - - if (post) { - if (await blacklistIfNecessary(message, [post])) return; - - responses.push(`<${getPostUrl(post)}>`); - - continue; - } - } - } - - if (responses.length > 0) { - await message.reply(responses.join('\n')); - } -} - -export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) { - if (newMessage.author.bot) return; - - const loggedMessage = await Database.getMessageWithRetry(newMessage.id); - - if (!loggedMessage) { - if (newMessage.inGuild()) await Database.putMessage(newMessage); - - return; - } - - if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) { - await Database.putMessage(newMessage); - await logEdit(loggedMessage, newMessage); - } - - if (loggedMessage.content == newMessage.content) return; - - const responses: string[] = []; - - for (const test of regexTesters) { - if (config.DEV_MODE && !test.runInDev) continue; - - const hasMatches = test.regex.test(newMessage.content); - test.regex.lastIndex = 0; - - if (hasMatches) { - const oldMatches: RegExpExecArray[] = []; - const newMatches: RegExpExecArray[] = []; - let match: RegExpExecArray | null; - - while ((match = test.regex.exec(newMessage.content)) != null) { - newMatches.push(match); - } - test.regex.lastIndex = 0; - - while ((match = test.regex.exec(loggedMessage.content)) != null) { - oldMatches.push(match); - } - test.regex.lastIndex = 0; - - const properMatches: RegExpExecArray[] = []; - - for (const newMatch of newMatches) { - if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch); - } - - if (properMatches.length == 0) continue; - - const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches)); - - if (response === false) return; - - if (response !== true) responses.push(response as string); - } - } - - if (responses.length > 0) { - await newMessage.reply(responses.join('\n')); - } -} - -export async function handleMessageDelete(message: Message | PartialMessage) { - const loggedMessage = await Database.getMessageWithRetry(message.id); - - if (!loggedMessage) return; - else await Database.removeMessge(message.id); - - if (message.inGuild()) { - await logDeletion(loggedMessage, message); - } -} - -export async function handleBulkMessageDelete(messages: ReadonlyCollection, channel: GuildTextBasedChannel) { - for (const message of messages.values()) { - await handleMessageDelete(message); - } -} +export const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i; async function searchHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); @@ -226,7 +82,7 @@ async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[ return true; } -async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise { +export async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise { const blacklistedIds: number[] = []; const channel = await message.channel.fetch() as GuildTextBasedChannel; diff --git a/src/utils/message-utils.ts b/src/utils/message-utils.ts index d1d59fe..17c946f 100644 --- a/src/utils/message-utils.ts +++ b/src/utils/message-utils.ts @@ -1,6 +1,6 @@ -import { Message } from '../events'; import { Encrypter } from '../shared/Encrypter'; import { LoggedMessage } from '../types'; +import { Message } from '../utils'; export const ARRAY_SEPARATOR = '$';