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

@@ -1,8 +1,10 @@
import { Card, CardContent, CardHeader, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Shield, Link, Ban, AlertTriangle, Save, Info } from 'lucide-react';
import { useState } from 'react';
import { Card, CardContent, CardHeader, Input, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Shield, Link2, Ban, AlertTriangle, Save, Info, MailWarning, AtSign, CaseUpper, X, UserPlus } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { RoleSelect } from '../components/shared/RoleSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { useGuildResources } from '../hooks/useGuildResources';
@@ -15,9 +17,27 @@ const FILTERS = [
},
{
key: 'linkFilter' as const,
icon: <Link size={16} />,
icon: <Link2 size={16} />,
title: 'Link-Filter',
description: 'Blockiert bekannte schädliche Domains und nicht-whitelistete Links.',
description: 'Blockiert Links, die nicht auf der Whitelist stehen.',
},
{
key: 'inviteFilter' as const,
icon: <MailWarning size={16} />,
title: 'Einladungslink-Filter',
description: 'Blockiert Discord-Invites unabhängig vom Link-Filter.',
},
{
key: 'mentionSpamFilter' as const,
icon: <AtSign size={16} />,
title: 'Mass-Mention-Schutz',
description: 'Verhindert Raid-artiges Massen-Pingen von Usern/Rollen.',
},
{
key: 'capsFilter' as const,
icon: <CaseUpper size={16} />,
title: 'Caps-Filter',
description: 'Entfernt Nachrichten mit überwiegend GROSSBUCHSTABEN.',
},
{
key: 'spamFilter' as const,
@@ -27,9 +47,26 @@ const FILTERS = [
},
];
function toList(value?: string) {
return (value || '').split(',').map((x) => x.trim()).filter(Boolean);
}
export function Automod() {
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
const { channels, roles } = useGuildResources(currentGuildId);
const [roleDraft, setRoleDraft] = useState('');
const cfg = settings.automodConfig || {};
const whitelistRoles: string[] = cfg.whitelistRoles || [];
const patchConfig = (patch: Record<string, any>) =>
setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), ...patch } }));
const addWhitelistRole = () => {
if (!roleDraft || whitelistRoles.includes(roleDraft)) return;
patchConfig({ whitelistRoles: [...whitelistRoles, roleDraft] });
setRoleDraft('');
};
return (
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
@@ -56,7 +93,8 @@ export function Automod() {
<div className="flex flex-col gap-2">
{FILTERS.map((f) => (
<div key={f.key} className="bg-surface-secondary flex items-center gap-3 rounded-xl p-3">
<div key={f.key} className="bg-surface-secondary rounded-xl p-3">
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-default-soft text-muted">
{f.icon}
</div>
@@ -66,10 +104,70 @@ export function Automod() {
</div>
<AppSwitch
aria-label={f.title}
isSelected={settings.automodConfig?.[f.key] ?? false}
onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), [f.key]: v } }))}
isSelected={cfg[f.key] ?? true}
onChange={(v) => patchConfig({ [f.key]: v })}
/>
</div>
{f.key === 'linkFilter' && (cfg.linkFilter ?? true) && (
<div className="mt-3 border-t border-border pt-3">
<TextField>
<Label className="text-xs">Whitelist Links (Komma-getrennt)</Label>
<TextArea
value={(cfg.linkWhitelist || []).join(', ')}
onChange={(e) => patchConfig({ linkWhitelist: toList(e.target.value) })}
placeholder="trusted-domain.com, another-safe.site"
/>
</TextField>
</div>
)}
{f.key === 'mentionSpamFilter' && (cfg.mentionSpamFilter ?? true) && (
<div className="mt-3 flex items-end gap-2 border-t border-border pt-3">
<TextField className="w-56">
<Label className="text-xs">Max. Erwähnungen pro Nachricht</Label>
<Input
type="number"
min="2"
value={String(cfg.maxMentions ?? 5)}
onChange={(e) => patchConfig({ maxMentions: Number(e.target.value || 5) })}
/>
</TextField>
</div>
)}
{f.key === 'spamFilter' && (cfg.spamFilter ?? true) && (
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border pt-3">
<TextField>
<Label className="text-xs">Nachrichten</Label>
<Input
type="number"
min="2"
value={String(cfg.spamThreshold ?? 5)}
onChange={(e) => patchConfig({ spamThreshold: Number(e.target.value || 5) })}
/>
</TextField>
<TextField>
<Label className="text-xs">Zeitfenster (Sek.)</Label>
<Input
type="number"
min="1"
value={String(Math.round((cfg.windowMs ?? 7000) / 1000))}
onChange={(e) => patchConfig({ windowMs: Number(e.target.value || 7) * 1000 })}
/>
</TextField>
<TextField>
<Label className="text-xs">Timeout (Min.)</Label>
<Input
type="number"
min="1"
value={String(cfg.spamTimeoutMinutes ?? 10)}
onChange={(e) => patchConfig({ spamTimeoutMinutes: Number(e.target.value || 10) })}
/>
</TextField>
</div>
)}
</div>
))}
</div>
@@ -77,30 +175,73 @@ export function Automod() {
<Label>Log Channel</Label>
<ChannelSelect
options={channels}
value={settings.automodConfig?.logChannelId}
onChange={(id) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), logChannelId: id } }))}
value={cfg.logChannelId}
onChange={(id) => patchConfig({ logChannelId: id })}
placeholder="Channel für Automod-Logs wählen"
/>
</TextField>
<TextField>
<Label>Whitelist Links (Komma-getrennt)</Label>
<TextArea
value={(settings.automodConfig?.linkWhitelist || []).join(', ')}
onChange={(e) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), linkWhitelist: e.target.value.split(',').map((x) => x.trim()).filter(Boolean) } }))}
placeholder="trusted-domain.com, another-safe.site"
/>
</TextField>
<Separator />
<Button variant="primary" onPress={() => saveSettingsPayload({ automodEnabled: settings.automodEnabled !== false, automodConfig: settings.automodConfig || {} }, 'Automod gespeichert')}>
<Button variant="primary" onPress={() => saveSettingsPayload({ automodEnabled: settings.automodEnabled !== false, automodConfig: cfg }, 'Automod gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Eigene Bad-Words</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
<TextArea
value={(cfg.customBadwords || []).join(', ')}
onChange={(e) => patchConfig({ customBadwords: toList(e.target.value) })}
placeholder="wort1, wort2, wort3"
/>
<p className="text-xs text-muted">Zusätzlich zur eingebauten Standard-Liste, Komma-getrennt.</p>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Rollen-Whitelist</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<p className="text-xs text-muted">Mitglieder mit diesen Rollen werden von Automod komplett ignoriert.</p>
<div className="flex flex-wrap gap-2">
{whitelistRoles.length ? whitelistRoles.map((roleId) => {
const role = roles.find((r) => r.id === roleId);
return (
<div key={roleId} className="bg-default-soft flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-medium">
{role?.color && <span className="size-2 rounded-full" style={{ backgroundColor: role.color }} />}
{role?.name || roleId}
<button
type="button"
className="flex size-4 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => patchConfig({ whitelistRoles: whitelistRoles.filter((id) => id !== roleId) })}
>
<X size={11} />
</button>
</div>
);
}) : <p className="text-xs text-muted">Keine Rollen ausgenommen</p>}
</div>
<div className="flex gap-2">
<RoleSelect
options={roles.filter((r) => !whitelistRoles.includes(r.id))}
value={roleDraft}
onChange={setRoleDraft}
placeholder="Rolle wählen"
/>
<Button size="sm" variant="tertiary" className="shrink-0" onPress={addWhitelistRole} isDisabled={!roleDraft}>
<UserPlus size={14} /> Hinzufügen
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-start gap-3 p-4">
<Info size={16} className="mt-0.5 shrink-0 text-accent" />

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