Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions app/discord/automod.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;

Expand Down
1 change: 1 addition & 0 deletions app/discord/client.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const client = new Client({
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.AutoModerationExecution,
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
Expand Down
16 changes: 14 additions & 2 deletions app/discord/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }) => {
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",
Expand Down
5 changes: 1 addition & 4 deletions app/helpers/escalate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
124 changes: 109 additions & 15 deletions app/helpers/modLog.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -45,6 +47,7 @@ const ReadableReasons: Record<ReportReasons, string> = {
[ReportReasons.track]: "tracked",
[ReportReasons.modResolution]: "Mod vote resolved",
[ReportReasons.spam]: "detected as spam",
[ReportReasons.automod]: "detected by automod",
};

const isForwardedMessage = (message: Message): boolean => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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, string> = {
[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<void> => {
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,
Expand All @@ -127,25 +223,26 @@ export const reportUser = async ({
}: Omit<Report, "date">): 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,
);

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
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/models/reportedMessages.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const enum ReportReasons {
track = "track",
modResolution = "modResolution",
spam = "spam",
automod = "automod",
}

export async function recordReport(data: {
Expand Down
Loading
Loading