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,
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,
whitelist: ['discord.com', 'discordapp.com'],
Copy link

Copilot AI Jan 19, 2026

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;
}).filter(Boolean);
return hosts.some(host => !self.whitelist.includes(host));
Copy link

Copilot AI Jan 19, 2026

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.
},
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);
Comment on lines +31 to +35
Copy link

Copilot AI Jan 19, 2026

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.

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

Copilot AI Jan 19, 2026

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.
reason: 'Fake Steam Link'
},
{
regex: /([a-zA-Z-\\.]+)\.ru\.com/ig,
whitelist: [],
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.
Copy link

Copilot AI Jan 19, 2026

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
Copy link

Copilot AI Jan 19, 2026

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',
maxCount: 4
}
] 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.");
}*/

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

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