11import * as Sentry from "@sentry/bun" ;
22import type { GuildMember , Message } from "discord.js" ;
3+ import ExpiryMap from "expiry-map" ;
4+ import { config } from "../../Config.js" ;
35import { logger } from "../../logging.js" ;
46import { getOrCreateUserById } from "../../store/models/DDUser.js" ;
57import { getMember } from "../../util/member.js" ;
@@ -17,11 +19,31 @@ const invitePatterns = [
1719
1820const 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+
2031const 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+
2547export function parseInvites ( message : Message < true > ) {
2648 // Check if message contains any Discord invite
2749 const matches = invitePatterns
@@ -56,6 +78,45 @@ async function sendAuditMessage(
5678const 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+
59120async 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 ) ;
0 commit comments