Skip to content

Commit ad302ed

Browse files
vcarlclaude
andauthored
Revise Track/Report to use a single persistent thread for each user (#174)
Closes #172 This PR majorly alters the Track command so that it creates threads for each user, rather than for each reported message, while still recording each new report at the top-level of our #mod-log channel by forwarding the message (only once per message ID). This lets us read a single thread of reports to evaluate how someone's participation has been received in general. A major change: This PR does away with our in-memory LRU cache of reported messages in favor of storing IDs in the database. This will enable us to develop more features to review the totality of someone's behavior in e.g. a web app, by retrieving those messages from Discord, without requiring a complete archive of the message contents. <img width="549" height="875" alt="Screenshot 2025-07-28 at 2 10 45 AM" src="https://github.com/user-attachments/assets/bab3b50d-a737-4164-9724-b4a773744779" /> <img width="553" height="796" alt="Screenshot 2025-07-28 at 2 10 53 AM" src="https://github.com/user-attachments/assets/58b55dd0-a415-46d5-b023-5b55f89ba911" /> --------- Co-authored-by: Claude <[email protected]>
1 parent 2cc19b6 commit ad302ed

33 files changed

+2513
-391
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ k8s-context
1313
tsconfig.tsbuildinfo
1414
.react-router
1515
tailwind.css
16+
userInfoCache.json
17+
vite.config.ts*

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
- You should periodically jot down your thoughts in `/notes`, especially if it will help you remember important implementation details later.
44
- Your notes must be named consistently with a date prefix in the format YYYY-MM-DD followed by a sequence in the format \_X where x is a monotonically increasing integer.
5+
- You must commit periodically, running `npm run validate` first.
56
- You expect to be able to access VS Code. If you can't, prompt me about it.
67
- This project uses sqlite, so you can inspect the database yourself. You can make your own dummy data, but don't do anything destructive, and make sure to describe how to reverse any DB changes.
78
- You can curl this website, it's running locally at http://localhost:3000. You are not able to access areas behind authentication without data from me.

app/commands/demo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const command = new SlashCommandBuilder()
88

99
export const handler = async (interaction: CommandInteraction) => {
1010
await interaction.reply({
11-
ephemeral: true,
11+
flags: "Ephemeral",
1212
content: "ok",
1313
});
1414
};

app/commands/escalationControls.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { InteractionType, PermissionsBitField } from "discord.js";
2+
import type { MessageComponentCommand } from "#~/helpers/discord";
3+
import { fetchSettings, SETTINGS } from "#~/models/guilds.server";
4+
import { deleteAllReportedForUser } from "#~/models/reportedMessages.server";
5+
import { timeout, ban, kick, applyRestriction } from "#~/models/discord.server";
6+
7+
export const EscalationCommands = [
8+
{
9+
command: {
10+
type: InteractionType.MessageComponent,
11+
name: "escalate-delete",
12+
},
13+
handler: async (interaction) => {
14+
await interaction.deferReply();
15+
const reportedUserId = interaction.customId.split("|")[1];
16+
const guildId = interaction.guildId!;
17+
18+
// Permission check
19+
const member = await interaction.guild!.members.fetch(
20+
interaction.user.id,
21+
);
22+
if (!member.permissions.has(PermissionsBitField.Flags.ManageMessages)) {
23+
return interaction.editReply({
24+
content: "Insufficient permissions",
25+
});
26+
}
27+
28+
try {
29+
const result = await deleteAllReportedForUser(reportedUserId, guildId);
30+
await interaction.editReply(
31+
`Messages deleted by ${interaction.user.username} (${result.deleted}/${result.total} successful)`,
32+
);
33+
} catch (error) {
34+
console.error("Error deleting reported messages:", error);
35+
await interaction.editReply({
36+
content: "Failed to delete messages",
37+
});
38+
}
39+
},
40+
},
41+
42+
{
43+
command: { type: InteractionType.MessageComponent, name: "escalate-kick" },
44+
handler: async (interaction) => {
45+
const reportedUserId = interaction.customId.split("|")[1];
46+
const guildId = interaction.guildId!;
47+
48+
// Get moderator role for permission check
49+
const { moderator: modRoleId } = await fetchSettings(guildId, [
50+
SETTINGS.moderator,
51+
]);
52+
53+
const member = interaction.member;
54+
if (
55+
!member ||
56+
(Array.isArray(member.roles)
57+
? !member.roles.includes(modRoleId)
58+
: !member.roles.cache.has(modRoleId))
59+
) {
60+
return interaction.reply({
61+
content: "Insufficient permissions",
62+
ephemeral: true,
63+
});
64+
}
65+
66+
try {
67+
const reportedMember =
68+
await interaction.guild!.members.fetch(reportedUserId);
69+
await Promise.allSettled([
70+
kick(reportedMember),
71+
interaction.reply(
72+
`<@${reportedUserId}> kicked by ${interaction.user.username}`,
73+
),
74+
]);
75+
} catch (error) {
76+
console.error("Error kicking user:", error);
77+
await interaction.reply({
78+
content: "Failed to kick user",
79+
ephemeral: true,
80+
});
81+
}
82+
},
83+
},
84+
85+
{
86+
command: { type: InteractionType.MessageComponent, name: "escalate-ban" },
87+
handler: async (interaction) => {
88+
const reportedUserId = interaction.customId.split("|")[1];
89+
const guildId = interaction.guildId!;
90+
91+
// Get moderator role for permission check
92+
const { moderator: modRoleId } = await fetchSettings(guildId, [
93+
SETTINGS.moderator,
94+
]);
95+
96+
const member = interaction.member;
97+
if (
98+
!member ||
99+
(Array.isArray(member.roles)
100+
? !member.roles.includes(modRoleId)
101+
: !member.roles.cache.has(modRoleId))
102+
) {
103+
return interaction.reply({
104+
content: "Insufficient permissions",
105+
ephemeral: true,
106+
});
107+
}
108+
109+
try {
110+
const reportedMember =
111+
await interaction.guild!.members.fetch(reportedUserId);
112+
await Promise.allSettled([
113+
ban(reportedMember),
114+
interaction.reply(
115+
`<@${reportedUserId}> banned by ${interaction.user.username}`,
116+
),
117+
]);
118+
} catch (error) {
119+
console.error("Error banning user:", error);
120+
await interaction.reply({
121+
content: "Failed to ban user",
122+
ephemeral: true,
123+
});
124+
}
125+
},
126+
},
127+
128+
{
129+
command: {
130+
type: InteractionType.MessageComponent,
131+
name: "escalate-restrict",
132+
},
133+
handler: async (interaction) => {
134+
const reportedUserId = interaction.customId.split("|")[1];
135+
const guildId = interaction.guildId!;
136+
137+
// Get moderator role for permission check
138+
const { moderator: modRoleId } = await fetchSettings(guildId, [
139+
SETTINGS.moderator,
140+
]);
141+
142+
const member = interaction.member;
143+
if (
144+
!member ||
145+
(Array.isArray(member.roles)
146+
? !member.roles.includes(modRoleId)
147+
: !member.roles.cache.has(modRoleId))
148+
) {
149+
return interaction.reply({
150+
content: "Insufficient permissions",
151+
ephemeral: true,
152+
});
153+
}
154+
155+
try {
156+
const reportedMember =
157+
await interaction.guild!.members.fetch(reportedUserId);
158+
await Promise.allSettled([
159+
applyRestriction(reportedMember),
160+
interaction.reply(
161+
`<@${reportedUserId}> restricted by ${interaction.user.username}`,
162+
),
163+
]);
164+
} catch (error) {
165+
console.error("Error restricting user:", error);
166+
await interaction.reply({
167+
content: "Failed to restrict user",
168+
ephemeral: true,
169+
});
170+
}
171+
},
172+
},
173+
174+
{
175+
command: {
176+
type: InteractionType.MessageComponent,
177+
name: "escalate-timeout",
178+
},
179+
handler: async (interaction) => {
180+
const reportedUserId = interaction.customId.split("|")[1];
181+
const guildId = interaction.guildId!;
182+
183+
// Get moderator role for permission check
184+
const { moderator: modRoleId } = await fetchSettings(guildId, [
185+
SETTINGS.moderator,
186+
]);
187+
188+
const member = interaction.member;
189+
if (
190+
!member ||
191+
(Array.isArray(member.roles)
192+
? !member.roles.includes(modRoleId)
193+
: !member.roles.cache.has(modRoleId))
194+
) {
195+
return interaction.reply({
196+
content: "Insufficient permissions",
197+
ephemeral: true,
198+
});
199+
}
200+
201+
try {
202+
const reportedMember =
203+
await interaction.guild!.members.fetch(reportedUserId);
204+
await Promise.allSettled([
205+
timeout(reportedMember),
206+
interaction.reply(
207+
`<@${reportedUserId}> timed out by ${interaction.user.username}`,
208+
),
209+
]);
210+
} catch (error) {
211+
console.error("Error timing out user:", error);
212+
await interaction.reply({
213+
content: "Failed to timeout user",
214+
ephemeral: true,
215+
});
216+
}
217+
},
218+
},
219+
220+
{
221+
command: {
222+
type: InteractionType.MessageComponent,
223+
name: "escalate-escalate",
224+
},
225+
handler: async (interaction) => {
226+
const guildId = interaction.guildId!;
227+
228+
// Get moderator role for mentions
229+
const { moderator: modRoleId } = await fetchSettings(guildId, [
230+
SETTINGS.moderator,
231+
]);
232+
233+
try {
234+
const member = await interaction.guild!.members.fetch(
235+
interaction.user.id,
236+
);
237+
238+
await Promise.all([
239+
interaction.channel && "send" in interaction.channel
240+
? interaction.channel.send(
241+
`Report escalated by <@${member.id}>, <@&${modRoleId}> please respond.`,
242+
)
243+
: Promise.resolve(),
244+
interaction.reply({
245+
content: `Report escalated successfully`,
246+
ephemeral: true,
247+
}),
248+
]);
249+
250+
// Note: The full escalate() function with ModResponse voting would need
251+
// more complex refactoring to work without Reacord. For now, this provides
252+
// basic escalation notification functionality.
253+
} catch (error) {
254+
console.error("Error escalating report:", error);
255+
await interaction.reply({
256+
content: "Failed to escalate report",
257+
ephemeral: true,
258+
});
259+
}
260+
},
261+
},
262+
] as Array<MessageComponentCommand>;

app/commands/report.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { MessageContextMenuCommandInteraction } from "discord.js";
22
import { PermissionFlagsBits, ContextMenuCommandBuilder } from "discord.js";
33
import { ApplicationCommandType } from "discord-api-types/v10";
44
import { reportUser } from "#~/helpers/modLog";
5-
import { ReportReasons } from "./track/reportCache";
5+
import { ReportReasons } from "#~/models/reportedMessages.server";
66
import { log, trackPerformance } from "#~/helpers/observability";
77
import { commandStats } from "#~/helpers/metrics";
88

app/commands/track.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { Button } from "reacord";
55
import { reacord } from "#~/discord/client.server";
66

77
import { reportUser } from "#~/helpers/modLog";
8-
import { ReportReasons } from "#~/commands/track/reportCache";
8+
import {
9+
ReportReasons,
10+
markMessageAsDeleted,
11+
} from "#~/models/reportedMessages.server";
912

1013
const command = new ContextMenuCommandBuilder()
1114
.setName("Track")
@@ -35,7 +38,9 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => {
3538
const { latestReport, thread } = await reportPromise;
3639

3740
await Promise.allSettled([
38-
message.delete(),
41+
message
42+
.delete()
43+
.then(() => markMessageAsDeleted(message.id, message.guild!.id)),
3944
latestReport?.reply({
4045
allowedMentions: { users: [] },
4146
content: `deleted by ${user.username}`,

0 commit comments

Comments
 (0)