Skip to content

Commit 0feb2d9

Browse files
Merge pull request #216 from Pdzly/feature/log-deleted-messages
2 parents 80b7e25 + 88b7dcc commit 0feb2d9

10 files changed

Lines changed: 263 additions & 35 deletions

File tree

docker-compose.dev.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ services:
1010
ports:
1111
- "3306:5432"
1212
volumes:
13-
- postgres_data:/var/lib/postgresql/data
13+
- postgres_data:/var/lib/postgresql
1414
healthcheck:
1515
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
1616
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: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import * as Sentry from "@sentry/bun";
2+
import type { Collection, Message, Snowflake, TextBasedChannel } from "discord.js";
3+
import ExpiryMap from "expiry-map";
4+
import { config } from "../../Config.js";
5+
import { logger } from "../../logging.js";
6+
import { messageFetcher } from "../../util/ratelimiting.js";
7+
import type { EventListener } from "../module.js";
8+
import { type CachedMessage, logBulkDeletedMessages, logDeletedMessage } from "./logs.js";
9+
10+
// Configurable TTL - default 24 hours
11+
const CACHE_TTL_MS =
12+
config.deletedMessageLog?.cacheTtlMs ?? 1000 * 60 * 60 * 24;
13+
14+
const messageCache = new ExpiryMap<Snowflake, CachedMessage>(CACHE_TTL_MS);
15+
16+
// Auto-excluded mod channels
17+
const modChannels = new Set([
18+
config.channels.modLog,
19+
config.channels.auditLog,
20+
config.modmail.channel,
21+
]);
22+
23+
function isExcludedChannel(channelId: Snowflake): boolean {
24+
const additionalExcluded = config.deletedMessageLog?.excludedChannels ?? [];
25+
return modChannels.has(channelId) || additionalExcluded.includes(channelId);
26+
}
27+
28+
function cacheMessage(message: Message): void {
29+
if (
30+
message.author.bot ||
31+
!message.inGuild() ||
32+
isExcludedChannel(message.channelId)
33+
)
34+
return;
35+
36+
messageCache.set(message.id, {
37+
id: message.id,
38+
content: message.content,
39+
authorId: message.author.id,
40+
authorTag: message.author.tag,
41+
channelId: message.channelId,
42+
createdTimestamp: message.createdTimestamp,
43+
attachmentUrls: message.attachments.map((a) => a.url),
44+
});
45+
}
46+
47+
function cacheMessages(messages: Collection<string, Message>): number {
48+
for (const message of messages.values()) {
49+
cacheMessage(message);
50+
}
51+
return messages.size;
52+
}
53+
54+
async function fetchAndCacheMessages(
55+
channel: TextBasedChannel,
56+
limit: number
57+
): Promise<number> {
58+
let cached = 0;
59+
let before: Snowflake | undefined;
60+
61+
while (cached < limit) {
62+
const batch = await channel.messages.fetch({
63+
limit: Math.min(100, limit - cached),
64+
before
65+
});
66+
if (batch.size === 0) break;
67+
68+
cached += cacheMessages(batch);
69+
before = batch.last()?.id;
70+
71+
if (batch.size < 100) break;
72+
}
73+
74+
return cached;
75+
}
76+
77+
export const DeletedMessagesListener: EventListener = {
78+
async clientReady(client) {
79+
const guild = await client.guilds.fetch(config.guildId);
80+
const channels = await guild.channels.fetch();
81+
82+
for (const channel of channels.values()) {
83+
if (!channel?.isTextBased() || isExcludedChannel(channel.id)) continue;
84+
85+
await messageFetcher.addToQueue(async () => {
86+
try {
87+
const cached = await fetchAndCacheMessages(channel, 200);
88+
logger.info(`Cached ${cached} messages from #${channel.name}`);
89+
} catch {
90+
// Skip channels we can't read (permissions)
91+
}
92+
});
93+
}
94+
},
95+
96+
messageCreate(_, message) {
97+
if (!message.inGuild()) return;
98+
cacheMessage(message);
99+
},
100+
101+
messageUpdate(_, _oldMessage, newMessage) {
102+
if (!newMessage.inGuild()) return;
103+
if (newMessage.partial) return;
104+
cacheMessage(newMessage);
105+
},
106+
107+
async messageDelete(client, message) {
108+
try {
109+
if (isExcludedChannel(message.channelId)) return;
110+
111+
const cached = messageCache.get(message.id);
112+
if (!cached) {
113+
logger.debug(`Deleted message ${message.id} not in cache`);
114+
return;
115+
}
116+
117+
await logDeletedMessage(client, cached);
118+
messageCache.delete(message.id);
119+
} catch (error) {
120+
logger.error("Failed to log deleted message:", error);
121+
Sentry.captureException(error);
122+
}
123+
},
124+
125+
async messageDeleteBulk(client, messages, channel) {
126+
try {
127+
if (isExcludedChannel(channel.id)) return;
128+
129+
const cachedMessages: CachedMessage[] = [];
130+
for (const [id] of messages) {
131+
const cached = messageCache.get(id);
132+
if (cached) {
133+
cachedMessages.push(cached);
134+
messageCache.delete(id);
135+
}
136+
}
137+
138+
if (cachedMessages.length === 0) {
139+
logger.debug(`Bulk deletion in ${channel.id} - no messages in cache`);
140+
return;
141+
}
142+
143+
await logBulkDeletedMessages(client, cachedMessages, channel.id);
144+
} catch (error) {
145+
logger.error("Failed to log bulk deleted messages:", error);
146+
Sentry.captureException(error);
147+
}
148+
},
149+
};

