diff --git a/apps/bot/src/modules/automod/filters.ts b/apps/bot/src/modules/automod/filters.ts index 5b68fa3..d34a48a 100644 --- a/apps/bot/src/modules/automod/filters.ts +++ b/apps/bot/src/modules/automod/filters.ts @@ -1,6 +1,7 @@ import type { Redis } from 'ioredis'; import { capsRatio, + contentMatchesBlockedWord, countEmojis, extractUrls, hasZalgo, @@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter } function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null { - const lower = content.toLowerCase(); for (const word of wordList.words) { - if (word.length > 0 && lower.includes(word.toLowerCase())) { + if (contentMatchesBlockedWord(content, word)) { return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' }; } } for (const pattern of wordList.regexPatterns) { try { - const regex = new RegExp(pattern, 'i'); + const regex = new RegExp(pattern, 'iu'); if (regex.test(content)) { return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' }; } diff --git a/apps/bot/src/modules/automod/service.ts b/apps/bot/src/modules/automod/service.ts index 1600281..9658ad4 100644 --- a/apps/bot/src/modules/automod/service.ts +++ b/apps/bot/src/modules/automod/service.ts @@ -1,6 +1,8 @@ -import type { PrismaClient } from '@prisma/client'; +import type { Prisma, PrismaClient } from '@prisma/client'; import { DEFAULT_AUTOMOD_RULES, + defaultAutoModWordList, + shouldSeedDefaultBlockedWords, type AutoModExceptions, type AutoModThreshold, type AutoModWordList @@ -16,6 +18,26 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) { return config; } +async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) { + const rule = await prisma.autoModRule.findUnique({ + where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } }, + select: { id: true, wordList: true } + }); + if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) { + return; + } + const existing = parseWordList(rule.wordList); + await prisma.autoModRule.update({ + where: { id: rule.id }, + data: { + wordList: { + ...defaultAutoModWordList(), + regexPatterns: existing.regexPatterns + } as Prisma.InputJsonValue + } + }); +} + export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) { await ensureGuild(prisma, guildId); const existing = await prisma.autoModRule.findMany({ @@ -24,19 +46,19 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s }); const have = new Set(existing.map((row) => row.ruleType)); const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType)); - if (missing.length === 0) { - return; + if (missing.length > 0) { + await prisma.autoModRule.createMany({ + data: missing.map((rule) => ({ + guildId, + ruleType: rule.ruleType, + action: rule.action, + threshold: rule.threshold ?? undefined, + wordList: rule.wordList ?? undefined, + enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) + })) + }); } - await prisma.autoModRule.createMany({ - data: missing.map((rule) => ({ - guildId, - ruleType: rule.ruleType, - action: rule.action, - threshold: rule.threshold ?? undefined, - wordList: rule.wordList ?? undefined, - enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) - })) - }); + await seedDefaultWordFilterIfNeeded(prisma, guildId); } export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) { @@ -71,7 +93,8 @@ export function parseWordList(raw: unknown): AutoModWordList { const obj = raw as Record; return { words: Array.isArray(obj.words) ? obj.words.map(String) : [], - regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [] + regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [], + ...(obj.userCleared === true ? { userCleared: true } : {}) }; } diff --git a/apps/webui/src/components/modules/automod-form.tsx b/apps/webui/src/components/modules/automod-form.tsx index c295ec8..98d8075 100644 --- a/apps/webui/src/components/modules/automod-form.tsx +++ b/apps/webui/src/components/modules/automod-form.tsx @@ -6,8 +6,10 @@ import type { AutomodConfigDashboard, AutomodRuleDashboard } from '@nexumi/shared'; +import { mergeDefaultBlockedWords } from '@nexumi/shared'; import { FieldAnchor } from '@/components/layout/field-anchor'; import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select'; import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select'; @@ -25,19 +27,26 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom try { const cleaned: AutomodConfigDashboard = { ...value, - rules: value.rules.map((rule) => ({ - ...rule, - durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null, - threshold: { - ...rule.threshold, - linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean), - linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean) - }, - wordList: { - words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean), - regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean) - } - })) + rules: value.rules.map((rule) => { + const words = (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean); + const regexPatterns = (rule.wordList.regexPatterns ?? []) + .map((s) => s.trim()) + .filter(Boolean); + return { + ...rule, + durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null, + threshold: { + ...rule.threshold, + linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean), + linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean) + }, + wordList: { + words, + regexPatterns, + ...(words.length === 0 ? { userCleared: true as const } : {}) + } + }; + }) }; const response = await fetch(`/api/guilds/${guildId}/automod`, { method: 'PATCH', @@ -279,7 +288,24 @@ function RuleCard({ {type === 'WORD_FILTER' ? (
- +
+ + +
onChange({ wordList: { ...rule.wordList, words } })} diff --git a/apps/webui/src/lib/module-configs/automod.ts b/apps/webui/src/lib/module-configs/automod.ts index 8637b86..03610ce 100644 --- a/apps/webui/src/lib/module-configs/automod.ts +++ b/apps/webui/src/lib/module-configs/automod.ts @@ -1,5 +1,7 @@ import { DEFAULT_AUTOMOD_RULES, + defaultAutoModWordList, + shouldSeedDefaultBlockedWords, validateAutomodRegexPatterns, type AutomodConfigDashboard, type AutomodConfigDashboardPatch, @@ -19,6 +21,32 @@ async function ensureAutoModConfig(guildId: string) { }); } +async function seedDefaultWordFilterIfNeeded(guildId: string): Promise { + const rule = await prisma.autoModRule.findUnique({ + where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } }, + select: { id: true, wordList: true } + }); + if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) { + return; + } + const existing = + rule.wordList && typeof rule.wordList === 'object' + ? (rule.wordList as { regexPatterns?: unknown }) + : {}; + const regexPatterns = Array.isArray(existing.regexPatterns) + ? existing.regexPatterns.map(String) + : []; + await prisma.autoModRule.update({ + where: { id: rule.id }, + data: { + wordList: { + ...defaultAutoModWordList(), + regexPatterns + } as Prisma.InputJsonValue + } + }); +} + async function ensureAllRules(guildId: string): Promise { const existing = await prisma.autoModRule.findMany({ where: { guildId }, @@ -26,19 +54,19 @@ async function ensureAllRules(guildId: string): Promise { }); const have = new Set(existing.map((row) => row.ruleType)); const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType)); - if (missing.length === 0) { - return; + if (missing.length > 0) { + await prisma.autoModRule.createMany({ + data: missing.map((rule) => ({ + guildId, + ruleType: rule.ruleType, + action: rule.action, + threshold: rule.threshold ?? undefined, + wordList: rule.wordList ?? undefined, + enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) + })) + }); } - await prisma.autoModRule.createMany({ - data: missing.map((rule) => ({ - guildId, - ruleType: rule.ruleType, - action: rule.action, - threshold: rule.threshold ?? undefined, - wordList: rule.wordList ?? undefined, - enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType) - })) - }); + await seedDefaultWordFilterIfNeeded(guildId); } function parseRuleRow(row: { @@ -58,7 +86,7 @@ function parseRuleRow(row: { : { roleIds: [], channelIds: [] }; const wordList = row.wordList && typeof row.wordList === 'object' - ? (row.wordList as AutomodRuleDashboard['wordList']) + ? (row.wordList as AutomodRuleDashboard['wordList'] & { userCleared?: boolean }) : { words: [], regexPatterns: [] }; return { @@ -83,7 +111,8 @@ function parseRuleRow(row: { }, wordList: { words: Array.isArray(wordList.words) ? wordList.words.map(String) : [], - regexPatterns: Array.isArray(wordList.regexPatterns) ? wordList.regexPatterns.map(String) : [] + regexPatterns: Array.isArray(wordList.regexPatterns) ? wordList.regexPatterns.map(String) : [], + ...(wordList.userCleared === true ? { userCleared: true } : {}) } }; } diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 088b2d4..969f39a 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -401,6 +401,7 @@ "linkBlacklist": "Link-Blacklist (Hosts)", "addHost": "Host hinzufügen", "wordList": "Wortliste", + "loadDefaultWords": "Standardliste laden (DE/EN)", "regexList": "Regex-Muster", "wordPlaceholder": "verbotenes Wort", "addWord": "Wort hinzufügen", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index c401744..122df19 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -401,6 +401,7 @@ "linkBlacklist": "Link blacklist (hosts)", "addHost": "Add host", "wordList": "Word list", + "loadDefaultWords": "Load default list (DE/EN)", "regexList": "Regex patterns", "wordPlaceholder": "banned word", "addWord": "Add word", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index df10b1d..33ab45a 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -634,3 +634,18 @@ - [ ] AutoMod-WARN → ebenfalls DM + Case verknüpft - [ ] Dashboard: Warn-Tabelle zeigt `#`-Spalte +## Post-Phase – AutoMod Standard-Wortliste DE/EN (Status: implementiert) + +### Abgeschlossen (Code) + +- `DEFAULT_AUTOMOD_BLOCKED_WORDS` (gängige DE+EN Schimpfwörter) als Default für `WORD_FILTER` +- Bestehende leere Wortlisten werden beim Ensure einmalig befüllt (außer bewusst geleert) +- Wort-Matching über Unicode-Wortgrenzen (weniger False Positives wie `class`/`ass`) +- Dashboard-Button „Standardliste laden (DE/EN)“ + +### Manuell testen + +- [ ] Automod → Wortfilter: Liste ist vorausgefüllt bzw. Button lädt Defaults +- [ ] WORD_FILTER aktivieren → Nachricht mit Blockwort wird gefiltert +- [ ] Harmlose Wörter (`class`, `hello`) lösen keinen Treffer aus + diff --git a/packages/shared/src/phase2.test.ts b/packages/shared/src/phase2.test.ts index d138f50..a5e8d9d 100644 --- a/packages/shared/src/phase2.test.ts +++ b/packages/shared/src/phase2.test.ts @@ -2,10 +2,14 @@ import { describe, expect, it } from 'vitest'; import { applyWelcomePlaceholders, capsRatio, + contentMatchesBlockedWord, countEmojis, + DEFAULT_AUTOMOD_BLOCKED_WORDS, hasZalgo, isDiscordInvite, - normalizeHost + mergeDefaultBlockedWords, + normalizeHost, + shouldSeedDefaultBlockedWords } from './phase2.js'; describe('phase2 helpers', () => { @@ -73,4 +77,35 @@ describe('phase2 helpers', () => { it('normalizes hostnames', () => { expect(normalizeHost('https://www.Example.com/path')).toBe('example.com'); }); + + it('matches blocked words on word boundaries', () => { + expect(contentMatchesBlockedWord('what the fuck', 'fuck')).toBe(true); + expect(contentMatchesBlockedWord('Du bist ein Arschloch!', 'arschloch')).toBe(true); + expect(contentMatchesBlockedWord('class is starting', 'ass')).toBe(false); + expect(contentMatchesBlockedWord('hello there', 'hell')).toBe(false); + expect(contentMatchesBlockedWord('grape juice', 'rape')).toBe(false); + }); + + it('ships a non-empty default DE/EN blocklist', () => { + expect(DEFAULT_AUTOMOD_BLOCKED_WORDS.length).toBeGreaterThan(40); + expect(DEFAULT_AUTOMOD_BLOCKED_WORDS).toContain('fuck'); + expect(DEFAULT_AUTOMOD_BLOCKED_WORDS).toContain('scheiße'); + expect(DEFAULT_AUTOMOD_BLOCKED_WORDS).toContain('hurensohn'); + }); + + it('merges default blocked words without duplicates', () => { + const merged = mergeDefaultBlockedWords(['custom', 'fuck']); + expect(merged[0]).toBe('custom'); + expect(merged.filter((word) => word.toLowerCase() === 'fuck')).toHaveLength(1); + expect(merged).toContain('scheiße'); + }); + + it('only seeds default words when list is empty and not user-cleared', () => { + expect(shouldSeedDefaultBlockedWords(null)).toBe(true); + expect(shouldSeedDefaultBlockedWords({ words: [], regexPatterns: [] })).toBe(true); + expect(shouldSeedDefaultBlockedWords({ words: [], regexPatterns: [], userCleared: true })).toBe( + false + ); + expect(shouldSeedDefaultBlockedWords({ words: ['x'], regexPatterns: [] })).toBe(false); + }); }); diff --git a/packages/shared/src/phase2.ts b/packages/shared/src/phase2.ts index bae3ab1..536f27c 100644 --- a/packages/shared/src/phase2.ts +++ b/packages/shared/src/phase2.ts @@ -38,10 +38,137 @@ export type AutoModThreshold = z.infer; export const AutoModWordListSchema = z.object({ words: z.array(z.string()).default([]), - regexPatterns: z.array(z.string()).default([]) + regexPatterns: z.array(z.string()).default([]), + /** When true, empty words must not be re-seeded with defaults. */ + userCleared: z.boolean().optional() }); export type AutoModWordList = z.infer; +/** + * Built-in DE + EN blocklist for WORD_FILTER (common profanity / insults). + * Matching uses word boundaries for single tokens (see contentMatchesBlockedWord). + */ +export const DEFAULT_AUTOMOD_BLOCKED_WORDS: string[] = [ + // English + 'fuck', + 'fucking', + 'fucked', + 'fucker', + 'motherfucker', + 'shit', + 'shitty', + 'bullshit', + 'bitch', + 'bitches', + 'asshole', + 'bastard', + 'dickhead', + 'dick', + 'cunt', + 'cock', + 'pussy', + 'whore', + 'slut', + 'retard', + 'retarded', + 'faggot', + 'fag', + 'nigger', + 'nigga', + 'twat', + 'wanker', + 'bollocks', + 'dumbass', + 'jackass', + 'piss', + 'pedo', + 'paedo', + 'rape', + 'rapist', + // German + 'scheiße', + 'scheisse', + 'scheiß', + 'scheiss', + 'fotze', + 'hurensohn', + 'hurenkind', + 'arschloch', + 'arschgeige', + 'wichser', + 'schwuchtel', + 'schlampe', + 'nutte', + 'hure', + 'ficken', + 'gefickt', + 'fick', + 'fickt', + 'schwanz', + 'pisser', + 'spast', + 'spassti', + 'mongo', + 'missgeburt', + 'neger', + 'kanake', + 'drecksau', + 'miststück', + 'vollidiot', + 'wichsen', + 'titten', + 'möse', + 'moese' +]; + +export function defaultAutoModWordList(): AutoModWordList { + return { + words: [...DEFAULT_AUTOMOD_BLOCKED_WORDS], + regexPatterns: [] + }; +} + +/** True when an empty word list should be filled with DEFAULT_AUTOMOD_BLOCKED_WORDS. */ +export function shouldSeedDefaultBlockedWords(raw: unknown): boolean { + if (!raw || typeof raw !== 'object') { + return true; + } + const obj = raw as Record; + if (obj.userCleared === true) { + return false; + } + const words = Array.isArray(obj.words) ? obj.words : []; + return words.length === 0; +} + +export function mergeDefaultBlockedWords(existing: string[]): string[] { + const seen = new Set(existing.map((word) => word.toLowerCase())); + const merged = [...existing]; + for (const word of DEFAULT_AUTOMOD_BLOCKED_WORDS) { + if (!seen.has(word.toLowerCase())) { + seen.add(word.toLowerCase()); + merged.push(word); + } + } + return merged; +} + +/** + * Match a blocklist entry against message content. + * Multi-word entries use substring match; single tokens use Unicode word boundaries. + */ +export function contentMatchesBlockedWord(content: string, word: string): boolean { + const trimmed = word.trim(); + if (!trimmed) { + return false; + } + if (/\s/.test(trimmed)) { + return content.toLowerCase().includes(trimmed.toLowerCase()); + } + const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?