Skip to content

Commit 1dab999

Browse files
vcarlclaude
andcommitted
Add kick/ban event logging to user mod threads
Listen for GuildBanAdd and GuildMemberRemove events to log external moderation actions (kicks/bans performed via Discord UI) to user threads. Uses audit log to identify executor and distinguish kicks from voluntary leaves. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e93f553 commit 1dab999

File tree

4 files changed

+304
-0
lines changed

4 files changed

+304
-0
lines changed

app/discord/client.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const client = new Client({
1111
GatewayIntentBits.GuildEmojisAndStickers,
1212
GatewayIntentBits.GuildMessages,
1313
GatewayIntentBits.GuildMessageReactions,
14+
GatewayIntentBits.GuildModeration,
1415
GatewayIntentBits.DirectMessages,
1516
GatewayIntentBits.DirectMessageReactions,
1617
GatewayIntentBits.AutoModerationExecution,

app/discord/gateway.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import automod from "#~/discord/automod";
55
import { client, login } from "#~/discord/client.server";
66
import { deployCommands } from "#~/discord/deployCommands.server";
77
import { startEscalationResolver } from "#~/discord/escalationResolver";
8+
import modActionLogger from "#~/discord/modActionLogger";
89
import onboardGuild from "#~/discord/onboardGuild";
910
import { startReactjiChanneler } from "#~/discord/reactjiChanneler";
1011
import { botStats, shutdownMetrics } from "#~/helpers/metrics";
@@ -59,6 +60,7 @@ export default function init() {
5960
await Promise.all([
6061
onboardGuild(client),
6162
automod(client),
63+
modActionLogger(client),
6264
deployCommands(client),
6365
startActivityTracking(client),
6466
startHoneypotTracking(client),

app/discord/modActionLogger.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import {
2+
AuditLogEvent,
3+
Events,
4+
type Client,
5+
type Guild,
6+
type GuildBan,
7+
type GuildMember,
8+
type PartialGuildMember,
9+
type PartialUser,
10+
type User,
11+
} from "discord.js";
12+
13+
import { reportModAction, type ModActionReport } from "#~/helpers/modLog";
14+
import { log } from "#~/helpers/observability";
15+
16+
// Time window to check audit log for matching entries (5 seconds)
17+
const AUDIT_LOG_WINDOW_MS = 5000;
18+
19+
async function handleBanAdd(ban: GuildBan) {
20+
const { guild, user } = ban;
21+
let { reason } = ban;
22+
let executor: User | PartialUser | null = null;
23+
24+
log("info", "ModActionLogger", "Ban detected", {
25+
userId: user.id,
26+
guildId: guild.id,
27+
reason,
28+
});
29+
30+
try {
31+
// Check audit log for who performed the ban
32+
const auditLogs = await guild.fetchAuditLogs({
33+
type: AuditLogEvent.MemberBanAdd,
34+
limit: 5,
35+
});
36+
37+
const banEntry = auditLogs.entries.find(
38+
(entry) =>
39+
entry.target?.id === user.id &&
40+
Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS,
41+
);
42+
43+
executor = banEntry?.executor ?? null;
44+
reason = banEntry?.reason;
45+
46+
// Skip if the bot performed this action (it's already logged elsewhere)
47+
if (executor?.id === guild.client.user?.id) {
48+
log("debug", "ModActionLogger", "Skipping self-ban", {
49+
userId: user.id,
50+
guildId: guild.id,
51+
});
52+
return;
53+
}
54+
} catch (error) {
55+
// If we can't access audit log, still log the ban but without executor info
56+
if (
57+
error instanceof Error &&
58+
error.message.includes("Missing Permissions")
59+
) {
60+
log(
61+
"warn",
62+
"ModActionLogger",
63+
"Cannot access audit log for ban details",
64+
{ userId: user.id, guildId: guild.id },
65+
);
66+
} else {
67+
log("error", "ModActionLogger", "Failed to fetch audit log for ban", {
68+
userId: user.id,
69+
guildId: guild.id,
70+
error,
71+
});
72+
}
73+
}
74+
75+
try {
76+
await reportModAction({
77+
guild,
78+
user,
79+
actionType: "ban",
80+
executor,
81+
reason: reason ?? "",
82+
});
83+
} catch (error) {
84+
log("error", "ModActionLogger", "Failed to report ban", {
85+
userId: user.id,
86+
guildId: guild.id,
87+
error,
88+
});
89+
}
90+
}
91+
92+
async function fetchAuditLogs(
93+
guild: Guild,
94+
user: User,
95+
): Promise<ModActionReport | undefined> {
96+
// Check audit log to distinguish kick from voluntary leave
97+
const auditLogs = await guild.fetchAuditLogs({
98+
type: AuditLogEvent.MemberKick,
99+
limit: 5,
100+
});
101+
102+
const kickEntry = auditLogs.entries.find(
103+
(entry) =>
104+
entry.target?.id === user.id &&
105+
Date.now() - entry.createdTimestamp < AUDIT_LOG_WINDOW_MS,
106+
);
107+
108+
// If no kick entry found, user left voluntarily
109+
if (!kickEntry) {
110+
log(
111+
"debug",
112+
"ModActionLogger",
113+
"No kick entry found, user left voluntarily",
114+
{ userId: user.id, guildId: guild.id },
115+
);
116+
return {
117+
actionType: "left",
118+
user,
119+
guild,
120+
executor: undefined,
121+
reason: undefined,
122+
};
123+
}
124+
const { executor, reason } = kickEntry;
125+
126+
if (!executor) {
127+
log(
128+
"warn",
129+
"ModActionLogger",
130+
`No executor found for audit log entry ${kickEntry.id}`,
131+
);
132+
}
133+
134+
// Skip if the bot performed this action
135+
// TODO: maybe best to invert — remove manual kick logs in favor of this
136+
if (kickEntry.executor?.id === guild.client.user?.id) {
137+
log("debug", "ModActionLogger", "Skipping self-kick", {
138+
userId: user.id,
139+
guildId: guild.id,
140+
});
141+
return;
142+
}
143+
144+
return { actionType: "kick", user, guild, executor, reason: reason ?? "" };
145+
}
146+
147+
async function handleMemberRemove(member: GuildMember | PartialGuildMember) {
148+
const { guild, user } = member;
149+
150+
log("info", "ModActionLogger", "Member removal detected", {
151+
userId: user.id,
152+
guildId: guild.id,
153+
});
154+
155+
try {
156+
const auditLogs = await fetchAuditLogs(guild, user);
157+
158+
if (auditLogs) {
159+
const { executor = null, reason = "" } = auditLogs;
160+
await reportModAction({
161+
guild,
162+
user,
163+
actionType: "kick",
164+
executor,
165+
reason,
166+
});
167+
return;
168+
}
169+
await reportModAction({
170+
guild,
171+
user,
172+
actionType: "left",
173+
executor: undefined,
174+
reason: undefined,
175+
});
176+
} catch (error) {
177+
log("error", "ModActionLogger", "Failed to handle member removal", {
178+
userId: user.id,
179+
guildId: guild.id,
180+
error,
181+
});
182+
}
183+
}
184+
185+
export default async (bot: Client) => {
186+
bot.on(Events.GuildBanAdd, async (ban) => {
187+
try {
188+
await handleBanAdd(ban);
189+
} catch (error) {
190+
log("error", "ModActionLogger", "Unhandled error in ban handler", {
191+
userId: ban.user.id,
192+
guildId: ban.guild.id,
193+
error,
194+
});
195+
}
196+
});
197+
198+
bot.on(Events.GuildMemberRemove, async (member) => {
199+
try {
200+
await handleMemberRemove(member);
201+
} catch (error) {
202+
log(
203+
"error",
204+
"ModActionLogger",
205+
"Unhandled error in member remove handler",
206+
{
207+
userId: member.user?.id,
208+
guildId: member.guild.id,
209+
error,
210+
},
211+
);
212+
}
213+
});
214+
};

app/helpers/modLog.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type Guild,
1010
type Message,
1111
type MessageCreateOptions,
12+
type PartialUser,
1213
type TextChannel,
1314
type User,
1415
} from "discord.js";
@@ -70,12 +71,14 @@ interface Reported {
7071
latestReport?: Message;
7172
}
7273

74+
// todo: make an effect
7375
const makeUserThread = (channel: TextChannel, user: User) => {
7476
return channel.threads.create({
7577
name: `${user.username} logs`,
7678
});
7779
};
7880

81+
// todo: make an effect
7982
const getOrCreateUserThread = async (guild: Guild, user: User) => {
8083
if (!guild) throw new Error("Message has no guild");
8184

@@ -141,6 +144,7 @@ const ActionTypeLabels: Record<AutoModerationActionType, string> = {
141144
/**
142145
* Reports an automod action when we don't have a full Message object.
143146
* Used when Discord's automod blocks/deletes a message before we can fetch it.
147+
* todo: make this into an effect, and don't use Discord.js classes in the api
144148
*/
145149
export const reportAutomod = async ({
146150
guild,
@@ -186,6 +190,89 @@ export const reportAutomod = async ({
186190
});
187191
};
188192

193+
export type ModActionReport =
194+
| {
195+
guild: Guild;
196+
user: User;
197+
actionType: "kick" | "ban";
198+
executor: User | PartialUser | null;
199+
reason: string;
200+
}
201+
| {
202+
guild: Guild;
203+
user: User;
204+
actionType: "left";
205+
executor: undefined;
206+
reason: undefined;
207+
};
208+
209+
/**
210+
* Reports a mod action (kick/ban) to the user's persistent thread.
211+
* Used when Discord events indicate a kick or ban occurred.
212+
*/
213+
export const reportModAction = async ({
214+
guild,
215+
user,
216+
actionType,
217+
executor,
218+
reason,
219+
}: ModActionReport): Promise<void> => {
220+
log(
221+
"info",
222+
"reportModAction",
223+
`${actionType} detected for ${user.username}`,
224+
{
225+
userId: user.id,
226+
guildId: guild.id,
227+
actionType,
228+
executorId: executor?.id,
229+
reason,
230+
},
231+
);
232+
233+
if (actionType === "left") {
234+
return;
235+
}
236+
237+
// Get or create persistent user thread
238+
const thread = await getOrCreateUserThread(guild, user);
239+
240+
// Get mod log for forwarding
241+
const { modLog, moderator } = await fetchSettings(guild.id, [
242+
SETTINGS.modLog,
243+
SETTINGS.moderator,
244+
]);
245+
246+
// Construct the log message
247+
const actionLabels: Record<ModActionReport["actionType"], string> = {
248+
ban: "was banned",
249+
kick: "was kicked",
250+
left: "left",
251+
};
252+
const actionLabel = actionLabels[actionType];
253+
const executorMention = executor
254+
? ` by <@${executor.id}> (${executor.username})`
255+
: " by unknown";
256+
257+
const logContent = truncateMessage(
258+
`<@${user.id}> (${user.username}) ${actionLabel}
259+
-# ${executorMention} ${reason ?? "for no reason"} <t:${Math.floor(Date.now() / 1000)}:R>`,
260+
).trim();
261+
262+
// Send log to thread
263+
const logMessage = await thread.send({
264+
content: logContent,
265+
allowedMentions: { roles: [moderator] },
266+
});
267+
268+
// Forward to mod log
269+
await logMessage.forward(modLog).catch((e) => {
270+
log("error", "reportModAction", "failed to forward to modLog", {
271+
error: e,
272+
});
273+
});
274+
};
275+
189276
// const warningMessages = new ();
190277
export const reportUser = async ({
191278
reason,

0 commit comments

Comments
 (0)