overhaul automod: fix dead toggles, expose hidden settings, add new filters
All checks were successful
Deploy Discord Bot / deploy (push) Successful in -1m12s
SonarQube / sonar (push) Successful in -20s

Link-filter and spam-filter toggles in the dashboard wrote to config keys
the backend never read, so both filters always ran regardless of the
switch. Fixed by reading the same field names on both sides.

Also surfaces backend features that already existed but had no UI: caps
filter, a custom bad-word list, a role whitelist (members with these
roles are ignored entirely), and tunable spam thresholds/window/timeout.
Each filter now shows its relevant settings inline instead of hiding them
in a separate generic panel.

Adds two new detections: an invite-link filter separate from the general
link filter, and mass-mention/raid protection with a configurable limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Pepe44DEV
2026-07-02 22:49:28 +02:00
parent 3c31832a0d
commit fe41bfdd88
2 changed files with 219 additions and 38 deletions

View File

@@ -8,11 +8,17 @@ export interface AutomodConfig {
windowMs?: number;
linkWhitelist?: string[];
spamTimeoutMinutes?: number;
/** @deprecated use linkFilter */
deleteLinks?: boolean;
linkFilter?: boolean;
inviteFilter?: boolean;
spamFilter?: boolean;
badWordFilter?: boolean;
capsFilter?: boolean;
customBadwords?: string[];
whitelistRoles?: string[];
mentionSpamFilter?: boolean;
maxMentions?: number;
logChannelId?: string;
loggingConfig?: {
logChannelId?: string;
@@ -31,13 +37,18 @@ export class AutoModService {
windowMs: 7000,
linkWhitelist: [],
spamTimeoutMinutes: 10,
deleteLinks: true,
linkFilter: true,
inviteFilter: true,
spamFilter: true,
badWordFilter: true,
capsFilter: false,
customBadwords: [],
whitelistRoles: []
whitelistRoles: [],
mentionSpamFilter: true,
maxMentions: 5
};
private defaultBadwords = ['badword', 'spamword'];
private inviteRegex = /(discord\.gg\/|discord(?:app)?\.com\/invite\/)[a-z0-9-]+/i;
constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {}
@@ -53,7 +64,27 @@ export class AutoModService {
if (allowed) return;
}
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
if (config.mentionSpamFilter !== false && this.exceedsMentionLimit(message, config.maxMentions)) {
const count = message.mentions.users.size + message.mentions.roles.size;
const limit = config.maxMentions ?? this.defaults.maxMentions!;
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Mass-Mention').catch(() => undefined);
await this.deleteMessageWithReason(message, `${message.author}, bitte nicht so viele User/Rollen auf einmal erwähnen.`);
logger.warn(`Timed out ${message.author.tag} for mass-mention`);
await this.logAutomodAction(message, config, 'mention_spam', `${count}/${limit} Erwähnungen in einer Nachricht`);
return true;
}
const linkFilterOn = config.linkFilter ?? config.deleteLinks ?? true;
const linkMatch = this.matchLink(message.content);
if (linkMatch && config.inviteFilter !== false && this.inviteRegex.test(linkMatch)) {
await this.deleteMessageWithReason(message, `${message.author}, Einladungslinks sind hier nicht erlaubt.`);
logger.info(`Deleted invite link from ${message.author.tag}`);
await this.logAutomodAction(message, config, 'invite_filter', 'Discord-Einladungslink erkannt', message.content);
return true;
}
if (this.linkFilterEnabled && linkFilterOn && linkMatch && !this.isWhitelisted(linkMatch, config.linkWhitelist)) {
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
logger.info(`Deleted link from ${message.author.tag}`);
@@ -78,7 +109,8 @@ export class AutoModService {
}
}
if (this.antiSpamEnabled) {
const spamFilterOn = config.spamFilter ?? true;
if (this.antiSpamEnabled && spamFilterOn) {
const now = Date.now();
const tracker = this.spamTracker.get(message.author.id) ?? { count: 0, lastMessage: now };
if (now - tracker.lastMessage < (config.windowMs ?? this.windowMs)) {
@@ -104,6 +136,12 @@ export class AutoModService {
return false;
}
private exceedsMentionLimit(message: Message, maxMentions?: number) {
const limit = maxMentions ?? this.defaults.maxMentions!;
const count = message.mentions.users.size + message.mentions.roles.size;
return count >= limit;
}
private containsBadword(content: string, custom: string[] = []) {
const combined = [...this.defaultBadwords, ...(custom || [])]
.map((w) => w?.toString().trim().toLowerCase())
@@ -118,13 +156,15 @@ export class AutoModService {
});
}
private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
private matchLink(content: string): string | null {
// Match common link formats, even without protocol
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}\/?[^\s]*)/i.exec(content);
if (!match) return false;
const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w));
return match ? match[0].toLowerCase() : null;
}
private isWhitelisted(url: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
return normalized.some((w) => url.includes(w));
}
private async deleteMessageWithReason(message: Message, response: string) {