diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 8665b29..e6700cf 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,7 +1,7 @@ import { EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder } from '@discordjs/builders'; import { Ban } from '@/models/bans'; -import { banMessageDeleteChoices, sendEventLogMessage, ordinal } from '@/util'; +import { banMessageDeleteChoices, sendModLogMessage, ordinal } from '@/util'; import { untrustUser } from '@/leveling'; import { notifyUser } from '@/notifications'; import type { ChatInputCommandInteraction, CommandInteraction, ModalSubmitInteraction } from 'discord.js'; @@ -84,7 +84,7 @@ export async function banHandler(interaction: CommandInteraction | ModalSubmitIn iconURL: guild.iconURL()! }); - await sendEventLogMessage(guild, null, eventLogEmbed); + await sendModLogMessage(guild, null, eventLogEmbed); const { count, rows } = await Ban.findAndCountAll({ where: { diff --git a/src/commands/kick.ts b/src/commands/kick.ts index 609cb72..d94b26c 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -2,7 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder } from '@discordjs/builders'; import { Kick } from '@/models/kicks'; import { Ban } from '@/models/bans'; -import { banMessageDeleteChoices, ordinal, sendEventLogMessage } from '@/util'; +import { banMessageDeleteChoices, ordinal, sendModLogMessage } from '@/util'; import { untrustUser } from '@/leveling'; import { notifyUser } from '@/notifications'; import type { ChatInputCommandInteraction, CommandInteraction, ModalSubmitInteraction } from 'discord.js'; @@ -154,7 +154,7 @@ export async function kickHandler(interaction: CommandInteraction | ModalSubmitI sendMemberEmbeds.push(kickEmbed); } - await sendEventLogMessage(guild, null, eventLogEmbed); + await sendModLogMessage(guild, null, eventLogEmbed); if (count > 0) { const pastKicksEmbed = new EmbedBuilder(); diff --git a/src/commands/settings.ts b/src/commands/settings.ts index 3219d8f..8edc49f 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -18,6 +18,7 @@ const editableOptions = [ 'channels.nsfw-logs', 'channels.event-logs', 'channels.event-logs.blacklist', + 'channels.mod-logs', 'channels.matchmaking', 'channels.notifications', 'matchmaking.lock-timeout-seconds', diff --git a/src/commands/slow-mode.ts b/src/commands/slow-mode.ts index 184a3a9..5b3887c 100644 --- a/src/commands/slow-mode.ts +++ b/src/commands/slow-mode.ts @@ -1,7 +1,7 @@ import { SlowMode, SlowModeStage } from '@/models/slow-mode'; import handleSlowMode from '@/slow-mode'; import { SlashCommandBuilder } from '@discordjs/builders'; -import { sendEventLogMessage } from '@/util'; +import { sendModLogMessage } from '@/util'; import { ChannelType, EmbedBuilder } from 'discord.js'; import type { GuildTextBasedChannel, ChatInputCommandInteraction } from 'discord.js'; @@ -116,8 +116,8 @@ async function setStageHandler(interaction: ChatInputCommandInteraction): Promis } ]); } - - await sendEventLogMessage(interaction.guild!, channel.id, auditLogEmbed); + + await sendModLogMessage(interaction.guild!, channel.id, auditLogEmbed); await interaction.followUp({ content: `Set a limit of 1 message every ${limit} seconds above ${threshold} messages per minute on <#${channel.id}>` }); } @@ -190,8 +190,8 @@ async function unsetStageHandler(interaction: ChatInputCommandInteraction): Prom } ]); } - - await sendEventLogMessage(interaction.guild!, channel.id, auditLogEmbed); + + await sendModLogMessage(interaction.guild!, channel.id, auditLogEmbed); await interaction.followUp({ content: `Unset the limit at ${threshold} messages per minute on <#${channel.id}>` }); } @@ -270,7 +270,7 @@ async function enableAutoSlowModeHandler(interaction: ChatInputCommandInteractio ]); } - await sendEventLogMessage(interaction.guild!, channel.id, auditLogEmbed); + await sendModLogMessage(interaction.guild!, channel.id, auditLogEmbed); await interaction.followUp({ content: `Auto slow mode enabled for <#${channel.id}>` }); } @@ -324,7 +324,7 @@ async function enableStaticSlowModeHandler(interaction: ChatInputCommandInteract iconURL: interaction.guild!.iconURL()! }); - await sendEventLogMessage(interaction.guild!, channel.id, auditLogEmbed); + await sendModLogMessage(interaction.guild!, channel.id, auditLogEmbed); await interaction.followUp({ content: `Static slow mode enabled for <#${channel.id}>` }); } @@ -376,7 +376,7 @@ async function disableSlowModeHandler(interaction: ChatInputCommandInteraction): iconURL: interaction.guild!.iconURL()! }); - await sendEventLogMessage(interaction.guild!, channel.id, auditLogEmbed); + await sendModLogMessage(interaction.guild!, channel.id, auditLogEmbed); await interaction.followUp({ content: `Slow mode disabled for <#${channel.id}>` }); } diff --git a/src/commands/warn.ts b/src/commands/warn.ts index 091ec22..ceb0ec7 100644 --- a/src/commands/warn.ts +++ b/src/commands/warn.ts @@ -3,7 +3,7 @@ import { SlashCommandBuilder } from '@discordjs/builders'; import { Warning } from '@/models/warnings'; import { Kick } from '@/models/kicks'; import { Ban } from '@/models/bans'; -import { ordinal, sendEventLogMessage } from '@/util'; +import { ordinal, sendModLogMessage } from '@/util'; import { untrustUser } from '@/leveling'; import { notifyUser } from '@/notifications'; import type { ChatInputCommandInteraction, CommandInteraction, ModalSubmitInteraction } from 'discord.js'; @@ -156,7 +156,7 @@ export async function warnHandler(interaction: CommandInteraction | ModalSubmitI isBan = true; } - await sendEventLogMessage(guild, null, eventLogEmbed); + await sendModLogMessage(guild, null, eventLogEmbed); if (punishmentEmbed) { const pastWarningsEmbed = new EmbedBuilder(); diff --git a/src/events/guildAuditLogEntryCreate.ts b/src/events/guildAuditLogEntryCreate.ts index edb8591..dc0d4da 100644 --- a/src/events/guildAuditLogEntryCreate.ts +++ b/src/events/guildAuditLogEntryCreate.ts @@ -1,5 +1,5 @@ import { AuditLogEvent, EmbedBuilder } from 'discord.js'; -import { sendEventLogMessage } from '@/util'; +import { sendEventLogMessage, sendModLogMessage } from '@/util'; import type { Guild, User, GuildAuditLogsEntry } from 'discord.js'; export default async function guildAuditLogEntryCreateHandler(auditLogEntry: GuildAuditLogsEntry, guild: Guild): Promise { @@ -76,7 +76,7 @@ async function handleMemberTimedOut(guild: Guild, user: User, executor: User, re } ); - await sendEventLogMessage(guild, null, embed); + await sendModLogMessage(guild, null, embed); } async function handleMemberNicknameChange(guild: Guild, user: User, oldName?: string, newName?: string): Promise { @@ -160,7 +160,7 @@ async function handleMemberKick(auditLogEntry: GuildAuditLogsEntry, guild: Guild): Promise { @@ -208,7 +208,7 @@ async function handleMemberBanAdd(auditLogEntry: GuildAuditLogsEntry(log: GuildAuditLogsEntry, eventType: EventType): log is GuildAuditLogsEntry { diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index ace2236..82e3263 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -1,5 +1,5 @@ -import { BaseGuildTextChannel, EmbedBuilder } from 'discord.js'; -import { sendEventLogMessage } from '@/util'; +import { AuditLogEvent, BaseGuildTextChannel, EmbedBuilder } from 'discord.js'; +import { sendEventLogMessage, sendModLogMessage } from '@/util'; import type { Message, PartialMessage } from 'discord.js'; export default async function messageDeleteHandler(message: Message | PartialMessage): Promise { @@ -12,8 +12,6 @@ export default async function messageDeleteHandler(message: Message | PartialMes } const guild = await message.guild!.fetch(); - const member = await message.member.fetch(); - const user = member.user; let messageContent = message.content.length > 1024 ? message.content.substring(0, 1023) + '…' : message.content; if (messageContent === '') { @@ -25,21 +23,33 @@ export default async function messageDeleteHandler(message: Message | PartialMes channelName = message.channel.name; } + // * Audit logs will not show up at the same time the message delete event + await new Promise(resolve => setTimeout(resolve, 1000)); + + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.MessageDelete, + limit: 5 + }); + const auditLogEntry = auditLogs.entries.find(entry => + entry.target.id === message.author.id && + entry.extra.channel.id === message.channelId && + Date.now() - entry.createdTimestamp < 5 * 60 * 1000 + ); + // * This large time range is needed because message delete audit log entries are combined if multiple deletions are + // * performed with the same target user, executor, and channel within 5 minutes. Decreasing this time range would + // * create a risk of false negatives because the combined entry keeps the timestamp of the first deletion. + // ! Edge case: If a user deletes their own message after a moderator deletes one of theirs within 5 minutes and in + // ! the same channel, there will be a false positive mod log entry. + + const isDeletedByModerator = auditLogEntry !== undefined; + const eventLogEmbed = new EmbedBuilder(); eventLogEmbed.setColor(0xC0C0C0); eventLogEmbed.setTitle('Event Type: _Message Delete_'); eventLogEmbed.setDescription('――――――――――――――――――――――――――――――――――'); eventLogEmbed.setTimestamp(Date.now()); - eventLogEmbed.setFields( - { - name: 'User', - value: `<@${user.id}>` - }, - { - name: 'User ID', - value: user.id - }, + eventLogEmbed.addFields( { name: 'Author', value: `<@${message.author.id}>` @@ -47,24 +57,42 @@ export default async function messageDeleteHandler(message: Message | PartialMes { name: 'Author ID', value: message.author.id + }); + + if (isDeletedByModerator) { + const executor = auditLogEntry.executor; + + eventLogEmbed.addFields({ + name: 'Executor', + value: executor ? `<@${executor.id}>` : 'Unknown' }, { - name: 'Channel Tag', - value: `<#${message.channelId}>` - }, - { - name: 'Channel Name', - value: channelName - }, - { - name: 'Message', - value: messageContent - } - ); + name: 'Executor ID', + value: executor?.id ?? 'Unknown' + }); + } + + eventLogEmbed.addFields({ + name: 'Channel Tag', + value: `<#${message.channelId}>` + }, + { + name: 'Channel Name', + value: channelName + }, + { + name: 'Message', + value: messageContent + }); + eventLogEmbed.setFooter({ text: 'Pretendo Network', iconURL: guild.iconURL()! }); - await sendEventLogMessage(guild, message.channelId, eventLogEmbed); + if (isDeletedByModerator) { + await sendModLogMessage(guild, message.channelId, eventLogEmbed); + } else { + await sendEventLogMessage(guild, message.channelId, eventLogEmbed); + } } diff --git a/src/leveling.ts b/src/leveling.ts index 31d5cea..88051fe 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -1,7 +1,7 @@ import { EmbedBuilder } from 'discord.js'; import { getDB, getDBList } from '@/db'; import { User } from '@/models/users'; -import { getRoleFromSettings, sendEventLogMessage } from '@/util'; +import { getRoleFromSettings, sendEventLogMessage, sendModLogMessage } from '@/util'; import { sequelize } from '@/sequelize-instance'; import { notifyUser } from '@/notifications'; import type { GuildMember, Message } from 'discord.js'; @@ -214,5 +214,5 @@ export async function untrustUser(member: GuildMember, newStartDate: Date): Prom text: 'Pretendo Network', iconURL: member.guild.iconURL()! }); - await sendEventLogMessage(member.guild, null, eventLogEmbed); + await sendModLogMessage(member.guild, null, eventLogEmbed); } diff --git a/src/util.ts b/src/util.ts index f46b8be..47d826f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -30,9 +30,9 @@ export function ordinal(number: number): string { return (number + suffix); } -export async function sendEventLogMessage(guild: Guild, originId: string | null, embed: EmbedBuilder, content?: string): Promise { - const blacklistedIds = getDBList('channels.event-logs.blacklist'); - if (originId && blacklistedIds.includes(originId)) { +export async function sendEventLogMessage(guild: Guild, originID: string | null, embed: EmbedBuilder, content?: string): Promise { + const blacklistedIDs = getDBList('channels.event-logs.blacklist'); + if (originID && blacklistedIDs.includes(originID)) { return null; } @@ -45,6 +45,21 @@ export async function sendEventLogMessage(guild: Guild, originId: string | null, return logChannel.send({ content, embeds: [embed] }); } +export async function sendModLogMessage(guild: Guild, originID: string | null, embed: EmbedBuilder, content?: string): Promise { + const blacklistedIDs = getDBList('channels.event-logs.blacklist'); + if (originID && blacklistedIDs.includes(originID)) { + return null; + } + + const logChannel = await getChannelFromSettings(guild, 'channels.mod-logs'); + if (!logChannel || logChannel.type !== ChannelType.GuildText) { + console.log('Missing mod log channel!'); + return null; + } + + return logChannel.send({ content, embeds: [embed] }); +} + export async function getChannelFromSettings(guild: Guild, channelName: string): Promise { const channelID = getDB().get(channelName); if (!channelID) {