src/modules/moderation/logs.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,20 @@ import {
88
} from "discord.js";
99
import { config } from "../../Config.js";
1010
import { logger } from "../../logging.js";
11+
import { createStandardEmbed } from "../../util/embeds.js";
1112
import { prettyPrintDuration } from "../../util/timespan.js";
1213
import { actualMention, fakeMention } from "../../util/users.js";
14+
import { upload } from "../pastify/pastify.js";
15+
16+
export interface CachedMessage {
17+
id: Snowflake;
18+
content: string;
19+
authorId: Snowflake;
20+
authorTag: string;
21+
channelId: Snowflake;
22+
createdTimestamp: number;
23+
attachmentUrls: string[];
24+
}
1325

1426
export type ModerationLog =
1527
| BanLog
@@ -153,3 +165,83 @@ export async function logModerationAction(
153165
embeds: [embed],
154166
});
155167
}
168+
169+
function formatMessageForPaste(message: CachedMessage): string {
170+
const timestamp = new Date(message.createdTimestamp).toISOString();
171+
const attachments =
172+
message.attachmentUrls.length > 0
173+
? `\nAttachments:\n${message.attachmentUrls.map((url) => ` - ${url}`).join("\n")}`
174+
: "";
175+
176+
return `Author: ${message.authorTag} (${message.authorId})
177+
Created: ${timestamp}
178+
Message ID: ${message.id}
179+
180+
Content:
181+
${message.content || "[No text content]"}${attachments}`;
182+
}
183+
184+
export async function logDeletedMessage(
185+
client: Client,
186+
message: CachedMessage,
187+
) {
188+
const modLogChannel = await client.channels.fetch(config.channels.modLog);
189+
if (!modLogChannel?.isSendable()) {
190+
logger.error("Moderation log channel not sendable");
191+
return;
192+
}
193+
194+
const pasteContent = formatMessageForPaste(message);
195+
const pasteUrl = await upload({ content: pasteContent });
196+
197+
const embed = createStandardEmbed(message.authorId)
198+
.setTitle("Message Deleted")
199+
.setColor("Grey")
200+
.setDescription(
201+
`**Author**: <@${message.authorId}> (${message.authorTag})\n` +
202+
`**Channel**: <#${message.channelId}>\n` +
203+
`**Created**: <t:${Math.round(message.createdTimestamp / 1000)}:R>\n\n` +
204+
`**Content**: [View on Paste](${pasteUrl})`,
205+
)
206+
.setFooter({ text: `Message ID: ${message.id}` })
207+
.setTimestamp();
208+
209+
await modLogChannel.send({ embeds: [embed] });
210+
}
211+
212+
export async function logBulkDeletedMessages(
213+
client: Client,
214+
messages: CachedMessage[],
215+
channelId: Snowflake,
216+
) {
217+
const modLogChannel = await client.channels.fetch(config.channels.modLog);
218+
if (!modLogChannel?.isSendable()) {
219+
logger.error("Moderation log channel not sendable");
220+
return;
221+
}
222+
223+
// Sort by timestamp
224+
messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp);
225+
226+
// Format all messages for paste
227+
const pasteContent = messages
228+
.map((m, i) => {
229+
const separator = i > 0 ? "\n" + "─".repeat(50) + "\n\n" : "";
230+
return separator + formatMessageForPaste(m);
231+
})
232+
.join("\n");
233+
234+
const pasteUrl = await upload({ content: pasteContent });
235+
236+
const embed = new EmbedBuilder()
237+
.setTitle("Bulk Messages Deleted")
238+
.setColor("DarkGrey")
239+
.setDescription(
240+
`**Channel**: <#${channelId}>\n` +
241+
`**Count**: ${messages.length} messages\n\n` +
242+
`**Messages**: [View on Paste](${pasteUrl})`,
243+
)
244+
.setTimestamp();
245+
246+
await modLogChannel.send({ embeds: [embed] });
247+
}

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
};

