From 70f7153e597a4a6993e67cc6501d673fd5408079 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 16 Jan 2026 15:51:14 -0500 Subject: [PATCH 1/5] Move reaction list to quoted message instead of report message --- app/discord/automod.ts | 2 +- app/helpers/discord.ts | 27 +++++++++++---------------- app/helpers/modLog.ts | 35 +++++++++++++++++------------------ 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/app/discord/automod.ts b/app/discord/automod.ts index f2bc6560..e7fc25eb 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/helpers/discord.ts b/app/helpers/discord.ts index a667744c..5c213f22 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 5693653b..9f586753 100644 --- a/app/helpers/modLog.ts +++ b/app/helpers/modLog.ts @@ -277,6 +277,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 +294,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 @@ -368,14 +382,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, }; }; @@ -399,8 +408,7 @@ const constructLog = async ({ 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)" : ""; @@ -409,18 +417,9 @@ const constructLog = async ({ })${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, allowedMentions: { roles: [moderator] }, }; }; From b7150ad4c41f9a7943365acae387129c6b47af23 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 16 Jan 2026 18:21:03 -0500 Subject: [PATCH 2/5] Include reaction/embed stats in tiny preview --- app/helpers/modLog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/modLog.ts b/app/helpers/modLog.ts index 9f586753..f050073f 100644 --- a/app/helpers/modLog.ts +++ b/app/helpers/modLog.ts @@ -349,7 +349,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 From e93f553873bafb280e86019589dd79dc745a1b9d Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 16 Jan 2026 18:22:36 -0500 Subject: [PATCH 3/5] Move report attribution to its own line --- app/helpers/modLog.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/helpers/modLog.ts b/app/helpers/modLog.ts index f050073f..9c97d080 100644 --- a/app/helpers/modLog.ts +++ b/app/helpers/modLog.ts @@ -398,10 +398,11 @@ 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) { @@ -412,14 +413,15 @@ const constructLog = async ({ // 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` : ""; return { content: truncateMessage(`${preface} --# ${extra}${formatDistanceToNowStrict(lastReport.message.createdAt)} ago · · ${report}`).trim(), +-# ${report} +-# ${extra}${formatDistanceToNowStrict(lastReport.message.createdAt)} ago · `).trim(), allowedMentions: { roles: [moderator] }, }; }; From 1dab9991250bf4024389766185f40d4ecf140391 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 17 Jan 2026 00:06:59 -0500 Subject: [PATCH 4/5] Add kick/ban event logging to user mod threads Listen for GuildBanAdd and GuildMemberRemove events to log external moderation actions (kicks/bans performed via Discord UI) to user threads. Uses audit log to identify executor and distinguish kicks from voluntary leaves. Co-Authored-By: Claude Opus 4.5 --- app/discord/client.server.ts | 1 + app/discord/gateway.ts | 2 + app/discord/modActionLogger.ts | 214 +++++++++++++++++++++++++++++++++ app/helpers/modLog.ts | 87 ++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 app/discord/modActionLogger.ts diff --git a/app/discord/client.server.ts b/app/discord/client.server.ts index ffa98be0..e7b7a50e 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 3c853e5c..699ae219 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 00000000..1a2c789a --- /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; + + // 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/modLog.ts b/app/helpers/modLog.ts index 9c97d080..56857a23 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,89 @@ 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 logContent = truncateMessage( + `<@${user.id}> (${user.username}) ${actionLabel} +-# ${executorMention} ${reason ?? "for no reason"} `, + ).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, From 92769a0fe7a69d79ab476b440a20fb7a75117ecc Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sat, 17 Jan 2026 00:40:54 -0500 Subject: [PATCH 5/5] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/discord/modActionLogger.ts | 2 +- app/helpers/modLog.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/discord/modActionLogger.ts b/app/discord/modActionLogger.ts index 1a2c789a..895c2ca8 100644 --- a/app/discord/modActionLogger.ts +++ b/app/discord/modActionLogger.ts @@ -41,7 +41,7 @@ async function handleBanAdd(ban: GuildBan) { ); executor = banEntry?.executor ?? null; - reason = banEntry?.reason; + reason = banEntry?.reason ?? reason; // Skip if the bot performed this action (it's already logged elsewhere) if (executor?.id === guild.client.user?.id) { diff --git a/app/helpers/modLog.ts b/app/helpers/modLog.ts index 56857a23..7db08698 100644 --- a/app/helpers/modLog.ts +++ b/app/helpers/modLog.ts @@ -254,9 +254,11 @@ export const reportModAction = async ({ ? ` by <@${executor.id}> (${executor.username})` : " by unknown"; + const reasonText = reason ? ` ${reason}` : " for no reason"; + const logContent = truncateMessage( `<@${user.id}> (${user.username}) ${actionLabel} --# ${executorMention} ${reason ?? "for no reason"} `, +-# ${executorMention}${reasonText} `, ).trim(); // Send log to thread