Skip to content

Commit c8801fa

Browse files
committed
- Add DeletedMessagesListener to log deleted and bulk deleted messages
- Update moderation module to include `DeletedMessagesListener` - Introduce configurable options for deleted message logging in `Config` and `config.type` - Adjust PostgreSQL volume path in `docker-compose.dev.yml` - Minor `package.json` script cleanup for `typecheck` command
1 parent 80185b2 commit c8801fa

8 files changed

Lines changed: 215 additions & 6 deletions

File tree

docker-compose.dev.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
version: '3.8'
2-
31
services:
42
postgres:
53
image: postgres:18
@@ -12,7 +10,7 @@ services:
1210
ports:
1311
- "3306:5432"
1412
volumes:
15-
- postgres_data:/var/lib/postgresql/data
13+
- postgres_data:/var/lib/postgresql
1614
healthcheck:
1715
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
1816
interval: 10s

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"scripts": {
1010
"version": "echo $npm_package_version",
1111
"lint": "bunx biome check --fix",
12-
"typecheck": "bunx tsc --noEmit --build tsconfig.json",
12+
"typecheck": "bunx tsc --build --noEmit tsconfig.json",
1313
"typecheck:prod": "bunx tsc --noEmit -p tsconfig.production.json",
1414
"watch-dev": "bun --watch src/index.ts",
1515
"start": "bun --trace-warnings src/index.ts",

src/Config.prod.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export const config: Config = {
2323
commands: {
2424
daily: "1059214166075912225",
2525
},
26-
26+
deletedMessageLog: {
27+
cacheTtlMs: 1000 * 60 * 60 * 24,
28+
excludedChannels: [],
29+
},
2730
roles: {
2831
tiers: [
2932
"821743100203368458", // @everyone (tier 0)

src/Config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ const devConfig: Config = {
2323
commands: {
2424
daily: "1029850807794937949",
2525
},
26+
deletedMessageLog: {
27+
cacheTtlMs: 1000 * 60 * 60 * 24,
28+
excludedChannels: [],
29+
},
2630
roles: {
2731
tiers: [
2832
"904478147351806012", // @everyone (tier 0)

src/config.type.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export interface Config {
5252
archiveChannel: string;
5353
pingRole?: Snowflake;
5454
};
55+
deletedMessageLog?: {
56+
/** Cache TTL in milliseconds (default: 24 hours) */
57+
cacheTtlMs?: number;
58+
/** Additional channel IDs to exclude from tracking (mod channels auto-excluded) */
59+
excludedChannels?: Snowflake[];
60+
};
5561
branding: BrandingConfig;
5662
informationMessage?: InformationMessage;
5763
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as Sentry from "@sentry/bun";
2+
import type { Message, Snowflake } from "discord.js";
3+
import ExpiryMap from "expiry-map";
4+
import { config } from "../../Config.js";
5+
import { logger } from "../../logging.js";
6+
import type { EventListener } from "../module.js";
7+
import {
8+
type CachedMessage,
9+
logBulkDeletedMessages,
10+
logDeletedMessage,
11+
} from "./logs.js";
12+
13+
// Configurable TTL - default 24 hours
14+
const CACHE_TTL_MS =
15+
config.deletedMessageLog?.cacheTtlMs ?? 1000 * 60 * 60 * 24;
16+
17+
const messageCache = new ExpiryMap<Snowflake, CachedMessage>(CACHE_TTL_MS);
18+
19+
// Auto-excluded mod channels
20+
const modChannels = new Set([
21+
config.channels.modLog,
22+
config.channels.auditLog,
23+
config.modmail.channel,
24+
]);
25+
26+
function isExcludedChannel(channelId: Snowflake): boolean {
27+
const additionalExcluded = config.deletedMessageLog?.excludedChannels ?? [];
28+
return modChannels.has(channelId) || additionalExcluded.includes(channelId);
29+
}
30+
31+
function cacheMessage(message: Message): void {
32+
if (
33+
message.author.bot ||
34+
!message.inGuild() ||
35+
isExcludedChannel(message.channelId)
36+
)
37+
return;
38+
39+
messageCache.set(message.id, {
40+
id: message.id,
41+
content: message.content,
42+
authorId: message.author.id,
43+
authorTag: message.author.tag,
44+
channelId: message.channelId,
45+
createdTimestamp: message.createdTimestamp,
46+
attachmentUrls: message.attachments.map((a) => a.url),
47+
});
48+
}
49+
50+
export const DeletedMessagesListener: EventListener = {
51+
messageCreate(_, message) {
52+
if (!message.inGuild()) return;
53+
cacheMessage(message);
54+
},
55+
56+
messageUpdate(_, _oldMessage, newMessage) {
57+
if (!newMessage.inGuild()) return;
58+
if (newMessage.partial) return;
59+
cacheMessage(newMessage);
60+
},
61+
62+
async messageDelete(client, message) {
63+
try {
64+
if (isExcludedChannel(message.channelId)) return;
65+
66+
const cached = messageCache.get(message.id);
67+
if (!cached) {
68+
logger.debug(`Deleted message ${message.id} not in cache`);
69+
return;
70+
}
71+
72+
await logDeletedMessage(client, cached);
73+
messageCache.delete(message.id);
74+
} catch (error) {
75+
logger.error("Failed to log deleted message:", error);
76+
Sentry.captureException(error);
77+
}
78+
},
79+
80+
async messageDeleteBulk(client, messages, channel) {
81+
try {
82+
if (isExcludedChannel(channel.id)) return;
83+
84+
const cachedMessages: CachedMessage[] = [];
85+
for (const [id] of messages) {
86+
const cached = messageCache.get(id);
87+
if (cached) {
88+
cachedMessages.push(cached);
89+
messageCache.delete(id);
90+
}
91+
}
92+
93+
if (cachedMessages.length === 0) {
94+
logger.debug(`Bulk deletion in ${channel.id} - no messages in cache`);
95+
return;
96+
}
97+
98+
await logBulkDeletedMessages(client, cachedMessages, channel.id);
99+
} catch (error) {
100+
logger.error("Failed to log bulk deleted messages:", error);
101+
Sentry.captureException(error);
102+
}
103+
},
104+
};

src/modules/moderation/logs.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ import { logger } from "../../logging.js";
1111
import { prettyPrintDuration } from "../../util/timespan.js";
1212
import { actualMention, fakeMention } from "../../util/users.js";
1313

14+
export interface CachedMessage {
15+
id: Snowflake;
16+
content: string;
17+
authorId: Snowflake;
18+
authorTag: string;
19+
channelId: Snowflake;
20+
createdTimestamp: number;
21+
attachmentUrls: string[];
22+
}
23+
1424
export type ModerationLog =
1525
| BanLog
1626
| UnbanLog
@@ -153,3 +163,86 @@ export async function logModerationAction(
153163
embeds: [embed],
154164
});
155165
}
166+
167+
export async function logDeletedMessage(
168+
client: Client,
169+
message: CachedMessage,
170+
) {
171+
const modLogChannel = await client.channels.fetch(config.channels.modLog);
172+
if (!modLogChannel?.isSendable()) {
173+
logger.error("Moderation log channel not sendable");
174+
return;
175+
}
176+
177+
const contentDisplay =
178+
message.content.slice(0, 1024) || "*[No text content]*";
179+
180+
const embed = new EmbedBuilder()
181+
.setTitle("Message Deleted")
182+
.setColor("Grey")
183+
.setDescription(
184+
`**Author**: <@${message.authorId}> (${message.authorTag})\n` +
185+
`**Channel**: <#${message.channelId}>\n` +
186+
`**Created**: <t:${Math.round(message.createdTimestamp / 1000)}:R>\n\n` +
187+
`**Content**:\n${contentDisplay}`,
188+
)
189+
.setFooter({ text: `Message ID: ${message.id}` })
190+
.setTimestamp();
191+
192+
if (message.attachmentUrls.length > 0) {
193+
const attachmentList =
194+
message.attachmentUrls.slice(0, 5).join("\n") +
195+
(message.attachmentUrls.length > 5
196+
? `\n... and ${message.attachmentUrls.length - 5} more`
197+
: "");
198+
embed.addFields({
199+
name: "Attachments",
200+
value: attachmentList,
201+
});
202+
}
203+
204+
await modLogChannel.send({ embeds: [embed] });
205+
}
206+
207+
export async function logBulkDeletedMessages(
208+
client: Client,
209+
messages: CachedMessage[],
210+
channelId: Snowflake,
211+
) {
212+
const modLogChannel = await client.channels.fetch(config.channels.modLog);
213+
if (!modLogChannel?.isSendable()) {
214+
logger.error("Moderation log channel not sendable");
215+
return;
216+
}
217+
218+
// Sort by timestamp
219+
messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp);
220+
221+
// Build a summary - truncate if too many
222+
const maxDisplayed = 10;
223+
const displayed = messages.slice(0, maxDisplayed);
224+
const remaining = messages.length - maxDisplayed;
225+
226+
let messageList = displayed
227+
.map(
228+
(m) =>
229+
`**${m.authorTag}** (<t:${Math.round(m.createdTimestamp / 1000)}:t>): ${m.content.slice(0, 100) || "*[No text]*"}`,
230+
)
231+
.join("\n");
232+
233+
if (remaining > 0) {
234+
messageList += `\n... and ${remaining} more messages`;
235+
}
236+
237+
const embed = new EmbedBuilder()
238+
.setTitle("Bulk Messages Deleted")
239+
.setColor("DarkGrey")
240+
.setDescription(
241+
`**Channel**: <#${channelId}>\n` +
242+
`**Count**: ${messages.length} messages\n\n` +
243+
`**Messages**:\n${messageList.slice(0, 2000)}`,
244+
)
245+
.setTimestamp();
246+
247+
await modLogChannel.send({ embeds: [embed] });
248+
}

src/modules/moderation/moderation.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type Module from "../module.js";
22
import { BanCommand } from "./ban.command.js";
3+
import { DeletedMessagesListener } from "./deletedMessages.listener.js";
34
import { InviteListeners } from "./discordInvitesMonitor.listener.js";
45
import { KickCommand } from "./kick.command.js";
56
import { SoftBanCommand } from "./softBan.command.js";
@@ -18,5 +19,5 @@ export const ModerationModule: Module = {
1819
KickCommand,
1920
ZookeepCommand,
2021
],
21-
listeners: [...InviteListeners, TempBanListener],
22+
listeners: [...InviteListeners, TempBanListener, DeletedMessagesListener],
2223
};

0 commit comments

Comments
 (0)