|
1 | 1 | import { formatDistanceToNowStrict } from "date-fns"; |
2 | 2 | import { |
| 3 | + AutoModerationActionType, |
3 | 4 | ChannelType, |
4 | 5 | messageLink, |
5 | 6 | MessageReferenceType, |
6 | 7 | type AnyThreadChannel, |
7 | 8 | type APIEmbed, |
| 9 | + type Guild, |
8 | 10 | type Message, |
9 | 11 | type MessageCreateOptions, |
10 | 12 | type TextChannel, |
@@ -45,6 +47,7 @@ const ReadableReasons: Record<ReportReasons, string> = { |
45 | 47 | [ReportReasons.track]: "tracked", |
46 | 48 | [ReportReasons.modResolution]: "Mod vote resolved", |
47 | 49 | [ReportReasons.spam]: "detected as spam", |
| 50 | + [ReportReasons.automod]: "detected by automod", |
48 | 51 | }; |
49 | 52 |
|
50 | 53 | const isForwardedMessage = (message: Message): boolean => { |
@@ -118,6 +121,167 @@ const getOrCreateUserThread = async (message: Message, user: User) => { |
118 | 121 | return thread; |
119 | 122 | }; |
120 | 123 |
|
| 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 | + |
121 | 285 | // const warningMessages = new (); |
122 | 286 | export const reportUser = async ({ |
123 | 287 | reason, |
|
0 commit comments