Skip to content

Commit 5e53923

Browse files
committed
WIP: refactor track and modlog helpers
1 parent 2af7950 commit 5e53923

File tree

12 files changed

+685
-685
lines changed

12 files changed

+685
-685
lines changed

app/commands/report.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
type MessageContextMenuCommandInteraction,
66
} from "discord.js";
77

8+
import { logUserMessageLegacy } from "#~/commands/report/userLog.ts";
89
import { commandStats } from "#~/helpers/metrics";
9-
import { reportUserLegacy } from "#~/helpers/modLog";
1010
import { log, trackPerformance } from "#~/helpers/observability";
1111
import { ReportReasons } from "#~/models/reportedMessages.ts";
1212

@@ -30,7 +30,7 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => {
3030
});
3131

3232
try {
33-
await reportUserLegacy({
33+
await logUserMessageLegacy({
3434
reason: ReportReasons.anonReport,
3535
message,
3636
staff: false,

app/commands/report/automodLog.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { AutoModerationActionType, type Guild, type User } from "discord.js";
2+
import { Effect } from "effect";
3+
4+
import { DatabaseServiceLive } from "#~/Database";
5+
import { DiscordApiError } from "#~/effects/errors";
6+
import { logEffect } from "#~/effects/observability";
7+
import { runEffect } from "#~/effects/runtime";
8+
import { truncateMessage } from "#~/helpers/string";
9+
import { fetchSettings, SETTINGS } from "#~/models/guilds.server";
10+
import { getOrCreateUserThread } from "#~/models/userThreads.ts";
11+
12+
export interface AutomodReport {
13+
guild: Guild;
14+
user: User;
15+
content: string;
16+
channelId?: string;
17+
messageId?: string;
18+
ruleName: string;
19+
matchedKeyword?: string;
20+
actionType: AutoModerationActionType;
21+
}
22+
23+
const ActionTypeLabels: Record<AutoModerationActionType, string> = {
24+
[AutoModerationActionType.BlockMessage]: "blocked message",
25+
[AutoModerationActionType.SendAlertMessage]: "sent alert",
26+
[AutoModerationActionType.Timeout]: "timed out user",
27+
[AutoModerationActionType.BlockMemberInteraction]: "blocked interaction",
28+
};
29+
30+
const logAutomod = ({
31+
guild,
32+
user,
33+
channelId,
34+
ruleName,
35+
matchedKeyword,
36+
actionType,
37+
}: AutomodReport) =>
38+
Effect.gen(function* () {
39+
yield* logEffect(
40+
"info",
41+
"logAutomod",
42+
`Automod triggered for ${user.username}`,
43+
{
44+
userId: user.id,
45+
guildId: guild.id,
46+
ruleName,
47+
actionType,
48+
},
49+
);
50+
51+
// Get or create persistent user thread
52+
const thread = yield* getOrCreateUserThread(guild, user);
53+
54+
// Get mod log for forwarding
55+
const { modLog, moderator } = yield* Effect.tryPromise({
56+
try: () => fetchSettings(guild.id, [SETTINGS.modLog, SETTINGS.moderator]),
57+
catch: (error) =>
58+
new DiscordApiError({
59+
operation: "fetchSettings",
60+
discordError: error,
61+
}),
62+
});
63+
64+
// Construct the log message
65+
const channelMention = channelId ? `<#${channelId}>` : "Unknown channel";
66+
const actionLabel = ActionTypeLabels[actionType] ?? "took action";
67+
68+
const logContent = truncateMessage(
69+
`<@${user.id}> (${user.username}) triggered automod ${matchedKeyword ? `with text \`${matchedKeyword}\` ` : ""}in ${channelMention}
70+
-# ${ruleName} · Automod ${actionLabel}`,
71+
).trim();
72+
73+
// Send log to thread
74+
const logMessage = yield* Effect.tryPromise({
75+
try: () =>
76+
thread.send({
77+
content: logContent,
78+
allowedMentions: { roles: [moderator] },
79+
}),
80+
catch: (error) =>
81+
new DiscordApiError({
82+
operation: "sendLogMessage",
83+
discordError: error,
84+
}),
85+
});
86+
87+
// Forward to mod log (non-critical)
88+
yield* Effect.tryPromise({
89+
try: () => logMessage.forward(modLog),
90+
catch: (error) => error,
91+
}).pipe(
92+
Effect.catchAll((error) =>
93+
logEffect("error", "logAutomod", "failed to forward to modLog", {
94+
error: String(error),
95+
}),
96+
),
97+
);
98+
}).pipe(
99+
Effect.withSpan("logAutomod", {
100+
attributes: { userId: user.id, guildId: guild.id, ruleName },
101+
}),
102+
);
103+
104+
/**
105+
* Logs an automod action when we don't have a full Message object.
106+
* Used when Discord's automod blocks/deletes a message before we can fetch it.
107+
*/
108+
export const logAutomodLegacy = (report: AutomodReport): Promise<void> =>
109+
runEffect(Effect.provide(logAutomod(report), DatabaseServiceLive));
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { formatDistanceToNowStrict } from "date-fns";
2+
import {
3+
MessageReferenceType,
4+
type Message,
5+
type MessageCreateOptions,
6+
} from "discord.js";
7+
import { Effect } from "effect";
8+
9+
import { DiscordApiError } from "#~/effects/errors";
10+
import { constructDiscordLink } from "#~/helpers/discord";
11+
import { truncateMessage } from "#~/helpers/string";
12+
import { fetchSettings, SETTINGS } from "#~/models/guilds.server";
13+
import { ReportReasons, type Report } from "#~/models/reportedMessages";
14+
15+
const ReadableReasons: Record<ReportReasons, string> = {
16+
[ReportReasons.anonReport]: "Reported anonymously",
17+
[ReportReasons.track]: "tracked",
18+
[ReportReasons.modResolution]: "Mod vote resolved",
19+
[ReportReasons.spam]: "detected as spam",
20+
[ReportReasons.automod]: "detected by automod",
21+
};
22+
23+
export const makeReportMessage = ({ message: _, reason, staff }: Report) => {
24+
return {
25+
content: `${staff ? ` ${staff.username} ` : ""}${ReadableReasons[reason]}`,
26+
};
27+
};
28+
29+
export const constructLog = ({
30+
logs,
31+
extra: origExtra = "",
32+
}: Pick<Report, "extra" | "staff"> & {
33+
logs: Report[];
34+
}) =>
35+
Effect.gen(function* () {
36+
const lastReport = logs.at(-1);
37+
if (!lastReport?.message.guild) {
38+
return yield* Effect.fail(
39+
new DiscordApiError({
40+
operation: "constructLog",
41+
discordError: new Error(
42+
"Something went wrong when trying to retrieve last report",
43+
),
44+
}),
45+
);
46+
}
47+
const { message } = lastReport;
48+
const { author } = message;
49+
const { moderator } = yield* Effect.tryPromise({
50+
try: () =>
51+
fetchSettings(lastReport.message.guild!.id, [SETTINGS.moderator]),
52+
catch: (error) =>
53+
new DiscordApiError({
54+
operation: "fetchSettings",
55+
discordError: error,
56+
}),
57+
});
58+
59+
// This should never be possible but we gotta satisfy types
60+
if (!moderator) {
61+
return yield* Effect.fail(
62+
new DiscordApiError({
63+
operation: "constructLog",
64+
discordError: new Error("No role configured to be used as moderator"),
65+
}),
66+
);
67+
}
68+
69+
const { content: report } = makeReportMessage(lastReport);
70+
71+
// Add indicator if this is forwarded content
72+
const forwardNote = isForwardedMessage(message) ? " (forwarded)" : "";
73+
const preface = `${constructDiscordLink(message)} by <@${author.id}> (${
74+
author.username
75+
})${forwardNote}`;
76+
const extra = origExtra ? `${origExtra}\n` : "";
77+
78+
return {
79+
content: truncateMessage(`${preface}
80+
-# ${report}
81+
-# ${extra}${formatDistanceToNowStrict(lastReport.message.createdAt)} ago · <t:${Math.floor(lastReport.message.createdTimestamp / 1000)}:R>`).trim(),
82+
allowedMentions: { roles: [moderator] },
83+
} satisfies MessageCreateOptions;
84+
});
85+
86+
export const isForwardedMessage = (message: Message): boolean => {
87+
return message.reference?.type === MessageReferenceType.Forward;
88+
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { type Guild, type PartialUser, type User } from "discord.js";
2+
import { Effect } from "effect";
3+
4+
import { DatabaseServiceLive } from "#~/Database";
5+
import { DiscordApiError } from "#~/effects/errors";
6+
import { logEffect } from "#~/effects/observability";
7+
import { runEffect } from "#~/effects/runtime";
8+
import { truncateMessage } from "#~/helpers/string";
9+
import { fetchSettings, SETTINGS } from "#~/models/guilds.server";
10+
import { getOrCreateUserThread } from "#~/models/userThreads.ts";
11+
12+
export type ModActionReport =
13+
| {
14+
guild: Guild;
15+
user: User;
16+
actionType: "kick" | "ban";
17+
executor: User | PartialUser | null;
18+
reason: string;
19+
}
20+
| {
21+
guild: Guild;
22+
user: User;
23+
actionType: "left";
24+
executor: undefined;
25+
reason: undefined;
26+
};
27+
28+
export const logModAction = ({
29+
guild,
30+
user,
31+
actionType,
32+
executor,
33+
reason,
34+
}: ModActionReport) =>
35+
Effect.gen(function* () {
36+
yield* logEffect(
37+
"info",
38+
"logModAction",
39+
`${actionType} detected for ${user.username}`,
40+
{
41+
userId: user.id,
42+
guildId: guild.id,
43+
actionType,
44+
executorId: executor?.id,
45+
reason,
46+
},
47+
);
48+
49+
if (actionType === "left") {
50+
return;
51+
}
52+
53+
// Get or create persistent user thread
54+
const thread = yield* getOrCreateUserThread(guild, user);
55+
56+
// Get mod log for forwarding
57+
const { modLog, moderator } = yield* Effect.tryPromise({
58+
try: () => fetchSettings(guild.id, [SETTINGS.modLog, SETTINGS.moderator]),
59+
catch: (error) =>
60+
new DiscordApiError({
61+
operation: "fetchSettings",
62+
discordError: error,
63+
}),
64+
});
65+
66+
// Construct the log message
67+
const actionLabels: Record<ModActionReport["actionType"], string> = {
68+
ban: "was banned",
69+
kick: "was kicked",
70+
left: "left",
71+
};
72+
const actionLabel = actionLabels[actionType];
73+
const executorMention = executor
74+
? ` by <@${executor.id}> (${executor.username})`
75+
: " by unknown";
76+
77+
const reasonText = reason ? ` ${reason}` : " for no reason";
78+
79+
const logContent = truncateMessage(
80+
`<@${user.id}> (${user.username}) ${actionLabel}
81+
-# ${executorMention}${reasonText} <t:${Math.floor(Date.now() / 1000)}:R>`,
82+
).trim();
83+
84+
// Send log to thread
85+
const logMessage = yield* Effect.tryPromise({
86+
try: () =>
87+
thread.send({
88+
content: logContent,
89+
allowedMentions: { roles: [moderator] },
90+
}),
91+
catch: (error) =>
92+
new DiscordApiError({
93+
operation: "sendLogMessage",
94+
discordError: error,
95+
}),
96+
});
97+
98+
// Forward to mod log (non-critical)
99+
yield* Effect.tryPromise({
100+
try: () => logMessage.forward(modLog),
101+
catch: (error) => error,
102+
}).pipe(
103+
Effect.catchAll((error) =>
104+
logEffect("error", "logModAction", "failed to forward to modLog", {
105+
error: String(error),
106+
}),
107+
),
108+
);
109+
}).pipe(
110+
Effect.withSpan("logModAction", {
111+
attributes: { userId: user.id, guildId: guild.id, actionType },
112+
}),
113+
);
114+
115+
/**
116+
* Logs a mod action (kick/ban) to the user's persistent thread.
117+
* Used when Discord events indicate a kick or ban occurred.
118+
*/
119+
export const logModActionLegacy = (report: ModActionReport): Promise<void> =>
120+
runEffect(Effect.provide(logModAction(report), DatabaseServiceLive));

0 commit comments

Comments
 (0)