Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 94 additions & 25 deletions src/events/detectspam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,62 @@ import {EmbedBuilder, Events, Message, PermissionFlagsBits} from "discord.js";
import {guildDB} from "../db";
import Colors from "../util/colors";

type Patterns = {
regex: RegExp,
whitelist: string[],
predicate: (links: RegExpMatchArray[], self: Patterns) => boolean,
reason: string,
Comment thread
zrodevkaan marked this conversation as resolved.
maxCount?: number,
}

const fakeDiscordRegex = new RegExp(`([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\\.(com|net|app|gift|ru|uk)`, "ig");
const okayDiscordRegex = new RegExp(`([a-zA-Z-\\.]+\\.)?discord((?:app)|(?:status))?\\.(com|net|app)`, "i");
const fakeSteamRegex = new RegExp(`str?e[ea]?mcomm?m?un[un]?[un]?[tl]?[il][tl]?ty\\.(com|net|ru|us)`, "ig");
const sketchyRuRegex = new RegExp(`([a-zA-Z-\\.]+).ru.com`, "ig");
const phishingPatterns = [
{
regex: /([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\.(com|net|app|gift|ru|uk)/ig,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In a regular expression literal, \\ represents a literal backslash. Therefore, -\\ inside the character class [a-zA-Z-\\.] creates a character range from - (ASCII 45) to \\ (ASCII 92). This range matches many unintended characters such as numbers, colons, slashes, and uppercase letters, which can lead to false positives.\n\nUsing [a-zA-Z.-]+ (placing the hyphen at the end of the character class) correctly matches only letters, dots, and hyphens without creating an unintended range.

Suggested change
regex: /([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\.(com|net|app|gift|ru|uk)/ig,
regex: /([a-zA-Z.-]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z.-]+)?\\.(com|net|app|gift|ru|uk)/ig,

whitelist: ['discord.com', 'discordapp.com'],

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern will incorrectly match 'betterdiscord.app' which was explicitly whitelisted in the old code. Add 'betterdiscord.app' to the whitelist array to maintain the same behavior as the previous implementation.

Suggested change
whitelist: ['discord.com', 'discordapp.com'],
whitelist: ['discord.com', 'discordapp.com', 'betterdiscord.app'],

Copilot uses AI. Check for mistakes.
predicate: (links, self) => {
const hosts = links.map(match => {
const url = match[0];
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
return URL.parse(fullUrl)?.host;
Comment thread
zrodevkaan marked this conversation as resolved.
}).filter(Boolean);
Comment thread
zrodevkaan marked this conversation as resolved.
Comment on lines +18 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

URL.parse is a relatively new method introduced in Node.js v22.0.0 and v21.2.0. If this bot is deployed on an older LTS version of Node.js (such as v18 or v20), calling URL.parse will throw a TypeError and crash the application.\n\nUsing new URL() wrapped in a try/catch block is a safer, backward-compatible approach.

            const hosts = links.map(match => {\n                const url = match[0];\n                const fullUrl = url.startsWith('http') ? url : 'https://' + url;\n                try {\n                    return new URL(fullUrl).host;\n                } catch {\n                    return null;\n                }\n            }).filter(Boolean) as string[];

return hosts.some(host => !self.whitelist.includes(host));

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whitelist check should use the full hostname, not just the host. URL.parse().host may include the port number, which could cause legitimate URLs with ports to be flagged. Consider comparing against hostname instead or normalize the comparison.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using self.whitelist.includes(host) will cause legitimate subdomains of whitelisted domains (such as canary.discord.com, ptb.discord.com, or media.discordapp.com) to be flagged as fake domains. This will result in false positives where legitimate messages are deleted and users are muted.\n\nChecking if the host matches exactly or ends with . + the allowed domain resolves this issue.

Suggested change
return hosts.some(host => !self.whitelist.includes(host));
return hosts.some(host => !self.whitelist.some(allowed => host === allowed || host.endsWith('.' + allowed)));

},
reason: 'Fake Discord Domain'
},
{
regex: /str?e[ea]?mcomm?m?un[un]?[un]?[tl]?[il][tl]?ty\.(com|net|ru|us)/ig,
whitelist: ['steamcommunity.com'],
predicate: (links, self) => {
const hosts = links.map(match => {
const url = match[0];
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
return URL.parse(fullUrl)?.host;
Comment thread
zrodevkaan marked this conversation as resolved.
}).filter(Boolean);
Comment on lines +31 to +35

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for URL parsing. If the URL is malformed, URL.parse or new URL() will throw an error. Wrap the URL parsing in a try-catch block to prevent the entire spam detection from failing on malformed URLs.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

URL.parse is a relatively new method introduced in Node.js v22.0.0 and v21.2.0. If this bot is deployed on an older LTS version of Node.js (such as v18 or v20), calling URL.parse will throw a TypeError and crash the application.\n\nUsing new URL() wrapped in a try/catch block is a safer, backward-compatible approach.

            const hosts = links.map(match => {\n                const url = match[0];\n                const fullUrl = url.startsWith('http') ? url : 'https://' + url;\n                try {\n                    return new URL(fullUrl).host;\n                } catch {\n                    return null;\n                }\n            }).filter(Boolean) as string[];


return hosts.some(host => !self.whitelist.includes(host));
},
Comment on lines +13 to +38

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This predicate function is duplicated in the second pattern (lines 30-37). Consider extracting this into a shared helper function to reduce code duplication and improve maintainability.

Suggested change
const phishingPatterns = [
{
regex: /([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\.(com|net|app|gift|ru|uk)/ig,
whitelist: ['discord.com', 'discordapp.com'],
predicate: (links, self) => {
const hosts = links.map(match => {
const url = match[0];
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
return URL.parse(fullUrl)?.host;
}).filter(Boolean);
return hosts.some(host => !self.whitelist.includes(host));
},
reason: 'Fake Discord Domain'
},
{
regex: /str?e[ea]?mcomm?m?un[un]?[un]?[tl]?[il][tl]?ty\.(com|net|ru|us)/ig,
whitelist: ['steamcommunity.com'],
predicate: (links, self) => {
const hosts = links.map(match => {
const url = match[0];
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
return URL.parse(fullUrl)?.host;
}).filter(Boolean);
return hosts.some(host => !self.whitelist.includes(host));
},
function hasNonWhitelistedHost(links: RegExpMatchArray[], whitelist: string[]): boolean {
const hosts = links.map(match => {
const url = match[0];
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
return URL.parse(fullUrl)?.host;
}).filter(Boolean);
return hosts.some(host => !whitelist.includes(host as string));
}
const phishingPatterns = [
{
regex: /([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\.(com|net|app|gift|ru|uk)/ig,
whitelist: ['discord.com', 'discordapp.com'],
predicate: (links, self) => hasNonWhitelistedHost(links, self.whitelist),
reason: 'Fake Discord Domain'
},
{
regex: /str?e[ea]?mcomm?m?un[un]?[un]?[tl]?[il][tl]?ty\.(com|net|ru|us)/ig,
whitelist: ['steamcommunity.com'],
predicate: (links, self) => hasNonWhitelistedHost(links, self.whitelist),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using self.whitelist.includes(host) will cause legitimate subdomains of whitelisted domains (such as help.steamcommunity.com) to be flagged as fake domains.\n\nChecking if the host matches exactly or ends with . + the allowed domain resolves this issue.

            return hosts.some(host => !self.whitelist.some(allowed => host === allowed || host.endsWith('.' + allowed)));

reason: 'Fake Steam Link'
},
{
regex: /([a-zA-Z-\\.]+)\.ru\.com/ig,
whitelist: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In a regular expression literal, \\ represents a literal backslash. Therefore, -\\ inside the character class [a-zA-Z-\\.] creates a character range from - (ASCII 45) to \\ (ASCII 92). This range matches many unintended characters such as numbers, colons, slashes, and uppercase letters, which can lead to false positives.\n\nUsing [a-zA-Z.-]+ (placing the hyphen at the end of the character class) correctly matches only letters, dots, and hyphens without creating an unintended range.

Suggested change
whitelist: [],
regex: /([a-zA-Z.-]+)\\.ru\\.com/ig,

predicate: (links) => links.length > 0,
reason: 'Suspicious .ru.com Domain'
},
{
regex: /nsfwcord/ig, // new recent scam
whitelist: [],
predicate: (links) => links.length > 0,
reason: 'Sex bot scam'
},
{
regex: /(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&\/=]*/ig,
whitelist: [],
predicate: (links, self) => links.length == self.maxCount, // this should probably be more than 4 later on.
Comment thread
zrodevkaan marked this conversation as resolved.

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The equality check should use strict equality. Replace '==' with '===' to avoid type coercion issues and follow JavaScript best practices.

Suggested change
predicate: (links, self) => links.length == self.maxCount, // this should probably be more than 4 later on.
predicate: (links, self) => links.length === self.maxCount, // this should probably be more than 4 later on.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +56

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generic URL regex pattern is too broad and will match many legitimate URLs. It will trigger on any message containing 4+ URLs regardless of their legitimacy. Consider adding common legitimate domains to the whitelist or refining this pattern to avoid false positives.

Suggested change
whitelist: [],
predicate: (links, self) => links.length == self.maxCount, // this should probably be more than 4 later on.
whitelist: [
'discord.com',
'discordapp.com',
'steamcommunity.com',
'github.com',
'gitlab.com',
'bitbucket.org',
'google.com',
'youtube.com',
'youtu.be',
'twitch.tv',
'twitter.com',
'x.com',
'reddit.com'
],
predicate: (links, self) => {
const suspiciousLinks = links.filter(match => {
const url = match[0].toLowerCase();
return !self.whitelist.some(domain => url.includes(domain));
});
return suspiciousLinks.length === self.maxCount; // this should probably be more than 4 later on.
},

Copilot uses AI. Check for mistakes.
reason: 'Potential Scam Message',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using links.length == self.maxCount means that if a message contains more than 4 links (e.g., 5 or 10 links), the predicate will evaluate to false, allowing the spam message to bypass the filter.\n\nChanging this to >= ensures that any message with 4 or more links is caught.

Suggested change
reason: 'Potential Scam Message',
predicate: (links, self) => !!self.maxCount && links.length >= self.maxCount, // this should probably be more than 4 later on.

maxCount: 4
Comment thread
zrodevkaan marked this conversation as resolved.
}
] as Patterns[]

// TODO: consider de-duping with invitefilter event
export default {
Expand All @@ -22,29 +73,48 @@ export default {
const current = await guildDB.get(message.guild.id) ?? {};
if (!current?.detectspam) return;

/*
const fakeDiscordMatches = message.content.match(fakeDiscordRegex) || [];
const fakeSteamMatches = message.content.match(fakeSteamRegex) || [];
const isFakeDiscord = fakeDiscordMatches.some(s => {
if (okayDiscordRegex.test(s)) return false;
else if (s.toLowerCase() === "betterdiscord.app") return false;
return true;
});
const isFakeSteam = fakeSteamMatches.some(s => s.toLowerCase() !== "steamcommunity.com");
const isSketchy = sketchyRuRegex.test(message.content);
if (!isFakeDiscord && !isFakeSteam && !isSketchy) return; // Not spam, let's get out of here

let reason = "Sketchy Link";
if (isFakeDiscord) reason = "Fake Discord Link";
if (isFakeSteam) reason = "Fake Steam Link";

try {
await message.delete();
}
catch {
// TODO: logging?
console.error("Could not delete detect spam message. Likely permissions.");
}*/
Comment on lines +76 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Leaving large blocks of commented-out dead code reduces readability and maintainability. Since Git history preserves the previous implementation, this commented-out block should be removed.


let reasons: string[] = [];

const fakeDiscordMatches = message.content.match(fakeDiscordRegex) || [];
const fakeSteamMatches = message.content.match(fakeSteamRegex) || [];
const isFakeDiscord = fakeDiscordMatches.some(s => {
if (okayDiscordRegex.test(s)) return false;
else if (s.toLowerCase() === "betterdiscord.app") return false;
return true;
});
const isFakeSteam = fakeSteamMatches.some(s => s.toLowerCase() !== "steamcommunity.com");
const isSketchy = sketchyRuRegex.test(message.content);
if (!isFakeDiscord && !isFakeSteam && !isSketchy) return; // Not spam, let's get out of here
for (const pattern of phishingPatterns) {
const links = Array.from(message.content.matchAll(pattern.regex))
const shouldReason = pattern.predicate(links, pattern)
if (shouldReason) {
reasons.push(pattern.reason)
Comment thread
zrodevkaan marked this conversation as resolved.
Comment thread
zrodevkaan marked this conversation as resolved.
}
}

let reason = "Sketchy Link";
if (isFakeDiscord) reason = "Fake Discord Link";
if (isFakeSteam) reason = "Fake Steam Link";
if (reasons.length === 0) return;

try {
await message.delete();
Comment thread
zrodevkaan marked this conversation as resolved.
} catch (e) {
console.error("Could not delete message. Likely deleted or no permissions.", e);
}
catch {
// TODO: logging?
console.error("Could not delete detect spam message. Likely permissions.");
}


let didMute = false;
const muteRoleId = message.guild.roles.cache.findKey(r => r.name.toLowerCase().includes("mute"));
Expand All @@ -54,8 +124,7 @@ export default {
try {
await member.roles.add(muteRoleId);
didMute = true;
}
catch {
} catch {
// TODO: logging?
console.error("Could not add mute role. Likely permissions.");
}
Expand All @@ -69,7 +138,7 @@ export default {
const dEmbed = new EmbedBuilder().setColor(Colors.Info)
.setAuthor({name: message.author.username, iconURL: message.author.displayAvatarURL()})
.setDescription(`Message sent by ${message.author.username} in ${message.channel.name}\n\n` + message.content)
.addFields({name: "Reason", value: reason})
.addFields({name: "Reason(s)", value: reasons.join(', ')})
.setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp);
await modlogChannel.send({embeds: [dEmbed]});

Expand All @@ -78,7 +147,7 @@ export default {
const mEmbed = new EmbedBuilder().setColor(Colors.Info)
.setAuthor({name: "Member Muted", iconURL: message.author.displayAvatarURL()})
.setDescription(`${message.author.displayName} ${message.author.tag}`)
.addFields({name: "Reason", value: reason})
.addFields({name: "Reason(s)", value: reasons.join(', ')})
.setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp);

await modlogChannel.send({embeds: [mEmbed]});
Expand Down
Loading