Skip to content

Commit 2ca7045

Browse files
vcarlclaude
andcommitted
Add automod event logging to user mod threads
- Add AutoModerationExecution intent to Discord client - Handle AutoModerationActionExecution events in automod.ts - Create reportAutomod() function for cases where message isn't available - Modify escalationControls() to accept userId string directly - Add automod enum value to ReportReasons When automod triggers, the bot now logs the action to the user's mod thread. If the message is still available, it uses the full reportUser() flow. If blocked/deleted by automod, it uses a fallback that logs available context (rule name, matched content, action type). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5b1daf0 commit 2ca7045

File tree

6 files changed

+311
-4
lines changed

6 files changed

+311
-4
lines changed

app/discord/automod.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { Events, type Client } from "discord.js";
1+
import {
2+
AutoModerationActionType,
3+
Events,
4+
type AutoModerationActionExecution,
5+
type Client,
6+
} from "discord.js";
27

38
import { isStaff } from "#~/helpers/discord";
49
import { isSpam } from "#~/helpers/isSpam";
510
import { featureStats } from "#~/helpers/metrics";
6-
import { reportUser } from "#~/helpers/modLog";
11+
import { reportAutomod, reportUser } from "#~/helpers/modLog";
12+
import { log } from "#~/helpers/observability";
713
import {
814
markMessageAsDeleted,
915
ReportReasons,
@@ -13,7 +19,99 @@ import { client } from "./client.server";
1319

1420
const AUTO_SPAM_THRESHOLD = 3;
1521

22+
async function handleAutomodAction(execution: AutoModerationActionExecution) {
23+
const {
24+
guild,
25+
userId,
26+
channelId,
27+
messageId,
28+
content,
29+
action,
30+
matchedContent,
31+
matchedKeyword,
32+
autoModerationRule,
33+
} = execution;
34+
35+
// Only log actions that actually affected a message (BlockMessage, SendAlertMessage)
36+
// Skip Timeout actions as they don't have associated message content
37+
if (action.type === AutoModerationActionType.Timeout) {
38+
log("debug", "Automod", "Skipping timeout action (no message to log)", {
39+
userId,
40+
guildId: guild.id,
41+
ruleId: autoModerationRule?.name,
42+
});
43+
return;
44+
}
45+
46+
log("info", "Automod", "Automod action executed", {
47+
userId,
48+
guildId: guild.id,
49+
channelId,
50+
messageId,
51+
actionType: action.type,
52+
ruleName: autoModerationRule?.name,
53+
matchedKeyword,
54+
});
55+
56+
// Try to fetch the message if we have a messageId
57+
// The message may have been deleted by automod before we can fetch it
58+
if (messageId && channelId) {
59+
try {
60+
const channel = await guild.channels.fetch(channelId);
61+
if (channel?.isTextBased() && "messages" in channel) {
62+
const message = await channel.messages.fetch(messageId);
63+
// We have the full message, use reportUser
64+
await reportUser({
65+
reason: ReportReasons.automod,
66+
message,
67+
staff: client.user ?? false,
68+
extra: `Rule: ${autoModerationRule?.name ?? "Unknown"}\nMatched: ${matchedKeyword ?? matchedContent ?? "Unknown"}`,
69+
});
70+
return;
71+
}
72+
} catch (e) {
73+
log(
74+
"debug",
75+
"Automod",
76+
"Could not fetch message, using fallback logging",
77+
{
78+
messageId,
79+
error: e instanceof Error ? e.message : String(e),
80+
},
81+
);
82+
}
83+
}
84+
85+
// Fallback: message was blocked/deleted or we couldn't fetch it
86+
// Use reportAutomod which doesn't require a Message object
87+
const user = await guild.client.users.fetch(userId);
88+
await reportAutomod({
89+
guild,
90+
user,
91+
content: content ?? matchedContent ?? "[Content not available]",
92+
channelId: channelId ?? undefined,
93+
messageId: messageId ?? undefined,
94+
ruleName: autoModerationRule?.name ?? "Unknown rule",
95+
matchedKeyword: matchedKeyword ?? matchedContent ?? undefined,
96+
actionType: action.type,
97+
});
98+
}
99+
16100
export default async (bot: Client) => {
101+
// Handle Discord's built-in automod actions
102+
bot.on(Events.AutoModerationActionExecution, async (execution) => {
103+
try {
104+
await handleAutomodAction(execution);
105+
} catch (e) {
106+
log("error", "Automod", "Failed to handle automod action", {
107+
error: e instanceof Error ? e.message : String(e),
108+
userId: execution.userId,
109+
guildId: execution.guild.id,
110+
});
111+
}
112+
});
113+
114+
// Handle our custom spam detection
17115
bot.on(Events.MessageCreate, async (msg) => {
18116
if (msg.author.id === bot.user?.id || !msg.guild) return;
19117

app/discord/client.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const client = new Client({
1313
GatewayIntentBits.GuildMessageReactions,
1414
GatewayIntentBits.DirectMessages,
1515
GatewayIntentBits.DirectMessageReactions,
16+
GatewayIntentBits.AutoModerationExecution,
1617
],
1718
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
1819
});

app/helpers/escalate.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import {
77
} from "discord.js";
88

99
export async function escalationControls(
10-
reportedMessage: Message,
10+
reportedMessageOrUserId: Message | string,
1111
thread: ThreadChannel,
1212
) {
13-
const reportedUserId = reportedMessage.author.id;
13+
const reportedUserId =
14+
typeof reportedMessageOrUserId === "string"
15+
? reportedMessageOrUserId
16+
: reportedMessageOrUserId.author.id;
1417

1518
await thread.send({
1619
content: "Moderator controls",

app/helpers/modLog.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { formatDistanceToNowStrict } from "date-fns";
22
import {
3+
AutoModerationActionType,
34
ChannelType,
45
messageLink,
56
MessageReferenceType,
67
type AnyThreadChannel,
78
type APIEmbed,
9+
type Guild,
810
type Message,
911
type MessageCreateOptions,
1012
type TextChannel,
@@ -45,6 +47,7 @@ const ReadableReasons: Record<ReportReasons, string> = {
4547
[ReportReasons.track]: "tracked",
4648
[ReportReasons.modResolution]: "Mod vote resolved",
4749
[ReportReasons.spam]: "detected as spam",
50+
[ReportReasons.automod]: "detected by automod",
4851
};
4952

5053
const isForwardedMessage = (message: Message): boolean => {
@@ -118,6 +121,167 @@ const getOrCreateUserThread = async (message: Message, user: User) => {
118121
return thread;
119122
};
120123

124+
export interface AutomodReport {
125+
guild: Guild;
126+
user: User;
127+
content: string;
128+
channelId?: string;
129+
messageId?: string;
130+
ruleName: string;
131+
matchedKeyword?: string;
132+
actionType: AutoModerationActionType;
133+
}
134+
135+
const ActionTypeLabels: Record<AutoModerationActionType, string> = {
136+
[AutoModerationActionType.BlockMessage]: "blocked message",
137+
[AutoModerationActionType.SendAlertMessage]: "sent alert",
138+
[AutoModerationActionType.Timeout]: "timed out user",
139+
[AutoModerationActionType.BlockMemberInteraction]: "blocked interaction",
140+
};
141+
142+
const getOrCreateUserThreadForAutomod = async (guild: Guild, user: User) => {
143+
// Check if we already have a thread for this user
144+
const existingThread = await getUserThread(user.id, guild.id);
145+
146+
if (existingThread) {
147+
try {
148+
// Verify the thread still exists and is accessible
149+
const thread = await guild.channels.fetch(existingThread.thread_id);
150+
if (thread?.isThread()) {
151+
return thread;
152+
}
153+
} catch (error) {
154+
log(
155+
"warn",
156+
"getOrCreateUserThreadForAutomod",
157+
"Existing thread not accessible, will create new one",
158+
{ error },
159+
);
160+
}
161+
}
162+
163+
// Create new thread and store in database
164+
const { modLog: modLogId } = await fetchSettings(guild.id, [SETTINGS.modLog]);
165+
const modLog = await guild.channels.fetch(modLogId);
166+
if (!modLog || modLog.type !== ChannelType.GuildText) {
167+
throw new Error("Invalid mod log channel");
168+
}
169+
170+
// Create freestanding private thread
171+
const thread = await makeUserThread(modLog, user);
172+
await escalationControls(user.id, thread);
173+
174+
// Store or update the thread reference
175+
if (existingThread) {
176+
await updateUserThread(user.id, guild.id, thread.id);
177+
} else {
178+
await createUserThread(user.id, guild.id, thread.id);
179+
}
180+
181+
return thread;
182+
};
183+
184+
/**
185+
* Reports an automod action when we don't have a full Message object.
186+
* Used when Discord's automod blocks/deletes a message before we can fetch it.
187+
*/
188+
export const reportAutomod = async ({
189+
guild,
190+
user,
191+
content,
192+
channelId,
193+
messageId,
194+
ruleName,
195+
matchedKeyword,
196+
actionType,
197+
}: AutomodReport): Promise<void> => {
198+
log("info", "reportAutomod", `Automod triggered for ${user.username}`, {
199+
userId: user.id,
200+
guildId: guild.id,
201+
ruleName,
202+
actionType,
203+
});
204+
205+
// Get or create persistent user thread
206+
const thread = await getOrCreateUserThreadForAutomod(guild, user);
207+
208+
// Get mod log for forwarding
209+
const { modLog, moderator } = await fetchSettings(guild.id, [
210+
SETTINGS.modLog,
211+
SETTINGS.moderator,
212+
]);
213+
214+
// Construct the log message
215+
const channelMention = channelId ? `<#${channelId}>` : "Unknown channel";
216+
const actionLabel = ActionTypeLabels[actionType] ?? "took action";
217+
218+
const logContent = truncateMessage(`**Automod ${actionLabel}**
219+
<@${user.id}> (${user.username}) in ${channelMention}
220+
-# Rule: ${ruleName}${matchedKeyword ? ` · Matched: \`${matchedKeyword}\`` : ""}`).trim();
221+
222+
// Send log to thread
223+
const [logMessage] = await Promise.all([
224+
thread.send({
225+
content: logContent,
226+
allowedMentions: { roles: moderator ? [moderator] : [] },
227+
}),
228+
thread.send({
229+
content: quoteAndEscape(content).trim(),
230+
allowedMentions: {},
231+
}),
232+
]);
233+
234+
// Record to database if we have a messageId
235+
if (messageId) {
236+
await retry(3, async () => {
237+
const result = await recordReport({
238+
reportedMessageId: messageId,
239+
reportedChannelId: channelId ?? "unknown",
240+
reportedUserId: user.id,
241+
guildId: guild.id,
242+
logMessageId: logMessage.id,
243+
logChannelId: thread.id,
244+
reason: ReportReasons.automod,
245+
extra: `Rule: ${ruleName}`,
246+
});
247+
248+
if (!result.wasInserted) {
249+
log(
250+
"warn",
251+
"reportAutomod",
252+
"duplicate detected at database level, retrying check",
253+
);
254+
throw new Error("Race condition detected in recordReport, retrying…");
255+
}
256+
257+
return result;
258+
});
259+
}
260+
261+
// Forward to mod log
262+
await logMessage.forward(modLog).catch((e) => {
263+
log("error", "reportAutomod", "failed to forward to modLog", { error: e });
264+
});
265+
266+
// Send summary to parent channel
267+
if (thread.parent?.isSendable()) {
268+
const singleLine = content.slice(0, 80).replaceAll("\n", "\\n ");
269+
const truncatedContent =
270+
singleLine.length > 80 ? `${singleLine.slice(0, 80)}…` : singleLine;
271+
272+
await thread.parent
273+
.send({
274+
allowedMentions: {},
275+
content: `> ${escapeDisruptiveContent(truncatedContent)}\n-# [Automod: ${ruleName}](${messageLink(logMessage.channelId, logMessage.id)})`,
276+
})
277+
.catch((e) => {
278+
log("error", "reportAutomod", "failed to send summary to parent", {
279+
error: e,
280+
});
281+
});
282+
}
283+
};
284+
121285
// const warningMessages = new ();
122286
export const reportUser = async ({
123287
reason,

app/models/reportedMessages.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const enum ReportReasons {
1818
track = "track",
1919
modResolution = "modResolution",
2020
spam = "spam",
21+
automod = "automod",
2122
}
2223

2324
export async function recordReport(data: {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Automod Event Logging
2+
3+
Added functionality to log Discord's built-in automod trigger events to user mod threads.
4+
5+
## Changes
6+
7+
### Client Intent
8+
Added `GatewayIntentBits.AutoModerationExecution` to `client.server.ts` to receive automod events.
9+
10+
### Event Handler (`automod.ts`)
11+
- Added handler for `Events.AutoModerationActionExecution`
12+
- Skips `Timeout` actions (no message content to log)
13+
- Tries to fetch the message if `messageId` exists
14+
- If successful: uses existing `reportUser()` with `ReportReasons.automod`
15+
- If failed (message blocked/deleted): uses new `reportAutomod()` fallback
16+
17+
### New Function (`modLog.ts`)
18+
Created `reportAutomod()` for cases where we don't have a full `Message` object:
19+
- Gets/creates user thread (reusing pattern from `getOrCreateUserThread`)
20+
- Logs automod-specific info: rule name, matched keyword, action type
21+
- Records to database if `messageId` available
22+
- Forwards to mod log and sends summary to parent channel
23+
24+
Also modified `escalationControls()` in `escalate.tsx` to accept either a `Message` or just a `userId` string.
25+
26+
## Design Decisions
27+
28+
1. **Two-path approach**: Try to fetch the message first for full context, fallback to minimal logging if unavailable. This maximizes information captured.
29+
30+
2. **Skip Timeout actions**: These don't have associated message content worth logging. The timeout itself is visible in Discord's audit log.
31+
32+
3. **No MESSAGE_CONTENT intent**: The `content` field in automod events requires privileged intent. We work with what's available (`matchedContent`, `matchedKeyword`).
33+
34+
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.
35+
36+
## Related Files
37+
- `app/discord/client.server.ts` - intent added
38+
- `app/discord/automod.ts` - event handler
39+
- `app/helpers/modLog.ts` - `reportAutomod()` function
40+
- `app/helpers/escalate.tsx` - signature update

0 commit comments

Comments
 (0)