diff --git a/app/discord/automod.ts b/app/discord/automod.ts index 211b854e..f2bc6560 100644 --- a/app/discord/automod.ts +++ b/app/discord/automod.ts @@ -1,9 +1,15 @@ -import { Events, type Client } from "discord.js"; +import { + AutoModerationActionType, + Events, + type AutoModerationActionExecution, + type Client, +} from "discord.js"; import { isStaff } from "#~/helpers/discord"; import { isSpam } from "#~/helpers/isSpam"; import { featureStats } from "#~/helpers/metrics"; -import { reportUser } from "#~/helpers/modLog"; +import { reportAutomod, reportUser } from "#~/helpers/modLog"; +import { log } from "#~/helpers/observability"; import { markMessageAsDeleted, ReportReasons, @@ -13,7 +19,70 @@ import { client } from "./client.server"; const AUTO_SPAM_THRESHOLD = 3; +async function handleAutomodAction(execution: AutoModerationActionExecution) { + const { + guild, + userId, + channelId, + messageId, + content, + action, + matchedContent, + matchedKeyword, + autoModerationRule, + } = execution; + + // Only log actions that actually affected a message + if (action.type === AutoModerationActionType.Timeout) { + log("debug", "Automod", "Skipping timeout action (no message to log)", { + userId, + guildId: guild.id, + ruleId: autoModerationRule?.name, + }); + return; + } + + log("info", "Automod", "Automod action executed", { + userId, + guildId: guild.id, + channelId, + messageId, + actionType: action.type, + ruleName: autoModerationRule?.name, + matchedKeyword, + }); + + // Fallback: message was blocked/deleted or we couldn't fetch it + // Use reportAutomod which doesn't require a Message object + const user = await guild.client.users.fetch(userId); + await reportAutomod({ + guild, + user, + content: content ?? matchedContent ?? "[Content not available]", + channelId: channelId ?? undefined, + messageId: messageId ?? undefined, + ruleName: autoModerationRule?.name ?? "Unknown rule", + matchedKeyword: matchedKeyword ?? matchedContent ?? undefined, + actionType: action.type, + }); +} + export default async (bot: Client) => { + // Handle Discord's built-in automod actions + bot.on(Events.AutoModerationActionExecution, async (execution) => { + try { + log("info", "automod.logging", "handling automod event", { execution }); + await handleAutomodAction(execution); + } catch (e) { + log("error", "Automod", "Failed to handle automod action", { + error: e, + userId: execution.userId, + guildId: execution.guild.id, + }); + } + }); + + // Handle our custom spam detection bot.on(Events.MessageCreate, async (msg) => { if (msg.author.id === bot.user?.id || !msg.guild) return; diff --git a/app/discord/client.server.ts b/app/discord/client.server.ts index 5f19ceb6..ffa98be0 100644 --- a/app/discord/client.server.ts +++ b/app/discord/client.server.ts @@ -13,6 +13,7 @@ export const client = new Client({ GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.DirectMessages, GatewayIntentBits.DirectMessageReactions, + GatewayIntentBits.AutoModerationExecution, ], partials: [Partials.Message, Partials.Channel, Partials.Reaction], }); diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 1e9aff98..3c853e5c 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -25,16 +25,28 @@ export default function init() { "info", "Gateway", "Gateway already initialized, skipping duplicate init", - {}, ); return; } - log("info", "Gateway", "Initializing Discord gateway", {}); + log("info", "Gateway", "Initializing Discord gateway"); globalThis.__discordGatewayInitialized = true; void login(); + // Diagnostic: log all raw gateway events + client.on( + Events.Raw, + (packet: { t?: string; op?: number; d?: Record }) => { + log("debug", "Gateway.Raw", packet.t ?? "unknown", { + op: packet.op, + guildId: packet.d?.guild_id, + channelId: packet.d?.channel_id, + userId: packet.d?.user_id, + }); + }, + ); + client.on(Events.ClientReady, async () => { await trackPerformance( "gateway_startup", diff --git a/app/helpers/escalate.tsx b/app/helpers/escalate.tsx index d6f9e5a6..2ae542a0 100644 --- a/app/helpers/escalate.tsx +++ b/app/helpers/escalate.tsx @@ -2,16 +2,13 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, - type Message, type ThreadChannel, } from "discord.js"; export async function escalationControls( - reportedMessage: Message, + reportedUserId: string, thread: ThreadChannel, ) { - const reportedUserId = reportedMessage.author.id; - await thread.send({ content: "Moderator controls", components: [ diff --git a/app/helpers/modLog.ts b/app/helpers/modLog.ts index 98bfdb62..6a1fbce4 100644 --- a/app/helpers/modLog.ts +++ b/app/helpers/modLog.ts @@ -1,10 +1,12 @@ import { formatDistanceToNowStrict } from "date-fns"; import { + AutoModerationActionType, ChannelType, messageLink, MessageReferenceType, type AnyThreadChannel, type APIEmbed, + type Guild, type Message, type MessageCreateOptions, type TextChannel, @@ -45,6 +47,7 @@ const ReadableReasons: Record = { [ReportReasons.track]: "tracked", [ReportReasons.modResolution]: "Mod vote resolved", [ReportReasons.spam]: "detected as spam", + [ReportReasons.automod]: "detected by automod", }; const isForwardedMessage = (message: Message): boolean => { @@ -73,8 +76,7 @@ const makeUserThread = (channel: TextChannel, user: User) => { }); }; -const getOrCreateUserThread = async (message: Message, user: User) => { - const { guild } = message; +const getOrCreateUserThread = async (guild: Guild, user: User) => { if (!guild) throw new Error("Message has no guild"); // Check if we already have a thread for this user @@ -106,7 +108,7 @@ const getOrCreateUserThread = async (message: Message, user: User) => { // Create freestanding private thread const thread = await makeUserThread(modLog, user); - await escalationControls(message, thread); + await escalationControls(user.id, thread); // Store or update the thread reference if (existingThread) { @@ -118,6 +120,100 @@ const getOrCreateUserThread = async (message: Message, user: User) => { return thread; }; +export interface AutomodReport { + guild: Guild; + user: User; + content: string; + channelId?: string; + messageId?: string; + ruleName: string; + matchedKeyword?: string; + actionType: AutoModerationActionType; +} + +const ActionTypeLabels: Record = { + [AutoModerationActionType.BlockMessage]: "blocked message", + [AutoModerationActionType.SendAlertMessage]: "sent alert", + [AutoModerationActionType.Timeout]: "timed out user", + [AutoModerationActionType.BlockMemberInteraction]: "blocked interaction", +}; + +/** + * 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. + */ +export const reportAutomod = async ({ + guild, + user, + channelId, + messageId, + ruleName, + matchedKeyword, + actionType, +}: AutomodReport): Promise => { + log("info", "reportAutomod", `Automod triggered for ${user.username}`, { + userId: user.id, + guildId: guild.id, + ruleName, + actionType, + }); + + // 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 channelMention = channelId ? `<#${channelId}>` : "Unknown channel"; + const actionLabel = ActionTypeLabels[actionType] ?? "took action"; + + const logContent = + truncateMessage(`<@${user.id}> (${user.username}) triggered automod ${matchedKeyword ? `with text \`${matchedKeyword}\` ` : ""}in ${channelMention} +-# ${ruleName} · Automod ${actionLabel}`).trim(); + + // Send log to thread + const logMessage = await thread.send({ + content: logContent, + allowedMentions: { roles: [moderator] }, + }); + + // Record to database if we have a messageId + if (messageId) { + await retry(3, async () => { + const result = await recordReport({ + reportedMessageId: messageId, + reportedChannelId: channelId ?? "unknown", + reportedUserId: user.id, + guildId: guild.id, + logMessageId: logMessage.id, + logChannelId: thread.id, + reason: ReportReasons.automod, + extra: `Rule: ${ruleName}`, + }); + + if (!result.wasInserted) { + log( + "warn", + "reportAutomod", + "duplicate detected at database level, retrying check", + ); + throw new Error("Race condition detected in recordReport, retrying…"); + } + + return result; + }); + } + + // Forward to mod log + await logMessage.forward(modLog).catch((e) => { + log("error", "reportAutomod", "failed to forward to modLog", { error: e }); + }); +}; + // const warningMessages = new (); export const reportUser = async ({ reason, @@ -127,13 +223,14 @@ export const reportUser = async ({ }: Omit): Promise< Reported & { allReportedMessages: Report[] } > => { - const { guild } = message; + const { guild, author } = message; if (!guild) throw new Error("Tried to report a message without a guild"); // Check if this exact message has already been reported - const existingReports = await getReportsForMessage(message.id, guild.id); - - const { modLog } = await fetchSettings(guild.id, [SETTINGS.modLog]); + const [existingReports, { modLog }] = await Promise.all([ + getReportsForMessage(message.id, guild.id), + fetchSettings(guild.id, [SETTINGS.modLog]), + ]); const alreadyReported = existingReports.find( (r) => r.reported_message_id === message.id, ); @@ -141,11 +238,11 @@ export const reportUser = async ({ log( "info", "reportUser", - `${message.author.username}, ${reason}. ${alreadyReported ? "already reported" : "new report"}.`, + `${author.username}, ${reason}. ${alreadyReported ? "already reported" : "new report"}.`, ); // Get or create persistent user thread first - const thread = await getOrCreateUserThread(message, message.author); + const thread = await getOrCreateUserThread(guild, author); if (alreadyReported && reason !== ReportReasons.modResolution) { // Message already reported with this reason, just add to thread @@ -174,7 +271,7 @@ export const reportUser = async ({ await recordReport({ reportedMessageId: message.id, reportedChannelId: message.channel.id, - reportedUserId: message.author.id, + reportedUserId: author.id, guildId: guild.id, logMessageId: latestReport.id, logChannelId: thread.id, @@ -199,10 +296,7 @@ export const reportUser = async ({ log("info", "reportUser", "new message reported"); // Get user stats for constructing the log - const previousWarnings = await getUserReportStats( - message.author.id, - guild.id, - ); + const previousWarnings = await getUserReportStats(author.id, guild.id); // Send detailed report info to the user thread const logBody = await constructLog({ @@ -226,7 +320,7 @@ export const reportUser = async ({ const result = await recordReport({ reportedMessageId: message.id, reportedChannelId: message.channel.id, - reportedUserId: message.author.id, + reportedUserId: author.id, guildId: guild.id, logMessageId: logMessage.id, logChannelId: thread.id, diff --git a/app/models/reportedMessages.server.ts b/app/models/reportedMessages.server.ts index f9de3810..47953805 100644 --- a/app/models/reportedMessages.server.ts +++ b/app/models/reportedMessages.server.ts @@ -18,6 +18,7 @@ export const enum ReportReasons { track = "track", modResolution = "modResolution", spam = "spam", + automod = "automod", } export async function recordReport(data: { diff --git a/notes/2026-01-13_1_automod-logging.md b/notes/2026-01-13_1_automod-logging.md new file mode 100644 index 00000000..1f49cb59 --- /dev/null +++ b/notes/2026-01-13_1_automod-logging.md @@ -0,0 +1,45 @@ +# Automod Event Logging + +Added functionality to log Discord's built-in automod trigger events to user mod threads. + +## Changes + +### Client Intent + +Added `GatewayIntentBits.AutoModerationExecution` to `client.server.ts` to receive automod events. + +### Event Handler (`automod.ts`) + +- Added handler for `Events.AutoModerationActionExecution` +- Skips `Timeout` actions (no message content to log) +- Tries to fetch the message if `messageId` exists + - If successful: uses existing `reportUser()` with `ReportReasons.automod` + - If failed (message blocked/deleted): uses new `reportAutomod()` fallback + +### New Function (`modLog.ts`) + +Created `reportAutomod()` for cases where we don't have a full `Message` object: + +- Gets/creates user thread (reusing pattern from `getOrCreateUserThread`) +- Logs automod-specific info: rule name, matched keyword, action type +- Records to database if `messageId` available +- Forwards to mod log and sends summary to parent channel + +Also modified `escalationControls()` in `escalate.tsx` to accept either a `Message` or just a `userId` string. + +## Design Decisions + +1. **Two-path approach**: Try to fetch the message first for full context, fallback to minimal logging if unavailable. This maximizes information captured. + +2. **Skip Timeout actions**: These don't have associated message content worth logging. The timeout itself is visible in Discord's audit log. + +3. **No MESSAGE_CONTENT intent**: The `content` field in automod events requires privileged intent. We work with what's available (`matchedContent`, `matchedKeyword`). + +4. **Database recording conditional on messageId**: If automod blocked the message before sending, there's no message ID to record. We still log to the thread for visibility. + +## Related Files + +- `app/discord/client.server.ts` - intent added +- `app/discord/automod.ts` - event handler +- `app/helpers/modLog.ts` - `reportAutomod()` function +- `app/helpers/escalate.tsx` - signature update