Skip to content

Commit 40b917c

Browse files
Merge pull request #226 from Pdzly/feature/autoban-invite-spammer
Autoban Invite spammer
2 parents d33e982 + 2412549 commit 40b917c

5 files changed

Lines changed: 119 additions & 0 deletions

File tree

src/Config.prod.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ export const config: Config = {
7272
archiveChannel: "1415283787440328836",
7373
channel: "1415283659350741062",
7474
},
75+
inviteSpam: {
76+
maxViolations: 4,
77+
maxChannels: 3,
78+
violationWindowMs: 30_000,
79+
accountAgeDays: 0,
80+
},
7581
devbin: {
7682
url: "https://devbin.developerden.org",
7783
api_url: "https://devbin-api.developerden.org",

src/Config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ const devConfig: Config = {
2323
commands: {
2424
daily: "1029850807794937949",
2525
},
26+
inviteSpam: {
27+
maxViolations: 4,
28+
maxChannels: 3,
29+
violationWindowMs: 30_000,
30+
accountAgeDays: 0,
31+
},
2632
deletedMessageLog: {
2733
cacheTtlMs: 1000 * 60 * 60 * 24,
2834
excludedChannels: [],

src/config.type.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ export interface ThreatDetectionConfig {
6161
};
6262
}
6363

64+
export interface InviteSpamConfig {
65+
/** Max violations before auto-ban */
66+
maxViolations: number;
67+
/** Max unique channels before auto-ban */
68+
maxChannels: number;
69+
/** Time window in ms for tracking violations */
70+
violationWindowMs: number;
71+
/** Only auto-ban accounts that joined the server less than X days ago. 0 = disabled (everyone is considered) */
72+
accountAgeDays: number;
73+
}
74+
6475
export interface Config {
6576
guildId: string;
6677
clientId: string;
@@ -124,6 +135,7 @@ export interface Config {
124135
branding: BrandingConfig;
125136
informationMessage?: InformationMessage;
126137
threatDetection?: ThreatDetectionConfig;
138+
inviteSpam: InviteSpamConfig;
127139
reputation?: {
128140
enabled: boolean;
129141
warningThresholds: {

src/modules/moderation/discordInvitesMonitor.listener.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as Sentry from "@sentry/bun";
22
import type { GuildMember, Message } from "discord.js";
3+
import ExpiryMap from "expiry-map";
4+
import { config } from "../../Config.js";
35
import { logger } from "../../logging.js";
46
import { getOrCreateUserById } from "../../store/models/DDUser.js";
57
import { getMember } from "../../util/member.js";
@@ -17,11 +19,31 @@ const invitePatterns = [
1719

1820
const whitelistDomains: string[] = []; // For any .gg domains that are not discord.gg
1921

22+
interface InviteViolation {
23+
count: number;
24+
channels: Set<string>;
25+
}
26+
27+
const inviteViolationCache = new ExpiryMap<string, InviteViolation>(
28+
config.inviteSpam.violationWindowMs,
29+
);
30+
2031
const isAllowedToSendDiscordInvites = async (member: GuildMember) => {
2132
const ddUser = await getOrCreateUserById(BigInt(member.id));
2233
return getTierByLevel(ddUser.level) >= 2;
2334
};
2435

36+
const isSubjectToAutoban = (member: GuildMember): boolean => {
37+
if (config.inviteSpam.accountAgeDays === 0) return true;
38+
39+
const joinedAt = member.joinedAt;
40+
if (!joinedAt) return true;
41+
42+
const daysSinceJoin =
43+
(Date.now() - joinedAt.getTime()) / (1000 * 60 * 60 * 24);
44+
return daysSinceJoin < config.inviteSpam.accountAgeDays;
45+
};
46+
2547
export function parseInvites(message: Message<true>) {
2648
// Check if message contains any Discord invite
2749
const matches = invitePatterns
@@ -56,6 +78,45 @@ async function sendAuditMessage(
5678
const noInvitesAllowedMessage = (member: GuildMember) =>
5779
`${actualMention(member)}, only Users with Tier 2 or over are allowed to send Discord invites.\nPlease remove the invite before sending it again.\nThank you!`;
5880

81+
async function banForInviteSpam(
82+
message: Message<true>,
83+
member: GuildMember,
84+
violation: InviteViolation,
85+
) {
86+
const triggerReason =
87+
violation.channels.size >= config.inviteSpam.maxChannels
88+
? "cross_channel"
89+
: "same_channel";
90+
91+
try {
92+
await member
93+
.send(
94+
"You have been banned for spamming Discord invites. " +
95+
"If you believe this was a mistake, please contact a moderator.",
96+
)
97+
.catch(() => {});
98+
99+
await message.guild.bans.create(member.user, {
100+
reason: `Auto-ban: Invite spam (${violation.count} violations across ${violation.channels.size} channel(s) in ${config.inviteSpam.violationWindowMs / 1000}s)`,
101+
deleteMessageSeconds: 604800,
102+
});
103+
104+
await logModerationAction(message.client, {
105+
kind: "InviteSpamBan",
106+
target: member.user,
107+
violationCount: violation.count,
108+
channelCount: violation.channels.size,
109+
violationWindowMs: config.inviteSpam.violationWindowMs,
110+
triggerReason,
111+
});
112+
113+
inviteViolationCache.delete(member.id);
114+
} catch (error) {
115+
logger.error("Failed to ban invite spammer:", error);
116+
Sentry.captureException(error);
117+
}
118+
}
119+
59120
async function handleInvite(
60121
message: Message<true>,
61122
member: GuildMember,
@@ -74,6 +135,23 @@ async function handleInvite(
74135
}, 10000);
75136

76137
await sendAuditMessage(message, member, matches, wasEdit);
138+
139+
const existing = inviteViolationCache.get(member.id);
140+
const violation: InviteViolation = existing ?? {
141+
count: 0,
142+
channels: new Set(),
143+
};
144+
violation.count++;
145+
violation.channels.add(message.channelId);
146+
inviteViolationCache.set(member.id, violation);
147+
148+
const shouldBan =
149+
violation.count >= config.inviteSpam.maxViolations ||
150+
violation.channels.size >= config.inviteSpam.maxChannels;
151+
152+
if (shouldBan && isSubjectToAutoban(member)) {
153+
await banForInviteSpam(message, member, violation);
154+
}
77155
} catch (error) {
78156
logger.error("Failed to delete message with Discord invite:", error);
79157
Sentry.captureException(error);

src/modules/moderation/logs.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type ModerationLog =
3131
| SoftBanLog
3232
| KickLog
3333
| InviteDeletedLog
34+
| InviteSpamBanLog
3435
| WarningLog
3536
| WarningPardonedLog
3637
| ReputationGrantedLog;
@@ -115,6 +116,15 @@ interface ReputationGrantedLog {
115116
reason: string;
116117
}
117118

119+
interface InviteSpamBanLog {
120+
kind: "InviteSpamBan";
121+
target: User;
122+
violationCount: number;
123+
channelCount: number;
124+
violationWindowMs: number;
125+
triggerReason: "same_channel" | "cross_channel";
126+
}
127+
118128
type ModerationKindMapping<T> = {
119129
[f in ModerationLog["kind"]]: T;
120130
};
@@ -124,6 +134,7 @@ const embedTitles: ModerationKindMapping<string> = {
124134
Unban: "Member Unbanned",
125135
SoftBan: "Member Softbanned",
126136
InviteDeleted: "Discord Invite Removed",
137+
InviteSpamBan: "Member Auto-Banned (Invite Spam)",
127138
TempBan: "Member Tempbanned",
128139
Kick: "Member Kicked",
129140
TempBanEnded: "Tempban Expired",
@@ -140,6 +151,7 @@ const embedColors: ModerationKindMapping<keyof typeof Colors> = {
140151
Unban: "Green",
141152
TempBanEnded: "DarkGreen",
142153
InviteDeleted: "Blurple",
154+
InviteSpamBan: "DarkRed",
143155
Warning: "Gold",
144156
WarningPardoned: "Aqua",
145157
ReputationGranted: "Green",
@@ -181,6 +193,11 @@ const embedReasons: {
181193
`**Type:** ${rep.eventType}\n` +
182194
`**Score Change:** +${rep.scoreChange}\n` +
183195
`**New Score:** ${rep.newScore >= 0 ? "+" : ""}${rep.newScore}`,
196+
197+
InviteSpamBan: (ban) =>
198+
`**Violations:** ${ban.violationCount} in ${ban.violationWindowMs / 1000}s\n` +
199+
`**Channels:** ${ban.channelCount}\n` +
200+
`**Trigger:** ${ban.triggerReason === "same_channel" ? "4+ violations in same channel" : "Violations across 3+ channels"}`,
184201
};
185202

186203
export async function logModerationAction(

0 commit comments

Comments
 (0)