-
Notifications
You must be signed in to change notification settings - Fork 5
Add automod event logging to user mod threads #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- 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>
PR Summary
|
|
I still need to properly review and vet this, there are probably a couple of significant improvements left to be made |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds logging functionality for Discord's built-in automod trigger events to user moderation threads.
Changes:
- Added AutoModerationExecution gateway intent to receive automod events
- Implemented event handler for automod actions with two-path approach: full message logging when available, fallback logging otherwise
- Created reportAutomod() function for logging automod actions without full message objects
- Modified escalationControls() to accept either Message or userId for broader compatibility
- Added automod enum value to ReportReasons
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| notes/2026-01-13_1_automod-logging.md | Documentation of implementation approach and design decisions |
| app/models/reportedMessages.server.ts | Added automod reason enum value |
| app/helpers/modLog.ts | Implemented reportAutomod() function and helper for thread creation |
| app/helpers/escalate.tsx | Made escalationControls() accept Message or userId string |
| app/discord/client.server.ts | Added AutoModerationExecution intent |
| app/discord/automod.ts | Implemented AutoModerationActionExecution event handler |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Send summary to parent channel | ||
| if (thread.parent?.isSendable()) { | ||
| const singleLine = content.slice(0, 80).replaceAll("\n", "\\n "); |
Copilot
AI
Jan 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The content slicing logic is flawed. Line 268 already slices the content to 80 characters with content.slice(0, 80), but then line 270 checks if singleLine.length > 80 and slices again to 80 characters. Since singleLine is already at most 80 characters, the second slice is redundant and the condition will never be true. The check should be against the original content.length instead, or the logic should be restructured.
| const singleLine = content.slice(0, 80).replaceAll("\n", "\\n "); | |
| const singleLine = content.replaceAll("\n", "\\n "); |
| const getOrCreateUserThreadForAutomod = async (guild: Guild, user: User) => { | ||
| // Check if we already have a thread for this user | ||
| const existingThread = await getUserThread(user.id, guild.id); | ||
|
|
||
| if (existingThread) { | ||
| try { | ||
| // Verify the thread still exists and is accessible | ||
| const thread = await guild.channels.fetch(existingThread.thread_id); | ||
| if (thread?.isThread()) { | ||
| return thread; | ||
| } | ||
| } catch (error) { | ||
| log( | ||
| "warn", | ||
| "getOrCreateUserThreadForAutomod", | ||
| "Existing thread not accessible, will create new one", | ||
| { error }, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Create new thread and store in database | ||
| const { modLog: modLogId } = await fetchSettings(guild.id, [SETTINGS.modLog]); | ||
| const modLog = await guild.channels.fetch(modLogId); | ||
| if (!modLog || modLog.type !== ChannelType.GuildText) { | ||
| throw new Error("Invalid mod log channel"); | ||
| } | ||
|
|
||
| // Create freestanding private thread | ||
| const thread = await makeUserThread(modLog, user); | ||
| await escalationControls(user.id, thread); | ||
|
|
||
| // Store or update the thread reference | ||
| if (existingThread) { | ||
| await updateUserThread(user.id, guild.id, thread.id); | ||
| } else { | ||
| await createUserThread(user.id, guild.id, thread.id); | ||
| } | ||
|
|
||
| return thread; | ||
| }; |
Copilot
AI
Jan 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is significant code duplication between getOrCreateUserThread and getOrCreateUserThreadForAutomod. The only difference is the call to escalationControls passing different arguments (message vs user.id). Consider refactoring by extracting the common logic into a shared helper function or modifying getOrCreateUserThread to accept optional parameters to handle both cases.
| // Only log actions that actually affected a message (BlockMessage, SendAlertMessage) | ||
| // Skip Timeout actions as they don't have associated message content |
Copilot
AI
Jan 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment states "Only log actions that actually affected a message (BlockMessage, SendAlertMessage)" but the code only skips Timeout actions. BlockMemberInteraction actions will also be logged even though they may not have associated message content. Consider whether BlockMemberInteraction actions should also be skipped, or update the comment to accurately reflect what actions are being logged.
| // Only log actions that actually affected a message (BlockMessage, SendAlertMessage) | |
| // Skip Timeout actions as they don't have associated message content | |
| // Skip logging Timeout actions as they don't have associated message content. | |
| // All other automod actions are logged, even if they may not directly involve a message. |
| channelId: channelId ?? undefined, | ||
| messageId: messageId ?? undefined, |
Copilot
AI
Jan 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expression channelId ?? undefined is redundant. If channelId is already null or undefined, the nullish coalescing operator will return undefined, but if channelId is a string (including an empty string), it will return that string. This could be simplified to just channelId since the type already allows string | undefined. The same applies to messageId ?? undefined on line 93.
| channelId: channelId ?? undefined, | |
| messageId: messageId ?? undefined, | |
| channelId, | |
| messageId, |
| const logContent = truncateMessage(`**Automod ${actionLabel}** | ||
| <@${user.id}> (${user.username}) in ${channelMention} | ||
| -# Rule: ${ruleName}${matchedKeyword ? ` · Matched: \`${matchedKeyword}\`` : ""}`).trim(); |
Copilot
AI
Jan 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If matchedKeyword contains a backtick character, it will break the inline code formatting. Consider escaping backticks in the matchedKeyword before embedding it in the template literal, or use a different delimiter that doesn't conflict with Discord markdown.
| const logContent = truncateMessage(`**Automod ${actionLabel}** | |
| <@${user.id}> (${user.username}) in ${channelMention} | |
| -# Rule: ${ruleName}${matchedKeyword ? ` · Matched: \`${matchedKeyword}\`` : ""}`).trim(); | |
| const safeMatchedKeyword = | |
| matchedKeyword != null ? matchedKeyword.replace(/`/g, "\\`") : matchedKeyword; | |
| const logContent = truncateMessage(`**Automod ${actionLabel}** | |
| <@${user.id}> (${user.username}) in ${channelMention} | |
| -# Rule: ${ruleName}${safeMatchedKeyword ? ` · Matched: \`${safeMatchedKeyword}\`` : ""}`).trim(); |
| await thread.parent | ||
| .send({ | ||
| allowedMentions: {}, | ||
| content: `> ${escapeDisruptiveContent(truncatedContent)}\n-# [Automod: ${ruleName}](${messageLink(logMessage.channelId, logMessage.id)})`, |
Copilot
AI
Jan 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If ruleName contains square brackets or other markdown characters, it could break the markdown link formatting. Consider escaping special markdown characters in ruleName before using it in the link text.
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).