Skip to content
Open
Changes from 6 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
128 changes: 96 additions & 32 deletions src/events/detectspam.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,61 @@
import {EmbedBuilder, Events, Message, PermissionFlagsBits} from "discord.js";
import {guildDB} from "../db";
import { EmbedBuilder, Events, Message, PermissionFlagsBits } from "discord.js";
import { guildDB } from "../db";
import Colors from "../util/colors";
import Messages from "../util/messages";

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 imported Messages module is not used anywhere in this file. This import should be removed to keep the code clean.

Suggested change
import Messages from "../util/messages";

Copilot uses AI. Check for mistakes.


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");
type Patterns = {
regex: RegExp,
whitelist: string[],
mute: boolean,
Comment thread
zrodevkaan marked this conversation as resolved.
Outdated
predicate: (links: [], self: Patterns) => boolean,
Comment thread
zrodevkaan marked this conversation as resolved.
Outdated
reason: string,
Comment thread
zrodevkaan marked this conversation as resolved.
}

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.
mute: true,
predicate: (links, self) => {
const hosts = links.map(match => {
const url = match[0];
return URL.parse(url)?.host;
}).filter(Boolean);
Comment on lines +18 to +22

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 predicate functions use URL.parse() which is a Node.js API, but the correct constructor in a browser-compatible context (or standard Node.js) would be new URL(). Additionally, URL.parse may not work correctly with partial URLs that don't include the protocol. The regex matches may return strings like "discord.com" without "http://", which will fail to parse. Consider prepending a protocol before parsing or use a different approach to extract the hostname.

Copilot uses AI. Check for mistakes.
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'],
mute: true,
predicate: (links, self) => {
const hosts = links.map(match => {
const url = match[0];
return URL.parse(url)?.host;
});
Comment thread
zrodevkaan marked this conversation as resolved.
Outdated

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,

mute: true,
predicate: (links, self) => links.length > 0,
reason: 'Suspicious .ru.com Domain'
},
{
regex: /(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/ig,
Comment thread
zrodevkaan marked this conversation as resolved.
Outdated
whitelist: [],
mute: true,
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 +71,44 @@ 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.


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 reasons: string[] = [];

let reason = "Sketchy Link";
if (isFakeDiscord) reason = "Fake Discord Link";
if (isFakeSteam) reason = "Fake Steam Link";
for (var pattern of phishingPatterns) {

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.

Using var instead of const or let is an outdated practice in modern TypeScript/JavaScript. Change to const since the pattern variable is not reassigned within the loop.

Suggested change
for (var pattern of phishingPatterns) {
for (const pattern of phishingPatterns) {

Copilot uses AI. Check for mistakes.
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.
}
}

try {
if (reasons.length > 0) {
await message.delete();
Comment thread
zrodevkaan marked this conversation as resolved.
}
catch {
// TODO: logging?
console.error("Could not delete detect spam message. Likely permissions.");
}

Comment thread
zrodevkaan marked this conversation as resolved.
Outdated

let didMute = false;
const muteRoleId = message.guild.roles.cache.findKey(r => r.name.toLowerCase().includes("mute"));
Expand All @@ -67,21 +131,21 @@ export default {
if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; // Can't log

const dEmbed = new EmbedBuilder().setColor(Colors.Info)
.setAuthor({name: message.author.username, iconURL: message.author.displayAvatarURL()})
.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})
.setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp);
await modlogChannel.send({embeds: [dEmbed]});
.addFields({ name: "Reason(s)", value: reasons.join(', ') })
.setFooter({ text: `ID: ${message.author.id}` }).setTimestamp(message.createdTimestamp);
await modlogChannel.send({ embeds: [dEmbed] });


if (didMute) {
const mEmbed = new EmbedBuilder().setColor(Colors.Info)
.setAuthor({name: "Member Muted", iconURL: message.author.displayAvatarURL()})
.setAuthor({ name: "Member Muted", iconURL: message.author.displayAvatarURL() })
.setDescription(`${message.author.displayName} ${message.author.tag}`)
.addFields({name: "Reason", value: reason})
.setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp);
.addFields({ name: "Reason(s)", value: reasons.join(', ') })
.setFooter({ text: `ID: ${message.author.id}` }).setTimestamp(message.createdTimestamp);

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