src/modules/starboard/starboard.listener.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { config } from "../../Config.js";
1111
import { logger } from "../../logging.js";
1212
import { StarboardMessage } from "../../store/models/StarboardMessage.js";
1313
import { getMember } from "../../util/member.js";
14-
import { MessageFetcher } from "../../util/ratelimiting.js";
1514
import type { EventListener } from "../module.js";
1615
import {
1716
createStarboardMessage,
@@ -79,8 +78,6 @@ export const debounceStarboardReaction = (
7978
}, DEBOUNCE_DELAY);
8079
};
8180

82-
const messageFetcher = new MessageFetcher();
83-
8481
const getStarsFromEmbed: (embed: Embed) => number = (embed) => {
8582
const field = embed.fields.find((field) => field.name === "Details:");
8683
if (!field) return 0;
@@ -98,34 +95,7 @@ const isChannelBlacklisted = (channel: Channel): boolean => {
9895

9996
export const StarboardListener: EventListener = {
10097
async clientReady(client) {
101-
for (const guild of client.guilds.cache.values()) {
102-
try {
103-
const channels = await guild.channels.fetch();
104-
for (const channel of channels.values()) {
105-
if (
106-
channel?.isTextBased() &&
107-
channel.id !== config.starboard.channel &&
108-
!isChannelBlacklisted(channel)
109-
) {
110-
// Add to rate-limited queue
111-
await messageFetcher.addToQueue(async () => {
112-
try {
113-
await channel.messages.fetch({ limit: 100 }); // 100 is the maximum allowed by Discord API
114-
logger.info(`Fetched recent messages from #%s`, channel.name);
115-
} catch (error) {
116-
logger.error(
117-
`Error fetching messages from #%s`,
118-
channel.name,
119-
error,
120-
);
121-
}
122-
});
123-
}
124-
}
125-
} catch (error) {
126-
logger.error(`Error processing guild %s:`, guild.name, error);
127-
}
128-
}
98+
// Message fetching handled by DeletedMessagesListener - messages will be in Discord.js cache
12999
let isRunningStarboardCheck = false;
130100
schedule.scheduleJob(
131101
{

src/util/ratelimiting.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,6 @@ export class MessageFetcher {
3232
this.processing = false;
3333
}
3434
}
35+
36+
/** Shared singleton instance for rate-limited message fetching */
37+
export const messageFetcher = new MessageFetcher();

0 commit comments

Comments
 (0)