overhaul automod: fix dead toggles, expose hidden settings, add new filters
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:
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user