overhaul automod escalation and register applications, restore orange theme
Automod: per-filter actions (warn/timeout/kick/ban), channel/role exemptions, regex/severity badword rules, and a strike/escalation system backed by a new AutomodStrike table. Register: fixes missing answer labels, adds internal reviewer notes and cross-form application history for a user, and exposes form/status filters in the dashboard. Dashboard: overrides HeroUI's default blue accent token with the bot's established orange (#f97316), which was lost during the HeroUI v3 migration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,16 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root,
|
||||
.light,
|
||||
.dark {
|
||||
/* Brand accent: matches the orange used across bot embeds and the login page
|
||||
(see 0xf97316 in src/services/*.ts and src/web/server.ts), overriding
|
||||
HeroUI's blue default theme token. */
|
||||
--accent: #f97316;
|
||||
--accent-foreground: var(--snow);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* HeroUI ships fields with a transparent border and 0 border-width by default,
|
||||
so an Input on a plain Card is invisible (identical bg, no outline). Give
|
||||
|
||||
@@ -37,6 +37,13 @@ type AppState = {
|
||||
registerForms: RegisterForm[];
|
||||
registerApps: RegisterApplication[];
|
||||
musicStatus: { activeGuilds: number; sessions: MusicSession[] };
|
||||
automodStrikes: { userId: string; count: number; lastAt: string; reasons: string[] }[];
|
||||
registerStatusFilter: string;
|
||||
registerFormFilter: string;
|
||||
selectedAppId: string | null;
|
||||
appNotes: any[];
|
||||
appHistory: any[];
|
||||
noteDraft: string;
|
||||
};
|
||||
|
||||
type AppContextType = AppState & {
|
||||
@@ -105,6 +112,14 @@ type AppContextType = AppState & {
|
||||
setKbEditDraft: (s: any | ((prev: any) => any)) => void;
|
||||
automationEditDraft: any;
|
||||
setAutomationEditDraft: (s: any | ((prev: any) => any)) => void;
|
||||
loadAutomodStrikes: () => Promise<void>;
|
||||
resetAutomodStrike: (userId: string) => Promise<void>;
|
||||
setRegisterStatusFilter: (v: string) => void;
|
||||
setRegisterFormFilter: (v: string) => void;
|
||||
loadRegisterApps: (overrides?: { status?: string; formId?: string }) => Promise<void>;
|
||||
openAppDetail: (id: string) => Promise<void>;
|
||||
setNoteDraft: (v: string) => void;
|
||||
addAppNote: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
@@ -153,6 +168,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [statsItemDraft, setStatsItemDraft] = useState<{ id?: string; label: string; type: string }>({ label: '', type: 'members' });
|
||||
const [ticketDetail, setTicketDetail] = useState<TicketRecord | null>(null);
|
||||
const [ticketMessages, setTicketMessages] = useState<any[]>([]);
|
||||
const [automodStrikes, setAutomodStrikes] = useState<{ userId: string; count: number; lastAt: string; reasons: string[] }[]>([]);
|
||||
const [registerStatusFilter, setRegisterStatusFilter] = useState('');
|
||||
const [registerFormFilter, setRegisterFormFilter] = useState('');
|
||||
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
|
||||
const [appNotes, setAppNotes] = useState<any[]>([]);
|
||||
const [appHistory, setAppHistory] = useState<any[]>([]);
|
||||
const [noteDraft, setNoteDraft] = useState('');
|
||||
|
||||
const setSection = useCallback((key: NavKey) => {
|
||||
setSectionState(key);
|
||||
@@ -473,6 +495,52 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
await loadGuildData(currentGuildId);
|
||||
}
|
||||
|
||||
async function loadAutomodStrikes() {
|
||||
if (!currentGuildId) return;
|
||||
const res = await apiFetch<any>(`/automod/strikes?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||
setAutomodStrikes(res.strikes || []);
|
||||
}
|
||||
|
||||
async function resetAutomodStrike(userId: string) {
|
||||
await apiFetch(`/automod/strikes?guildId=${encodeURIComponent(currentGuildId)}&userId=${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
await loadAutomodStrikes();
|
||||
}
|
||||
|
||||
async function loadRegisterApps(overrides?: { status?: string; formId?: string }) {
|
||||
if (!currentGuildId) return;
|
||||
const params = new URLSearchParams({ guildId: currentGuildId });
|
||||
const status = overrides?.status ?? registerStatusFilter;
|
||||
const formId = overrides?.formId ?? registerFormFilter;
|
||||
if (status) params.set('status', status);
|
||||
if (formId) params.set('formId', formId);
|
||||
const res = await apiFetch<any>(`/register/apps?${params.toString()}`);
|
||||
setRegisterApps(res.applications || []);
|
||||
}
|
||||
|
||||
async function openAppDetail(id: string) {
|
||||
if (selectedAppId === id) {
|
||||
setSelectedAppId(null);
|
||||
setAppNotes([]);
|
||||
setAppHistory([]);
|
||||
return;
|
||||
}
|
||||
setSelectedAppId(id);
|
||||
const [notesRes, historyRes] = await Promise.all([
|
||||
apiFetch<any>(`/register/apps/${id}/notes`),
|
||||
apiFetch<any>(`/register/apps/${id}/history`)
|
||||
]);
|
||||
setAppNotes(notesRes.notes || []);
|
||||
setAppHistory(historyRes.applications || []);
|
||||
}
|
||||
|
||||
async function addAppNote() {
|
||||
if (!selectedAppId || !noteDraft.trim()) return;
|
||||
await apiFetch(`/register/apps/${selectedAppId}/notes`, { method: 'POST', body: JSON.stringify({ body: noteDraft.trim() }) });
|
||||
setNoteDraft('');
|
||||
const notesRes = await apiFetch<any>(`/register/apps/${selectedAppId}/notes`);
|
||||
setAppNotes(notesRes.notes || []);
|
||||
}
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
||||
}, []);
|
||||
@@ -486,6 +554,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
automationDraft, kbDraft, eventDraft, statusDraft, statsDraft, reactionDraft,
|
||||
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
|
||||
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
||||
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
||||
appNotes, appHistory, noteDraft,
|
||||
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
||||
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
||||
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
||||
@@ -498,6 +568,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setReactionDraft, setFormDraft, setEditingFormId, setRegisterTab,
|
||||
setStatusServiceDraft, setStatsItemDraft, setTicketDetail, setKbEditDraft,
|
||||
setAutomationEditDraft,
|
||||
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
|
||||
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, 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 { Shield, Link2, Ban, AlertTriangle, Save, Info, MailWarning, AtSign, CaseUpper, X, UserPlus, Plus, Trash2, Siren } 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 { ChannelSelect, type ChannelOption } from '../components/shared/ChannelSelect';
|
||||
import { RoleSelect, type RoleOption } from '../components/shared/RoleSelect';
|
||||
import { AppSwitch } from '../components/shared/AppSwitch';
|
||||
import { useGuildResources } from '../hooks/useGuildResources';
|
||||
|
||||
@@ -14,62 +14,192 @@ const FILTERS = [
|
||||
icon: <Ban size={16} />,
|
||||
title: 'Bad-Word-Filter',
|
||||
description: 'Entfernt Nachrichten mit unerwünschten Begriffen automatisch.',
|
||||
defaultAction: 'delete' as const,
|
||||
},
|
||||
{
|
||||
key: 'linkFilter' as const,
|
||||
icon: <Link2 size={16} />,
|
||||
title: 'Link-Filter',
|
||||
description: 'Blockiert Links, die nicht auf der Whitelist stehen.',
|
||||
defaultAction: 'delete' as const,
|
||||
},
|
||||
{
|
||||
key: 'inviteFilter' as const,
|
||||
icon: <MailWarning size={16} />,
|
||||
title: 'Einladungslink-Filter',
|
||||
description: 'Blockiert Discord-Invites unabhängig vom Link-Filter.',
|
||||
defaultAction: 'delete' as const,
|
||||
},
|
||||
{
|
||||
key: 'mentionSpamFilter' as const,
|
||||
icon: <AtSign size={16} />,
|
||||
title: 'Mass-Mention-Schutz',
|
||||
description: 'Verhindert Raid-artiges Massen-Pingen von Usern/Rollen.',
|
||||
defaultAction: 'timeout' as const,
|
||||
},
|
||||
{
|
||||
key: 'capsFilter' as const,
|
||||
icon: <CaseUpper size={16} />,
|
||||
title: 'Caps-Filter',
|
||||
description: 'Entfernt Nachrichten mit überwiegend GROSSBUCHSTABEN.',
|
||||
defaultAction: 'delete' as const,
|
||||
},
|
||||
{
|
||||
key: 'spamFilter' as const,
|
||||
icon: <AlertTriangle size={16} />,
|
||||
title: 'Spam-Filter',
|
||||
description: 'Erkennt und unterdrückt Mehrfachnachrichten in kurzer Zeit.',
|
||||
defaultAction: 'timeout' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
delete: 'Nur löschen',
|
||||
warn: 'Warnen',
|
||||
timeout: 'Timeout',
|
||||
kick: 'Kick',
|
||||
ban: 'Ban',
|
||||
};
|
||||
|
||||
const ESCALATION_ACTION_LABELS: Record<string, string> = {
|
||||
timeout: 'Timeout',
|
||||
kick: 'Kick',
|
||||
ban: 'Ban',
|
||||
};
|
||||
|
||||
function toList(value?: string) {
|
||||
return (value || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function RoleChipList({ roles, values, onChange }: { roles: RoleOption[]; values: string[]; onChange: (v: string[]) => void }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{values.length ? values.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={() => onChange(values.filter((id) => id !== roleId))}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}) : <p className="text-xs text-muted">Keine Rollen-Ausnahmen</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<RoleSelect options={roles.filter((r) => !values.includes(r.id))} value={draft} onChange={setDraft} placeholder="Rolle ausnehmen" />
|
||||
<Button
|
||||
size="sm" variant="tertiary" className="shrink-0" isDisabled={!draft}
|
||||
onPress={() => { if (draft && !values.includes(draft)) { onChange([...values, draft]); setDraft(''); } }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelChipList({ channels, values, onChange }: { channels: ChannelOption[]; values: string[]; onChange: (v: string[]) => void }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{values.length ? values.map((channelId) => {
|
||||
const channel = channels.find((c) => c.id === channelId);
|
||||
return (
|
||||
<div key={channelId} className="bg-default-soft flex items-center gap-1.5 rounded-full py-1 pl-3 pr-1.5 text-xs font-medium">
|
||||
#{channel?.name || channelId}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
|
||||
onClick={() => onChange(values.filter((id) => id !== channelId))}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}) : <p className="text-xs text-muted">Keine Kanal-Ausnahmen</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ChannelSelect options={channels.filter((c) => !values.includes(c.id))} value={draft} onChange={setDraft} placeholder="Kanal ausnehmen" />
|
||||
<Button
|
||||
size="sm" variant="tertiary" className="shrink-0" isDisabled={!draft}
|
||||
onPress={() => { if (draft && !values.includes(draft)) { onChange([...values, draft]); setDraft(''); } }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Automod() {
|
||||
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
|
||||
const {
|
||||
settings, setSettings, saveSettingsPayload, currentGuildId,
|
||||
automodStrikes, loadAutomodStrikes, resetAutomodStrike,
|
||||
} = useApp();
|
||||
const { channels, roles } = useGuildResources(currentGuildId);
|
||||
const [roleDraft, setRoleDraft] = useState('');
|
||||
|
||||
const cfg = settings.automodConfig || {};
|
||||
const whitelistRoles: string[] = cfg.whitelistRoles || [];
|
||||
const strikeCfg = cfg.strikeConfig || { enabled: false, decayHours: 24, thresholds: [] };
|
||||
const badwordRules: { pattern: string; isRegex?: boolean; severity?: string }[] =
|
||||
cfg.badwordRules && cfg.badwordRules.length
|
||||
? cfg.badwordRules
|
||||
: (cfg.customBadwords || []).map((w: string) => ({ pattern: w, isRegex: false, severity: 'low' }));
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGuildId) loadAutomodStrikes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentGuildId]);
|
||||
|
||||
const patchConfig = (patch: Record<string, any>) =>
|
||||
setSettings((s) => ({ ...s, automodConfig: { ...(s.automodConfig || {}), ...patch } }));
|
||||
|
||||
const patchFilter = (key: string, patch: Record<string, any>) => {
|
||||
const filters = { ...(cfg.filters || {}) };
|
||||
filters[key] = { ...(filters[key] || {}), ...patch };
|
||||
patchConfig({ filters });
|
||||
};
|
||||
|
||||
const addWhitelistRole = () => {
|
||||
if (!roleDraft || whitelistRoles.includes(roleDraft)) return;
|
||||
patchConfig({ whitelistRoles: [...whitelistRoles, roleDraft] });
|
||||
setRoleDraft('');
|
||||
};
|
||||
|
||||
const updateBadword = (idx: number, patch: Record<string, any>) => {
|
||||
patchConfig({ badwordRules: badwordRules.map((r, i) => (i === idx ? { ...r, ...patch } : r)) });
|
||||
};
|
||||
const removeBadword = (idx: number) => {
|
||||
patchConfig({ badwordRules: badwordRules.filter((_, i) => i !== idx) });
|
||||
};
|
||||
const addBadword = () => {
|
||||
patchConfig({ badwordRules: [...badwordRules, { pattern: '', isRegex: false, severity: 'low' }] });
|
||||
};
|
||||
|
||||
const patchStrike = (patch: Record<string, any>) => patchConfig({ strikeConfig: { ...strikeCfg, ...patch } });
|
||||
const thresholds: { count: number; action: string; timeoutMinutes?: number }[] = strikeCfg.thresholds || [];
|
||||
const updateThreshold = (idx: number, patch: Record<string, any>) => {
|
||||
patchStrike({ thresholds: thresholds.map((t, i) => (i === idx ? { ...t, ...patch } : t)) });
|
||||
};
|
||||
const removeThreshold = (idx: number) => {
|
||||
patchStrike({ thresholds: thresholds.filter((_, i) => i !== idx) });
|
||||
};
|
||||
const addThreshold = () => {
|
||||
patchStrike({ thresholds: [...thresholds, { count: 3, action: 'timeout', timeoutMinutes: 10 }] });
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard title="Automod" subtitle="Filter, Logging und Sicherheit">
|
||||
<SectionCard title="Automod" subtitle="Filter, Aktionen, Eskalation und Sicherheit">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
@@ -92,83 +222,113 @@ export function Automod() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{FILTERS.map((f) => (
|
||||
<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}
|
||||
{FILTERS.map((f) => {
|
||||
const filterCfg = cfg.filters?.[f.key] || {};
|
||||
const action = filterCfg.action || f.defaultAction;
|
||||
const exemptRoleIds: string[] = filterCfg.exemptRoleIds || [];
|
||||
const exemptChannelIds: string[] = filterCfg.exemptChannelIds || [];
|
||||
return (
|
||||
<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={cfg[f.key] ?? true}
|
||||
onChange={(v) => patchConfig({ [f.key]: v })}
|
||||
/>
|
||||
|
||||
{(cfg[f.key] ?? true) && (
|
||||
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<TextField className="w-48">
|
||||
<Label className="text-xs">Aktion bei Verstoß</Label>
|
||||
<select
|
||||
className="w-full rounded-xl px-3 py-2 text-sm"
|
||||
value={action}
|
||||
onChange={(e) => patchFilter(f.key, { action: e.target.value })}
|
||||
>
|
||||
{Object.entries(ACTION_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</TextField>
|
||||
{action === 'timeout' && (
|
||||
<TextField className="w-36">
|
||||
<Label className="text-xs">Timeout (Min.)</Label>
|
||||
<Input
|
||||
type="number" min="1"
|
||||
value={String(filterCfg.timeoutMinutes ?? cfg.spamTimeoutMinutes ?? 10)}
|
||||
onChange={(e) => patchFilter(f.key, { timeoutMinutes: Number(e.target.value || 10) })}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{f.key === 'linkFilter' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{f.key === 'mentionSpamFilter' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{f.key === 'spamFilter' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs">Ausgenommene Rollen</Label>
|
||||
<RoleChipList roles={roles} values={exemptRoleIds} onChange={(v) => patchFilter(f.key, { exemptRoleIds: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Ausgenommene Kanäle</Label>
|
||||
<ChannelChipList channels={channels} values={exemptChannelIds} onChange={(v) => patchFilter(f.key, { exemptChannelIds: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<TextField>
|
||||
@@ -192,24 +352,131 @@ export function Automod() {
|
||||
<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>
|
||||
<h3 className="text-base font-semibold">Bad-Word-Regeln</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 className="flex flex-col gap-3 p-5">
|
||||
<p className="text-xs text-muted">Wörter, Phrasen oder reguläre Ausdrücke mit eigenem Schweregrad. Ergänzt die eingebaute Standard-Liste.</p>
|
||||
{badwordRules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 rounded-xl bg-surface-secondary p-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={rule.isRegex ? 'Regex, z.B. \\bwort\\d+\\b' : 'Wort oder Phrase'}
|
||||
value={rule.pattern}
|
||||
onChange={(e) => updateBadword(idx, { pattern: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="rounded-xl px-2 py-2 text-xs"
|
||||
value={rule.severity || 'low'}
|
||||
onChange={(e) => updateBadword(idx, { severity: e.target.value })}
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-xs text-muted shrink-0">
|
||||
<AppSwitch aria-label="Regex" isSelected={!!rule.isRegex} onChange={(v) => updateBadword(idx, { isRegex: v })} />
|
||||
Regex
|
||||
</label>
|
||||
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => removeBadword(idx)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" variant="tertiary" onPress={addBadword}>
|
||||
<Plus size={14} /> Regel hinzufügen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Rollen-Whitelist</h3>
|
||||
<h3 className="flex items-center gap-2 text-base font-semibold"><Siren size={16} /> Eskalation / Strikes</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 items-center gap-3 rounded-xl bg-surface-secondary p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">Strike-System aktiv</div>
|
||||
<div className="text-xs text-muted">Verstöße werden gezählt; ab einer Schwelle greift automatisch eine härtere Strafe.</div>
|
||||
</div>
|
||||
<AppSwitch aria-label="Strike-System aktiv" isSelected={!!strikeCfg.enabled} onChange={(v) => patchStrike({ enabled: v })} />
|
||||
</div>
|
||||
{strikeCfg.enabled && (
|
||||
<>
|
||||
<TextField className="w-48">
|
||||
<Label className="text-xs">Verstöße verfallen nach (Std.)</Label>
|
||||
<Input
|
||||
type="number" min="0" placeholder="0 = nie"
|
||||
value={String(strikeCfg.decayHours ?? 24)}
|
||||
onChange={(e) => patchStrike({ decayHours: Number(e.target.value || 0) })}
|
||||
/>
|
||||
</TextField>
|
||||
<div className="flex flex-col gap-2">
|
||||
{thresholds.map((t, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 rounded-xl bg-surface-secondary p-2">
|
||||
<TextField className="w-24">
|
||||
<Label className="text-xs">Ab Anzahl</Label>
|
||||
<Input type="number" min="1" value={String(t.count)} onChange={(e) => updateThreshold(idx, { count: Number(e.target.value || 1) })} />
|
||||
</TextField>
|
||||
<TextField className="w-32">
|
||||
<Label className="text-xs">Aktion</Label>
|
||||
<select
|
||||
className="w-full rounded-xl px-3 py-2 text-sm"
|
||||
value={t.action}
|
||||
onChange={(e) => updateThreshold(idx, { action: e.target.value })}
|
||||
>
|
||||
{Object.entries(ESCALATION_ACTION_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</TextField>
|
||||
{t.action === 'timeout' && (
|
||||
<TextField className="w-28">
|
||||
<Label className="text-xs">Minuten</Label>
|
||||
<Input type="number" min="1" value={String(t.timeoutMinutes ?? 10)} onChange={(e) => updateThreshold(idx, { timeoutMinutes: Number(e.target.value || 10) })} />
|
||||
</TextField>
|
||||
)}
|
||||
<Button isIconOnly size="sm" variant="danger-soft" className="mt-4" onPress={() => removeThreshold(idx)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" variant="tertiary" onPress={addThreshold}>
|
||||
<Plus size={14} /> Schwelle hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Aktuelle Verstöße</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{automodStrikes.length ? automodStrikes.map((s) => (
|
||||
<div key={s.userId} className="flex items-center justify-between gap-2 rounded-xl bg-surface-secondary p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{s.userId}</div>
|
||||
<div className="text-xs text-muted truncate">{s.reasons.join(' · ') || 'Keine Details'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="rounded-full bg-danger-soft px-2 py-1 text-xs font-semibold text-danger">{s.count}</span>
|
||||
<Button size="sm" variant="tertiary" onPress={() => resetAutomodStrike(s.userId)}>Zurücksetzen</Button>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-xs text-muted">Keine aktiven Verstöße.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Globale 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 (alle Filter).</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{whitelistRoles.length ? whitelistRoles.map((roleId) => {
|
||||
const role = roles.find((r) => r.id === roleId);
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Tabs, Tab, Separator, TextField, Label } from '@heroui/react';
|
||||
import { ClipboardList, Pencil, Trash2, Send, Plus, FileText } from 'lucide-react';
|
||||
import { ClipboardList, Pencil, Trash2, Send, Plus, FileText, History, MessageSquare } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
accepted: 'Akzeptiert',
|
||||
invited: 'Zum Gespräch eingeladen',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, 'success' | 'danger' | 'warning' | 'accent' | 'default'> = {
|
||||
pending: 'warning',
|
||||
accepted: 'success',
|
||||
invited: 'accent',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
export function Register() {
|
||||
const {
|
||||
registerForms, registerApps, registerTab, setRegisterTab,
|
||||
formDraft, setFormDraft, editingFormId, setEditingFormId,
|
||||
saveForm, deleteForm, sendFormPanel
|
||||
saveForm, deleteForm, sendFormPanel,
|
||||
registerStatusFilter, registerFormFilter, setRegisterStatusFilter, setRegisterFormFilter, loadRegisterApps,
|
||||
selectedAppId, openAppDetail, appHistory, appNotes, noteDraft, setNoteDraft, addAppNote
|
||||
} = useApp();
|
||||
|
||||
return (
|
||||
@@ -118,17 +134,47 @@ export function Register() {
|
||||
|
||||
{registerTab === 'apps' && (
|
||||
<div className="mt-5">
|
||||
<div className="mb-4 flex flex-wrap items-end gap-2">
|
||||
<TextField className="w-56">
|
||||
<Label className="text-xs">Formular/Position</Label>
|
||||
<select
|
||||
className="w-full rounded-xl px-3 py-2 text-sm"
|
||||
value={registerFormFilter}
|
||||
onChange={(e) => { setRegisterFormFilter(e.target.value); loadRegisterApps({ formId: e.target.value }); }}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{registerForms.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
|
||||
</select>
|
||||
</TextField>
|
||||
<TextField className="w-48">
|
||||
<Label className="text-xs">Status</Label>
|
||||
<select
|
||||
className="w-full rounded-xl px-3 py-2 text-sm"
|
||||
value={registerStatusFilter}
|
||||
onChange={(e) => { setRegisterStatusFilter(e.target.value); loadRegisterApps({ status: e.target.value }); }}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-3 text-base font-semibold">Eingegangene Anträge ({registerApps.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{registerApps.length ? registerApps.map((app) => (
|
||||
<Card key={app.id}>
|
||||
<CardContent className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold">{app.username || app.userId}</div>
|
||||
<Chip size="sm" variant="soft" color={app.status === 'approved' ? 'success' : app.status === 'rejected' ? 'danger' : 'warning'}>
|
||||
{app.status}
|
||||
<button type="button" className="flex items-center justify-between gap-2 text-left" onClick={() => openAppDetail(app.id)}>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">{app.username || app.userId}</div>
|
||||
<div className="text-xs text-muted truncate">{app.form?.name || 'Formular'}</div>
|
||||
</div>
|
||||
<Chip size="sm" variant="soft" color={STATUS_COLORS[app.status] || 'default'}>
|
||||
{STATUS_LABELS[app.status] || app.status}
|
||||
</Chip>
|
||||
</div>
|
||||
</button>
|
||||
<div className="text-xs text-muted">{formatDate(app.createdAt)}</div>
|
||||
{app.answers?.length ? (
|
||||
<div className="space-y-1">
|
||||
@@ -140,6 +186,45 @@ export function Register() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAppId === app.id && (
|
||||
<div className="mt-2 grid gap-4 border-t border-border pt-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><History size={14} /> Bisherige Bewerbungen</h4>
|
||||
{appHistory.length ? (
|
||||
<div className="space-y-1.5">
|
||||
{appHistory.map((h) => (
|
||||
<div key={h.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate">{h.form?.name || 'Formular'} · {formatDate(h.createdAt)}</span>
|
||||
<Chip size="sm" variant="soft" color={STATUS_COLORS[h.status] || 'default'}>{STATUS_LABELS[h.status] || h.status}</Chip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="text-xs text-muted">Keine weiteren Bewerbungen dieses Nutzers</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><MessageSquare size={14} /> Interne Notizen</h4>
|
||||
<div className="space-y-2">
|
||||
{appNotes.length ? appNotes.map((n) => (
|
||||
<div key={n.id} className="rounded-lg bg-surface-secondary p-2 text-xs">
|
||||
<div className="mb-1 flex items-center justify-between text-muted">
|
||||
<span className="font-medium text-default">{n.authorTag}</span>
|
||||
<span>{formatDate(n.createdAt)}</span>
|
||||
</div>
|
||||
{n.body}
|
||||
</div>
|
||||
)) : <p className="text-xs text-muted">Keine Notizen</p>}
|
||||
</div>
|
||||
<TextArea
|
||||
className="mt-2" rows={2} placeholder="Interne Notiz hinzufügen..."
|
||||
value={noteDraft} onChange={(e) => setNoteDraft(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" variant="tertiary" className="mt-2" isDisabled={!noteDraft.trim()} onPress={addAppNote}>
|
||||
Notiz speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : (
|
||||
|
||||
@@ -135,8 +135,9 @@ export type RegisterApplication = {
|
||||
formId: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
status: 'pending' | 'accepted' | 'invited' | 'rejected';
|
||||
answers: { fieldId?: string; label?: string; value: string }[];
|
||||
form?: RegisterForm;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
|
||||
38
node_modules/.prisma/client/edge.js
generated
vendored
38
node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
23
node_modules/.prisma/client/index-browser.js
generated
vendored
23
node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -295,6 +295,25 @@ exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
|
||||
value: 'value'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationNoteScalarFieldEnum = {
|
||||
id: 'id',
|
||||
applicationId: 'applicationId',
|
||||
authorId: 'authorId',
|
||||
authorTag: 'authorTag',
|
||||
body: 'body',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AutomodStrikeScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
userId: 'userId',
|
||||
filterKey: 'filterKey',
|
||||
weight: 'weight',
|
||||
reason: 'reason',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -340,7 +359,9 @@ exports.Prisma.ModelName = {
|
||||
RegisterForm: 'RegisterForm',
|
||||
RegisterFormField: 'RegisterFormField',
|
||||
RegisterApplication: 'RegisterApplication',
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
||||
RegisterApplicationNote: 'RegisterApplicationNote',
|
||||
AutomodStrike: 'AutomodStrike'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
2675
node_modules/.prisma/client/index.d.ts
generated
vendored
2675
node_modules/.prisma/client/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load Diff
42
node_modules/.prisma/client/index.js
generated
vendored
42
node_modules/.prisma/client/index.js
generated
vendored
File diff suppressed because one or more lines are too long
23
node_modules/.prisma/client/wasm.js
generated
vendored
23
node_modules/.prisma/client/wasm.js
generated
vendored
@@ -295,6 +295,25 @@ exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
|
||||
value: 'value'
|
||||
};
|
||||
|
||||
exports.Prisma.RegisterApplicationNoteScalarFieldEnum = {
|
||||
id: 'id',
|
||||
applicationId: 'applicationId',
|
||||
authorId: 'authorId',
|
||||
authorTag: 'authorTag',
|
||||
body: 'body',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AutomodStrikeScalarFieldEnum = {
|
||||
id: 'id',
|
||||
guildId: 'guildId',
|
||||
userId: 'userId',
|
||||
filterKey: 'filterKey',
|
||||
weight: 'weight',
|
||||
reason: 'reason',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -340,7 +359,9 @@ exports.Prisma.ModelName = {
|
||||
RegisterForm: 'RegisterForm',
|
||||
RegisterFormField: 'RegisterFormField',
|
||||
RegisterApplication: 'RegisterApplication',
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
|
||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
||||
RegisterApplicationNote: 'RegisterApplicationNote',
|
||||
AutomodStrike: 'AutomodStrike'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RegisterApplicationNote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"authorTag" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RegisterApplicationNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AutomodStrike" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"filterKey" TEXT NOT NULL,
|
||||
"weight" INTEGER NOT NULL DEFAULT 1,
|
||||
"reason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AutomodStrike_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegisterApplicationNote_applicationId_idx" ON "RegisterApplicationNote"("applicationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AutomodStrike_guildId_userId_idx" ON "AutomodStrike"("guildId", "userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegisterApplicationNote" ADD CONSTRAINT "RegisterApplicationNote_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "RegisterApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -202,6 +202,7 @@ model RegisterApplication {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
answers RegisterApplicationAnswer[]
|
||||
notes RegisterApplicationNote[]
|
||||
|
||||
form RegisterForm @relation(fields: [formId], references: [id])
|
||||
|
||||
@@ -216,3 +217,28 @@ model RegisterApplicationAnswer {
|
||||
|
||||
application RegisterApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model RegisterApplicationNote {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
authorId String
|
||||
authorTag String
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
application RegisterApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([applicationId])
|
||||
}
|
||||
|
||||
model AutomodStrike {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
userId String
|
||||
filterKey String
|
||||
weight Int @default(1)
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([guildId, userId])
|
||||
}
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
import { Collection, Message } from 'discord.js';
|
||||
import { Collection, GuildMember, Message } from 'discord.js';
|
||||
import { logger } from '../utils/logger';
|
||||
import { GuildSettings } from '../config/state';
|
||||
import { LoggingService } from './loggingService';
|
||||
import { prisma } from '../database';
|
||||
|
||||
export type AutomodFilterKey =
|
||||
| 'linkFilter'
|
||||
| 'inviteFilter'
|
||||
| 'badWordFilter'
|
||||
| 'capsFilter'
|
||||
| 'spamFilter'
|
||||
| 'mentionSpamFilter';
|
||||
|
||||
export type AutomodAction = 'delete' | 'warn' | 'timeout' | 'kick' | 'ban';
|
||||
|
||||
export interface AutomodFilterConfig {
|
||||
enabled?: boolean;
|
||||
action?: AutomodAction;
|
||||
timeoutMinutes?: number;
|
||||
exemptRoleIds?: string[];
|
||||
exemptChannelIds?: string[];
|
||||
}
|
||||
|
||||
export interface AutomodBadwordRule {
|
||||
pattern: string;
|
||||
isRegex?: boolean;
|
||||
severity?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface AutomodStrikeThreshold {
|
||||
count: number;
|
||||
action: 'timeout' | 'kick' | 'ban';
|
||||
timeoutMinutes?: number;
|
||||
}
|
||||
|
||||
export interface AutomodStrikeConfig {
|
||||
enabled?: boolean;
|
||||
decayHours?: number;
|
||||
thresholds?: AutomodStrikeThreshold[];
|
||||
}
|
||||
|
||||
export interface AutomodConfig {
|
||||
spamThreshold?: number;
|
||||
@@ -26,6 +63,20 @@ export interface AutomodConfig {
|
||||
automodActions?: boolean;
|
||||
};
|
||||
};
|
||||
/** Per-filter action/exemption overrides. Falls back to the legacy flat flags above when unset. */
|
||||
filters?: Partial<Record<AutomodFilterKey, AutomodFilterConfig>>;
|
||||
/** Fine-grained badword rules (regex/severity). Falls back to customBadwords when unset. */
|
||||
badwordRules?: AutomodBadwordRule[];
|
||||
/** Strike/escalation system. */
|
||||
strikeConfig?: AutomodStrikeConfig;
|
||||
}
|
||||
|
||||
interface ResolvedFilter {
|
||||
enabled: boolean;
|
||||
action: AutomodAction;
|
||||
timeoutMinutes: number;
|
||||
exemptRoleIds: string[];
|
||||
exemptChannelIds: string[];
|
||||
}
|
||||
|
||||
export class AutoModService {
|
||||
@@ -56,7 +107,7 @@ export class AutoModService {
|
||||
if (message.author.bot || message.webhookId) return;
|
||||
if (!message.inGuild()) return;
|
||||
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
|
||||
const config = { ...this.defaults, ...(guildConfig ?? {}) };
|
||||
const config: AutomodConfig = { ...this.defaults, ...((guildConfig as AutomodConfig) ?? {}) };
|
||||
const member = message.member;
|
||||
|
||||
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
|
||||
@@ -64,53 +115,88 @@ export class AutoModService {
|
||||
if (allowed) return;
|
||||
}
|
||||
|
||||
if (config.mentionSpamFilter !== false && this.exceedsMentionLimit(message, config.maxMentions)) {
|
||||
const mentionCfg = this.resolveFilter('mentionSpamFilter', config, 'timeout');
|
||||
if (mentionCfg.enabled && !this.isExempt(member, message.channelId, mentionCfg) && 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`);
|
||||
await this.triggerFilter(
|
||||
message,
|
||||
config,
|
||||
'mentionSpamFilter',
|
||||
mentionCfg,
|
||||
`${message.author}, bitte nicht so viele User/Rollen auf einmal erwähnen.`,
|
||||
`${count}/${limit} Erwähnungen in einer Nachricht`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const linkFilterOn = config.linkFilter ?? config.deleteLinks ?? true;
|
||||
const inviteCfg = this.resolveFilter('inviteFilter', config, 'delete');
|
||||
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);
|
||||
if (linkMatch && inviteCfg.enabled && !this.isExempt(member, message.channelId, inviteCfg) && this.inviteRegex.test(linkMatch)) {
|
||||
await this.triggerFilter(
|
||||
message,
|
||||
config,
|
||||
'inviteFilter',
|
||||
inviteCfg,
|
||||
`${message.author}, Einladungslinks sind hier nicht erlaubt.`,
|
||||
'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 linkCfg = this.resolveFilter('linkFilter', config, 'delete');
|
||||
const linkFilterOn = linkCfg.enabled ?? config.deleteLinks ?? true;
|
||||
if (
|
||||
this.linkFilterEnabled &&
|
||||
linkFilterOn &&
|
||||
linkMatch &&
|
||||
!this.isExempt(member, message.channelId, linkCfg) &&
|
||||
!this.isWhitelisted(linkMatch, config.linkWhitelist)
|
||||
) {
|
||||
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
|
||||
logger.info(`Deleted link from ${message.author.tag}`);
|
||||
await this.logAutomodAction(message, config, 'link_filter', reason);
|
||||
await this.triggerFilter(message, config, 'linkFilter', linkCfg, `${message.author}, Links sind hier nicht erlaubt.`, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
|
||||
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.capsFilter) {
|
||||
const letters = message.content.replace(/[^a-zA-Z]/g, '');
|
||||
const upper = letters.replace(/[^A-Z]/g, '');
|
||||
if (letters.length >= 10 && upper.length / letters.length > 0.7) {
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
|
||||
const ratio = Math.round((upper.length / letters.length) * 100);
|
||||
await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
|
||||
const badwordCfg = this.resolveFilter('badWordFilter', config, 'delete');
|
||||
if (badwordCfg.enabled && !this.isExempt(member, message.channelId, badwordCfg)) {
|
||||
const match = this.matchBadword(message.content, config);
|
||||
if (match) {
|
||||
await this.triggerFilter(
|
||||
message,
|
||||
config,
|
||||
'badWordFilter',
|
||||
badwordCfg,
|
||||
`${message.author}, bitte auf deine Wortwahl achten.`,
|
||||
`Badword erkannt (Schweregrad: ${match.severity})`,
|
||||
message.content
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const spamFilterOn = config.spamFilter ?? true;
|
||||
if (this.antiSpamEnabled && spamFilterOn) {
|
||||
const capsCfg = this.resolveFilter('capsFilter', config, 'delete');
|
||||
if (capsCfg.enabled && !this.isExempt(member, message.channelId, capsCfg)) {
|
||||
const letters = message.content.replace(/[^a-zA-Z]/g, '');
|
||||
const upper = letters.replace(/[^A-Z]/g, '');
|
||||
if (letters.length >= 10 && upper.length / letters.length > 0.7) {
|
||||
const ratio = Math.round((upper.length / letters.length) * 100);
|
||||
await this.triggerFilter(
|
||||
message,
|
||||
config,
|
||||
'capsFilter',
|
||||
capsCfg,
|
||||
`${message.author}, bitte weniger Capslock nutzen.`,
|
||||
`Caps Anteil ${ratio}%`,
|
||||
message.content
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const spamCfg = this.resolveFilter('spamFilter', config, 'timeout');
|
||||
if (this.antiSpamEnabled && spamCfg.enabled && !this.isExempt(member, message.channelId, spamCfg)) {
|
||||
const now = Date.now();
|
||||
const tracker = this.spamTracker.get(message.author.id) ?? { count: 0, lastMessage: now };
|
||||
if (now - tracker.lastMessage < (config.windowMs ?? this.windowMs)) {
|
||||
@@ -123,37 +209,136 @@ export class AutoModService {
|
||||
|
||||
const threshold = config.spamThreshold ?? this.spamThreshold;
|
||||
if (tracker.count >= threshold) {
|
||||
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
|
||||
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
|
||||
await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
|
||||
logger.warn(`Timed out ${message.author.tag} for spam`);
|
||||
this.spamTracker.delete(message.author.id);
|
||||
const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`;
|
||||
await this.logAutomodAction(message, config, 'spam', reason);
|
||||
await this.triggerFilter(message, config, 'spamFilter', spamCfg, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`, reason);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Resolves the effective per-filter config, falling back to the legacy flat flags for backwards compatibility. */
|
||||
private resolveFilter(key: AutomodFilterKey, config: AutomodConfig, legacyDefaultAction: AutomodAction): ResolvedFilter {
|
||||
const override = config.filters?.[key] ?? {};
|
||||
const legacyEnabled = (config as any)[key] as boolean | undefined;
|
||||
return {
|
||||
enabled: override.enabled ?? legacyEnabled ?? true,
|
||||
action: override.action ?? legacyDefaultAction,
|
||||
timeoutMinutes: override.timeoutMinutes ?? config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!,
|
||||
exemptRoleIds: Array.isArray(override.exemptRoleIds) ? override.exemptRoleIds : [],
|
||||
exemptChannelIds: Array.isArray(override.exemptChannelIds) ? override.exemptChannelIds : []
|
||||
};
|
||||
}
|
||||
|
||||
private isExempt(member: GuildMember | null, channelId: string, filterCfg: ResolvedFilter): boolean {
|
||||
if (filterCfg.exemptChannelIds.length && filterCfg.exemptChannelIds.includes(channelId)) return true;
|
||||
if (member?.roles.cache.size && filterCfg.exemptRoleIds.length) {
|
||||
return member.roles.cache.some((r) => filterCfg.exemptRoleIds.includes(r.id));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async triggerFilter(
|
||||
message: Message,
|
||||
config: AutomodConfig,
|
||||
filterKey: AutomodFilterKey,
|
||||
filterCfg: ResolvedFilter,
|
||||
userMessage: string,
|
||||
logReason: string,
|
||||
content?: string
|
||||
) {
|
||||
await this.deleteMessageWithReason(message, userMessage);
|
||||
logger.info(`Automod ${filterKey} triggered for ${message.author.tag} -> ${filterCfg.action}`);
|
||||
await this.applyAction(message, filterCfg.action, filterCfg.timeoutMinutes, `Automod: ${filterKey}`);
|
||||
await this.logAutomodAction(message, config, filterKey, `${logReason} | Aktion: ${filterCfg.action}`, content);
|
||||
await this.registerStrike(message, config, filterKey, logReason);
|
||||
}
|
||||
|
||||
private async applyAction(message: Message, action: AutomodAction, timeoutMinutes: number, reason: string) {
|
||||
const member = message.member;
|
||||
if (!member) return;
|
||||
switch (action) {
|
||||
case 'timeout': {
|
||||
const ms = Math.max(1, timeoutMinutes) * 60 * 1000;
|
||||
await member.timeout(ms, reason).catch(() => undefined);
|
||||
break;
|
||||
}
|
||||
case 'kick':
|
||||
await member.kick(reason).catch(() => undefined);
|
||||
break;
|
||||
case 'ban':
|
||||
await member.ban({ reason }).catch(() => undefined);
|
||||
break;
|
||||
case 'warn':
|
||||
case 'delete':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async registerStrike(message: Message, config: AutomodConfig, filterKey: AutomodFilterKey, reason: string) {
|
||||
const strikeCfg = config.strikeConfig;
|
||||
if (!strikeCfg?.enabled || !message.guildId) return;
|
||||
try {
|
||||
await prisma.automodStrike.create({
|
||||
data: { guildId: message.guildId, userId: message.author.id, filterKey, weight: 1, reason }
|
||||
});
|
||||
const decayHours = strikeCfg.decayHours ?? 0;
|
||||
const since = decayHours > 0 ? new Date(Date.now() - decayHours * 60 * 60 * 1000) : undefined;
|
||||
const rows = await prisma.automodStrike.findMany({
|
||||
where: { guildId: message.guildId, userId: message.author.id, ...(since ? { createdAt: { gte: since } } : {}) }
|
||||
});
|
||||
const total = rows.reduce((sum, r) => sum + r.weight, 0);
|
||||
const thresholds = (strikeCfg.thresholds || []).slice().sort((a, b) => a.count - b.count);
|
||||
const hit = thresholds.filter((t) => total >= t.count).pop();
|
||||
if (hit) {
|
||||
const minutes = hit.timeoutMinutes ?? config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!;
|
||||
await this.applyAction(message, hit.action, minutes, `Automod-Eskalation (${total} Verstöße)`);
|
||||
await this.logAutomodAction(message, config, 'strike_escalation', `${total} aktive Verstöße -> ${hit.action}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to record automod strike', err);
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
.filter(Boolean);
|
||||
if (!combined.length) return false;
|
||||
private getBadwordRules(config: AutomodConfig): Required<AutomodBadwordRule>[] {
|
||||
if (Array.isArray(config.badwordRules) && config.badwordRules.length) {
|
||||
return config.badwordRules
|
||||
.filter((r) => r?.pattern)
|
||||
.map((r) => ({ pattern: r.pattern, isRegex: !!r.isRegex, severity: r.severity ?? 'low' }));
|
||||
}
|
||||
const combined = [...this.defaultBadwords, ...(config.customBadwords || [])]
|
||||
.map((w) => w?.toString().trim())
|
||||
.filter(Boolean) as string[];
|
||||
return combined.map((w) => ({ pattern: w, isRegex: false, severity: 'low' as const }));
|
||||
}
|
||||
|
||||
private matchBadword(content: string, config: AutomodConfig): { pattern: string; severity: 'low' | 'medium' | 'high' } | null {
|
||||
const rules = this.getBadwordRules(config);
|
||||
if (!rules.length) return null;
|
||||
const lower = content.toLowerCase();
|
||||
return combined.some((w) => {
|
||||
// Try to match word boundaries first, fall back to substring to remain permissive
|
||||
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
return regex.test(lower) || lower.includes(w);
|
||||
});
|
||||
for (const rule of rules) {
|
||||
if (rule.isRegex) {
|
||||
try {
|
||||
if (new RegExp(rule.pattern, 'i').test(content)) return rule;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const w = rule.pattern.toLowerCase();
|
||||
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
if (regex.test(lower) || lower.includes(w)) return rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private matchLink(content: string): string | null {
|
||||
|
||||
@@ -240,17 +240,51 @@ export class RegisterService {
|
||||
const where: any = { guildId };
|
||||
if (status) where.status = status;
|
||||
if (formId) where.formId = formId;
|
||||
return prisma.registerApplication.findMany({
|
||||
const apps = await prisma.registerApplication.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { form: { include: { fields: true } }, answers: true }
|
||||
});
|
||||
return apps.map((app) => this.withAnswerLabels(app));
|
||||
}
|
||||
|
||||
public async getApplication(id: string) {
|
||||
const app = await prisma.registerApplication.findUnique({
|
||||
where: { id },
|
||||
include: { form: { include: { fields: true } }, answers: true, notes: { orderBy: { createdAt: 'asc' } } }
|
||||
});
|
||||
return app ? this.withAnswerLabels(app) : null;
|
||||
}
|
||||
|
||||
private withAnswerLabels(app: any) {
|
||||
const fields = app.form?.fields || [];
|
||||
return {
|
||||
...app,
|
||||
answers: (app.answers || []).map((a: any) => ({
|
||||
...a,
|
||||
label: fields.find((f: any) => f.id === a.fieldId)?.label || 'Frage'
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
public async listApplicationsByUser(guildId: string, userId: string, excludeId?: string) {
|
||||
return prisma.registerApplication.findMany({
|
||||
where: { guildId, userId, ...(excludeId ? { id: { not: excludeId } } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { form: true }
|
||||
});
|
||||
}
|
||||
|
||||
public async getApplication(id: string) {
|
||||
return prisma.registerApplication.findUnique({
|
||||
where: { id },
|
||||
include: { form: true, answers: true }
|
||||
public async listNotes(applicationId: string) {
|
||||
return prisma.registerApplicationNote.findMany({
|
||||
where: { applicationId },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
public async addNote(applicationId: string, authorId: string, authorTag: string, body: string) {
|
||||
return prisma.registerApplicationNote.create({
|
||||
data: { applicationId, authorId, authorTag, body }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +657,27 @@ router.get('/register/apps/:id', requireAuth, async (req, res) => {
|
||||
res.json({ application: app });
|
||||
});
|
||||
|
||||
router.get('/register/apps/:id/history', requireAuth, async (req, res) => {
|
||||
const app = await context.register.getApplication(req.params.id);
|
||||
if (!app) return res.status(404).json({ error: 'not found' });
|
||||
const history = await context.register.listApplicationsByUser(app.guildId, app.userId, app.id);
|
||||
res.json({ applications: history });
|
||||
});
|
||||
|
||||
router.get('/register/apps/:id/notes', requireAuth, async (req, res) => {
|
||||
const notes = await context.register.listNotes(req.params.id);
|
||||
res.json({ notes });
|
||||
});
|
||||
|
||||
router.post('/register/apps/:id/notes', requireAuth, async (req, res) => {
|
||||
const body = typeof req.body.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!body) return res.status(400).json({ error: 'body required' });
|
||||
const author = req.session.user;
|
||||
const authorTag = author?.username || author?.global_name || author?.id || 'Unbekannt';
|
||||
const note = await context.register.addNote(req.params.id, author.id, authorTag, body);
|
||||
res.json({ note });
|
||||
});
|
||||
|
||||
router.get('/automations', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
@@ -813,6 +834,28 @@ router.post('/server-stats/refresh', requireAuth, async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/automod/strikes', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const rows = await prisma.automodStrike.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' } });
|
||||
const byUser = new Map<string, { userId: string; count: number; lastAt: Date; reasons: string[] }>();
|
||||
rows.forEach((r) => {
|
||||
const entry = byUser.get(r.userId) ?? { userId: r.userId, count: 0, lastAt: r.createdAt, reasons: [] };
|
||||
entry.count += r.weight;
|
||||
if (r.reason && entry.reasons.length < 5) entry.reasons.push(r.reason);
|
||||
byUser.set(r.userId, entry);
|
||||
});
|
||||
res.json({ strikes: Array.from(byUser.values()).sort((a, b) => b.count - a.count) });
|
||||
});
|
||||
|
||||
router.delete('/automod/strikes', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
const userId = typeof req.query.userId === 'string' ? req.query.userId : undefined;
|
||||
if (!guildId || !userId) return res.status(400).json({ error: 'guildId and userId required' });
|
||||
await prisma.automodStrike.deleteMany({ where: { guildId, userId } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/settings', requireAuth, async (req, res) => {
|
||||
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
|
||||
const {
|
||||
@@ -886,8 +929,10 @@ router.post('/settings', requireAuth, async (req, res) => {
|
||||
spamTimeoutMinutes: automodConfig?.spamTimeoutMinutes ? Number(automodConfig.spamTimeoutMinutes) : undefined,
|
||||
deleteLinks: automodConfig?.deleteLinks !== undefined ? automodConfig.deleteLinks === 'true' || automodConfig.deleteLinks === true : undefined
|
||||
};
|
||||
parsedAutomod.customBadwords = normalizeArray(automodConfig?.customBadwords);
|
||||
parsedAutomod.whitelistRoles = normalizeArray(automodConfig?.whitelistRoles);
|
||||
if (automodConfig !== undefined) {
|
||||
parsedAutomod.customBadwords = normalizeArray(automodConfig?.customBadwords);
|
||||
parsedAutomod.whitelistRoles = normalizeArray(automodConfig?.whitelistRoles);
|
||||
}
|
||||
parsedAutomod.logChannelId = automodConfig?.logChannelId ?? logChannelId ?? parsedLogging.logChannelId;
|
||||
parsedAutomod.loggingConfig = parsedLogging;
|
||||
parsedAutomod.statuspageEnabled =
|
||||
|
||||
Reference in New Issue
Block a user