diff --git a/app/discord/automod.ts b/app/discord/automod.ts index f2bc656..e7fc25e 100644 --- a/app/discord/automod.ts +++ b/app/discord/automod.ts @@ -34,7 +34,7 @@ async function handleAutomodAction(execution: AutoModerationActionExecution) { // Only log actions that actually affected a message if (action.type === AutoModerationActionType.Timeout) { - log("debug", "Automod", "Skipping timeout action (no message to log)", { + log("info", "Automod", "Skipping timeout action (no message to log)", { userId, guildId: guild.id, ruleId: autoModerationRule?.name, diff --git a/app/discord/client.server.ts b/app/discord/client.server.ts index ffa98be..e7b7a50 100644 --- a/app/discord/client.server.ts +++ b/app/discord/client.server.ts @@ -11,6 +11,7 @@ export const client = new Client({ GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildModeration, GatewayIntentBits.DirectMessages, GatewayIntentBits.DirectMessageReactions, GatewayIntentBits.AutoModerationExecution, diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 3c853e5..699ae21 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -5,6 +5,7 @@ import automod from "#~/discord/automod"; import { client, login } from "#~/discord/client.server"; import { deployCommands } from "#~/discord/deployCommands.server"; import { startEscalationResolver } from "#~/discord/escalationResolver"; +import modActionLogger from "#~/discord/modActionLogger"; import onboardGuild from "#~/discord/onboardGuild"; import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; import { botStats, shutdownMetrics } from "#~/helpers/metrics"; @@ -59,6 +60,7 @@ export default function init() { await Promise.all([ onboardGuild(client), automod(client), + modActionLogger(client), deployCommands(client), startActivityTracking(client), startHoneypotTracking(client), diff --git a/app/discord/modActionLogger.ts b/app/discord/modActionLogger.ts new file mode 100644 index 0000000..895c2ca --- /dev/null +++ b/app/discord/modActionLogger.ts @@ -0,0 +1,214 @@ +import { + AuditLogEvent, + Events, + type Client, + type Guild, + type GuildBan, + type GuildMember, + type PartialGuildMember, + type PartialUser, + type User, +} from "discord.js"; + +import { reportModAction, type ModActionReport } from "#~/helpers/modLog"; +import { log } from "#~/helpers/observability"; + +// Time window to check audit log for matching entries (5 seconds) +const AUDIT_LOG_WINDOW_MS = 5000; + +async function handleBanAdd(ban: GuildBan) { + const { guild, user } = ban; + let { reason } = ban; + let executor: User | PartialUser | null = null; + + log("info", "ModActionLogger", "Ban detected", { + userId: user.id, + guildId: guild.id, + reason, + }); + + try { + // Check audit log for who performed the ban + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.MemberBanAdd, + limit: 5, + }); + + const banEntry = auditLogs.entries.find( + (entry) => + entry.target?.id === user.id && + Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, + ); + + executor = banEntry?.executor ?? null; + reason = banEntry?.reason ?? reason; + + // Skip if the bot performed this action (it's already logged elsewhere) + if (executor?.id === guild.client.user?.id) { + log("debug", "ModActionLogger", "Skipping self-ban", { + userId: user.id, + guildId: guild.id, + }); + return; + } + } catch (error) { + // If we can't access audit log, still log the ban but without executor info + if ( + error instanceof Error && + error.message.includes("Missing Permissions") + ) { + log( + "warn", + "ModActionLogger", + "Cannot access audit log for ban details", + { userId: user.id, guildId: guild.id }, + ); + } else { + log("error", "ModActionLogger", "Failed to fetch audit log for ban", { + userId: user.id, + guildId: guild.id, + error, + }); + } + } + + try { + await reportModAction({ + guild, + user, + actionType: "ban", + executor, + reason: reason ?? "", + }); + } catch (error) { + log("error", "ModActionLogger", "Failed to report ban", { + userId: user.id, + guildId: guild.id, + error, + }); + } +} + +async function fetchAuditLogs( + guild: Guild, + user: User, +): Promise { + // Check audit log to distinguish kick from voluntary leave + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.MemberKick, + limit: 5, + }); + + const kickEntry = auditLogs.entries.find( + (entry) => + entry.target?.id === user.id && + Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS, + ); + + // If no kick entry found, user left voluntarily + if (!kickEntry) { + log( + "debug", + "ModActionLogger", + "No kick entry found, user left voluntarily", + { userId: user.id, guildId: guild.id }, + ); + return { + actionType: "left", + user, + guild, + executor: undefined, + reason: undefined, + }; + } + const { executor, reason } = kickEntry; + + if (!executor) { + log( + "warn", + "ModActionLogger", + `No executor found for audit log entry ${kickEntry.id}`, + ); + } + + // Skip if the bot performed this action + // TODO: maybe best to invert — remove manual kick logs in favor of this + if (kickEntry.executor?.id === guild.client.user?.id) { + log("debug", "ModActionLogger", "Skipping self-kick", { + userId: user.id, + guildId: guild.id, + }); + return; + } + + return { actionType: "kick", user, guild, executor, reason: reason ?? "" }; +} + +async function handleMemberRemove(member: GuildMember | PartialGuildMember) { + const { guild, user } = member; + + log("info", "ModActionLogger", "Member removal detected", { + userId: user.id, + guildId: guild.id, + }); + + try { + const auditLogs = await fetchAuditLogs(guild, user); + + if (auditLogs) { + const { executor = null, reason = "" } = auditLogs; + await reportModAction({ + guild, + user, + actionType: "kick", + executor, + reason, + }); + return; + } + await reportModAction({ + guild, + user, + actionType: "left", + executor: undefined, + reason: undefined, + }); + } catch (error) { + log("error", "ModActionLogger", "Failed to handle member removal", { + userId: user.id, + guildId: guild.id, + error, + }); + } +} + +export default async (bot: Client) => { + bot.on(Events.GuildBanAdd, async (ban) => { + try { + await handleBanAdd(ban); + } catch (error) { + log("error", "ModActionLogger", "Unhandled error in ban handler", { + userId: ban.user.id, + guildId: ban.guild.id, + error, + }); + } + }); + + bot.on(Events.GuildMemberRemove, async (member) => { + try { + await handleMemberRemove(member); + } catch (error) { + log( + "error", + "ModActionLogger", + "Unhandled error in member remove handler", + { + userId: member.user?.id, + guildId: member.guild.id, + error, + }, + ); + } + }); +}; diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts index a667744..5c213f2 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -88,21 +88,17 @@ export const describeAttachments = ( return attachments.size === 0 ? undefined : { - description: - "Attachments:\n" + - attachments - .map( - ({ size, name, contentType, url }) => - // Include size of the file and the filename - `${prettyBytes(size)}: ${ - // If it's a video or image, include a link. - // Renders as `1.12mb: [some-image.jpg]()` - contentType?.match(/(image|video)/) - ? `[${name}](${url})` - : name - }`, - ) - .join("\n"), + description: attachments + .map( + ({ size, name, contentType, url }) => + // Include size of the file and the filename + `${prettyBytes(size)}: ${ + // If it's a video or image, include a link. + // Renders as `1.12mb: [some-image.jpg]()` + contentType?.match(/(image|video)/) ? `[${name}](${url})` : name + }`, + ) + .join("\n"), }; }; @@ -115,7 +111,6 @@ export const describeReactions = ( return reactions.size === 0 ? undefined : { - title: "Reactions", fields: reactions.map((r) => ({ name: "", value: `${r.count} ${ diff --git a/app/helpers/modLog.ts b/app/helpers/modLog.ts index 5693653..7db0869 100644 --- a/app/helpers/modLog.ts +++ b/app/helpers/modLog.ts @@ -9,6 +9,7 @@ import { type Guild, type Message, type MessageCreateOptions, + type PartialUser, type TextChannel, type User, } from "discord.js"; @@ -70,12 +71,14 @@ interface Reported { latestReport?: Message; } +// todo: make an effect const makeUserThread = (channel: TextChannel, user: User) => { return channel.threads.create({ name: `${user.username} logs`, }); }; +// todo: make an effect const getOrCreateUserThread = async (guild: Guild, user: User) => { if (!guild) throw new Error("Message has no guild"); @@ -141,6 +144,7 @@ const ActionTypeLabels: Record = { /** * Reports an automod action when we don't have a full Message object. * Used when Discord's automod blocks/deletes a message before we can fetch it. + * todo: make this into an effect, and don't use Discord.js classes in the api */ export const reportAutomod = async ({ guild, @@ -186,6 +190,91 @@ export const reportAutomod = async ({ }); }; +export type ModActionReport = + | { + guild: Guild; + user: User; + actionType: "kick" | "ban"; + executor: User | PartialUser | null; + reason: string; + } + | { + guild: Guild; + user: User; + actionType: "left"; + executor: undefined; + reason: undefined; + }; + +/** + * Reports a mod action (kick/ban) to the user's persistent thread. + * Used when Discord events indicate a kick or ban occurred. + */ +export const reportModAction = async ({ + guild, + user, + actionType, + executor, + reason, +}: ModActionReport): Promise => { + log( + "info", + "reportModAction", + `${actionType} detected for ${user.username}`, + { + userId: user.id, + guildId: guild.id, + actionType, + executorId: executor?.id, + reason, + }, + ); + + if (actionType === "left") { + return; + } + + // Get or create persistent user thread + const thread = await getOrCreateUserThread(guild, user); + + // Get mod log for forwarding + const { modLog, moderator } = await fetchSettings(guild.id, [ + SETTINGS.modLog, + SETTINGS.moderator, + ]); + + // Construct the log message + const actionLabels: Record = { + ban: "was banned", + kick: "was kicked", + left: "left", + }; + const actionLabel = actionLabels[actionType]; + const executorMention = executor + ? ` by <@${executor.id}> (${executor.username})` + : " by unknown"; + + const reasonText = reason ? ` ${reason}` : " for no reason"; + + const logContent = truncateMessage( + `<@${user.id}> (${user.username}) ${actionLabel} +-# ${executorMention}${reasonText} `, + ).trim(); + + // Send log to thread + const logMessage = await thread.send({ + content: logContent, + allowedMentions: { roles: [moderator] }, + }); + + // Forward to mod log + await logMessage.forward(modLog).catch((e) => { + log("error", "reportModAction", "failed to forward to modLog", { + error: e, + }); + }); +}; + // const warningMessages = new (); export const reportUser = async ({ reason, @@ -277,6 +366,16 @@ export const reportUser = async ({ staff, }); + // For forwarded messages, get attachments from the snapshot + const attachments = isForwardedMessage(message) + ? (message.messageSnapshots.first()?.attachments ?? message.attachments) + : message.attachments; + + const embeds = [ + describeAttachments(attachments), + describeReactions(message.reactions.cache), + ].filter((e): e is APIEmbed => Boolean(e)); + // If it has the data for a poll, use a specialized formatting function const reportedMessage = message.poll ? quoteAndEscapePoll(message.poll) @@ -284,7 +383,11 @@ export const reportUser = async ({ // Send the detailed log message to thread const [logMessage] = await Promise.all([ thread.send(logBody), - thread.send({ content: reportedMessage, allowedMentions: {} }), + thread.send({ + content: reportedMessage, + allowedMentions: {}, + embeds: embeds.length === 0 ? undefined : embeds, + }), ]); // Try to record the report in database with retry logic @@ -335,7 +438,7 @@ export const reportUser = async ({ const stats = await getMessageStats(message); await thread.parent.send({ allowedMentions: {}, - content: `> ${escapeDisruptiveContent(truncatedMessage)}\n-# [${stats.char_count} chars in ${stats.word_count} words. ${stats.link_stats.length} links, ${stats.code_stats.reduce((count, { lines }) => count + lines, 0)} lines of code](${messageLink(logMessage.channelId, logMessage.id)})`, + content: `> ${escapeDisruptiveContent(truncatedMessage)}\n-# [${stats.char_count} chars in ${stats.word_count} words. ${stats.link_stats.length} links, ${stats.code_stats.reduce((count, { lines }) => count + lines, 0)} lines of code. ${message.attachments.size} attachments, ${message.reactions.cache.size} reactions](${messageLink(logMessage.channelId, logMessage.id)})`, }); } catch (e) { // If message was deleted or stats unavailable, send without stats @@ -368,14 +471,9 @@ export const reportUser = async ({ }; }; -const makeReportMessage = ({ message, reason, staff }: Report) => { - const embeds = [describeReactions(message.reactions.cache)].filter( - (e): e is APIEmbed => Boolean(e), - ); - +const makeReportMessage = ({ message: _, reason, staff }: Report) => { return { content: `${staff ? ` ${staff.username} ` : ""}${ReadableReasons[reason]}`, - embeds: embeds.length === 0 ? undefined : embeds, }; }; @@ -389,38 +487,30 @@ const constructLog = async ({ if (!lastReport?.message.guild) { throw new Error("Something went wrong when trying to retrieve last report"); } + const { message } = lastReport; + const { author } = message; const { moderator } = await fetchSettings(lastReport.message.guild.id, [ SETTINGS.moderator, ]); - const { message } = lastReport; // This should never be possible but we gotta satisfy types if (!moderator) { throw new Error("No role configured to be used as moderator"); } - const { content: report, embeds: reactions = [] } = - makeReportMessage(lastReport); + const { content: report } = makeReportMessage(lastReport); // Add indicator if this is forwarded content const forwardNote = isForwardedMessage(message) ? " (forwarded)" : ""; - const preface = `${constructDiscordLink(message)} by <@${lastReport.message.author.id}> (${ - lastReport.message.author.username + const preface = `${constructDiscordLink(message)} by <@${author.id}> (${ + author.username })${forwardNote}`; const extra = origExtra ? `${origExtra}\n` : ""; - // For forwarded messages, get attachments from the snapshot - const attachments = isForwardedMessage(message) - ? (message.messageSnapshots.first()?.attachments ?? message.attachments) - : message.attachments; - - const embeds = [describeAttachments(attachments), ...reactions].filter( - (e): e is APIEmbed => Boolean(e), - ); return { content: truncateMessage(`${preface} --# ${extra}${formatDistanceToNowStrict(lastReport.message.createdAt)} ago · · ${report}`).trim(), - embeds: embeds.length === 0 ? undefined : embeds, +-# ${report} +-# ${extra}${formatDistanceToNowStrict(lastReport.message.createdAt)} ago · `).trim(), allowedMentions: { roles: [moderator] }, }; };