Skip to content

Commit 16b126d

Browse files
authored
feat: duplicate message scanner bot (#479)
* feat: duplicate message scanner * test: duplicate scanner caching * chore: disabled bot for star helpers/mvp * chore: limited channels, added logging and cooldown
1 parent 9186ef8 commit 16b126d

File tree

6 files changed

+212
-1
lines changed

6 files changed

+212
-1
lines changed

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"xss": "^1.0.15"
4646
},
4747
"devDependencies": {
48+
"@total-typescript/shoehorn": "^0.1.2",
4849
"@types/node": "20.14.2",
4950
"@types/node-cron": "3.0.11",
5051
"@types/node-fetch": "2.6.11",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { messageDuplicateChecker } from "./duplicate-scanner";
3+
import { User } from "discord.js";
4+
import { fromPartial } from "@total-typescript/shoehorn";
5+
import { duplicateCache } from "./duplicate-scanner";
6+
7+
const maxMessagesPerUser = 5;
8+
const maxCacheSize = 100;
9+
const maxTrivialCharacters = 10;
10+
// Mock dependencies
11+
const mockBot = {
12+
channels: {
13+
fetch: vi.fn().mockResolvedValue({
14+
type: "GUILD_TEXT",
15+
send: vi.fn().mockResolvedValue({
16+
delete: vi.fn().mockResolvedValue(undefined),
17+
}),
18+
}),
19+
},
20+
};
21+
const mockMessage = (content: string, authorId: string, isBot = false) => {
22+
return {
23+
content,
24+
author: { id: authorId, bot: isBot } as User,
25+
delete: vi.fn(),
26+
channel: {
27+
send: vi.fn().mockResolvedValue({
28+
delete: vi.fn().mockResolvedValue(undefined),
29+
}),
30+
},
31+
};
32+
};
33+
describe("Duplicate Scanner Tests", () => {
34+
beforeEach(() => {
35+
// Reset the cache before each test
36+
duplicateCache.clear();
37+
});
38+
it(`should not store messages less than ${maxTrivialCharacters} characters`, async () => {
39+
const msg = mockMessage("Help me", "user1");
40+
const bot = mockBot;
41+
messageDuplicateChecker.handleMessage?.(fromPartial({ msg, bot }));
42+
const userMessages = duplicateCache.get("user1");
43+
expect(userMessages).toBeUndefined();
44+
});
45+
46+
it("should store messages correctly in the cache", async () => {
47+
const msg = mockMessage("Hello world", "user1");
48+
const bot = mockBot;
49+
50+
messageDuplicateChecker.handleMessage?.(fromPartial({ msg, bot }));
51+
52+
const userMessages = duplicateCache.get("user1");
53+
expect(userMessages).toBeDefined();
54+
expect(userMessages?.has("hello world")).toBe(true);
55+
});
56+
57+
it(`should enforce max size of ${maxMessagesPerUser} messages per user`, async () => {
58+
const bot = mockBot;
59+
for (let i = 1; i <= maxMessagesPerUser; i++) {
60+
const msg = mockMessage(`Message to delete ${i}`, "user1");
61+
await messageDuplicateChecker.handleMessage?.(fromPartial({ msg, bot }));
62+
}
63+
64+
const userMessages = duplicateCache.get("user1");
65+
expect(userMessages).toBeDefined();
66+
expect(userMessages?.size).toBe(maxMessagesPerUser);
67+
68+
const msg = mockMessage("New Message", "user1");
69+
await messageDuplicateChecker.handleMessage?.(fromPartial({ msg, bot }));
70+
71+
expect(userMessages?.size).toBe(maxMessagesPerUser);
72+
expect(userMessages?.has("message 1")).toBe(false); // First message should be removed
73+
expect(userMessages?.has("new message")).toBe(true); // New message should be added
74+
});
75+
76+
it(`should enforce max size of ${maxCacheSize} users in the cache`, async () => {
77+
const bot = mockBot;
78+
79+
for (let i = 1; i <= maxCacheSize; i++) {
80+
const msg = mockMessage("Hello world", `user${i}`);
81+
await messageDuplicateChecker.handleMessage?.(fromPartial({ msg, bot }));
82+
}
83+
84+
expect(duplicateCache.size).toBe(maxCacheSize);
85+
86+
const msg = mockMessage("Hello world", "user101");
87+
await messageDuplicateChecker.handleMessage?.(fromPartial({ msg, bot }));
88+
89+
expect(duplicateCache.size).toBe(maxCacheSize);
90+
expect(duplicateCache.has("user1")).toBe(false);
91+
expect(duplicateCache.has("user101")).toBe(true);
92+
});
93+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { ChannelHandlers, HandleMessageArgs } from "../../types/index.js";
2+
import { EmbedType } from "discord.js";
3+
4+
import { EMBED_COLOR } from "../commands.js";
5+
import { LRUCache } from "lru-cache";
6+
import { isStaff, isHelpful } from "../../helpers/discord.js";
7+
import { logger } from "../log.js";
8+
import { truncateMessage } from "../../helpers/modLog.js";
9+
import { formatWithEllipsis } from "./helper.js";
10+
import cooldown from "../cooldown.js";
11+
const maxMessagesPerUser = 5; // Maximum number of messages per user to track
12+
// Time (ms) to keep track of duplicates (e.g., 30 sec)
13+
export const duplicateCache = new LRUCache<string, Set<string>>({
14+
max: 100,
15+
ttl: 1000 * 60 * 0.5,
16+
dispose: (value) => {
17+
value.clear();
18+
},
19+
});
20+
const maxTrivialCharacters = 10;
21+
const removeFirstElement = (messages: Set<string>) => {
22+
const iterator = messages.values();
23+
const firstElement = iterator.next().value;
24+
if (firstElement) {
25+
messages.delete(firstElement);
26+
}
27+
};
28+
29+
const handleDuplicateMessage = async ({
30+
msg,
31+
userId,
32+
}: HandleMessageArgs & { userId: string }) => {
33+
await msg.delete().catch(console.error);
34+
const cooldownKey = `resume-${msg.channelId}`;
35+
if (cooldown.hasCooldown(userId, cooldownKey)) {
36+
return;
37+
}
38+
cooldown.addCooldown(userId, cooldownKey);
39+
const warningMsg = `Hey <@${userId}>, it looks like you've posted this message in another channel already. Please avoid cross-posting.`;
40+
const warning = await msg.channel.send({
41+
embeds: [
42+
{
43+
title: "Duplicate Message Detected",
44+
type: EmbedType.Rich,
45+
description: warningMsg,
46+
color: EMBED_COLOR,
47+
},
48+
],
49+
});
50+
51+
// Auto-delete warning after 30 seconds
52+
setTimeout(() => {
53+
warning.delete().catch(console.error);
54+
}, 30_000);
55+
56+
logger.log(
57+
"duplicate message detected",
58+
`${msg.author.username} in <#${msg.channel.id}> \n${formatWithEllipsis(truncateMessage(msg.content, 100))}`,
59+
);
60+
return;
61+
};
62+
const normalizeContent = (content: string) =>
63+
content.trim().toLowerCase().replace(/\s+/g, " ");
64+
export const messageDuplicateChecker: ChannelHandlers = {
65+
handleMessage: async ({ msg, bot }) => {
66+
if (msg.author.bot || isStaff(msg.member) || isHelpful(msg.member)) return;
67+
68+
const content = normalizeContent(msg.content);
69+
const userId = msg.author.id;
70+
71+
if (content.length < maxTrivialCharacters) return;
72+
73+
const userMessages = duplicateCache.get(userId);
74+
75+
if (!userMessages) {
76+
const messages = new Set<string>();
77+
messages.add(content);
78+
duplicateCache.set(userId, messages);
79+
return;
80+
}
81+
82+
if (userMessages.has(content)) {
83+
await handleDuplicateMessage({ msg, bot, userId });
84+
return;
85+
}
86+
87+
if (userMessages.size >= maxMessagesPerUser) {
88+
removeFirstElement(userMessages);
89+
}
90+
91+
userMessages.add(content);
92+
},
93+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const formatWithEllipsis = (sentences: string): string => {
2+
const ellipsis = "...";
3+
4+
if (sentences.length === 0) {
5+
return ellipsis;
6+
}
7+
if (sentences.charAt(sentences.length - 1) !== ".") {
8+
return sentences + ellipsis;
9+
}
10+
return sentences + "..";
11+
};

src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ import { recommendBookCommand } from "./features/book-list.js";
4242
import { mdnSearch } from "./features/mdn.js";
4343
import "./server.js";
4444
import { jobScanner } from "./features/job-scanner.js";
45+
46+
import { messageDuplicateChecker } from "./features/duplicate-scanner/duplicate-scanner.js";
47+
4548
import { getMessage } from "./helpers/discord.js";
4649

4750
export const bot = new Client({
@@ -228,7 +231,10 @@ const threadChannels = [CHANNELS.helpJs, CHANNELS.helpThreadsReact];
228231
addHandler(threadChannels, autothread);
229232

230233
addHandler(CHANNELS.resumeReview, resumeReviewPdf);
231-
234+
addHandler(
235+
[CHANNELS.helpReact, CHANNELS.generalReact, CHANNELS.generalTech],
236+
messageDuplicateChecker,
237+
);
232238
bot.on("ready", () => {
233239
deployCommands(bot);
234240
jobsMod(bot);

0 commit comments

Comments
 (0)