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,19 +93,80 @@ 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 className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-default-soft text-muted">
{f.icon}
<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>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{f.title}</div>
<div className="text-xs text-muted">{f.description}</div>
</div>
<AppSwitch
aria-label={f.title}
isSelected={cfg[f.key] ?? true}
onChange={(v) => patchConfig({ [f.key]: v })}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{f.title}</div>
<div className="text-xs text-muted">{f.description}</div>
</div>
<AppSwitch
aria-label={f.title}
isSelected={settings.automodConfig?.[f.key] ?? false}
onChange={(v) => setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), [f.key]: v } }))}
/>
{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" />