Enhance AutoMod functionality with default word list and improved filtering

- Introduced a default blocked words list for the WORD_FILTER, including common profanity in both English and German.
- Updated the word matching logic to utilize Unicode word boundaries, reducing false positives.
- Implemented a button in the WebUI to load the default word list, enhancing user experience.
- Added functionality to seed default word filters only when necessary, preventing overwriting user-defined lists.
- Improved localization for new features related to the word list in both English and German.
- Documented changes in phase tracking to reflect the new AutoMod enhancements.
This commit is contained in:
TheOnlyMace
2026-07-25 17:42:06 +02:00
parent 2058713e03
commit 03bb406d82
9 changed files with 305 additions and 48 deletions

View File

@@ -1,6 +1,7 @@
import type { Redis } from 'ioredis'; import type { Redis } from 'ioredis';
import { import {
capsRatio, capsRatio,
contentMatchesBlockedWord,
countEmojis, countEmojis,
extractUrls, extractUrls,
hasZalgo, hasZalgo,
@@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter
} }
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null { function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
const lower = content.toLowerCase();
for (const word of wordList.words) { 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' }; return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
} }
} }
for (const pattern of wordList.regexPatterns) { for (const pattern of wordList.regexPatterns) {
try { try {
const regex = new RegExp(pattern, 'i'); const regex = new RegExp(pattern, 'iu');
if (regex.test(content)) { if (regex.test(content)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' }; return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
} }

View File

@@ -1,6 +1,8 @@
import type { PrismaClient } from '@prisma/client'; import type { Prisma, PrismaClient } from '@prisma/client';
import { import {
DEFAULT_AUTOMOD_RULES, DEFAULT_AUTOMOD_RULES,
defaultAutoModWordList,
shouldSeedDefaultBlockedWords,
type AutoModExceptions, type AutoModExceptions,
type AutoModThreshold, type AutoModThreshold,
type AutoModWordList type AutoModWordList
@@ -16,6 +18,26 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
return config; 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) { export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId); await ensureGuild(prisma, guildId);
const existing = await prisma.autoModRule.findMany({ const existing = await prisma.autoModRule.findMany({
@@ -24,9 +46,7 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
}); });
const have = new Set(existing.map((row) => row.ruleType)); const have = new Set(existing.map((row) => row.ruleType));
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType)); const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
if (missing.length === 0) { if (missing.length > 0) {
return;
}
await prisma.autoModRule.createMany({ await prisma.autoModRule.createMany({
data: missing.map((rule) => ({ data: missing.map((rule) => ({
guildId, guildId,
@@ -38,6 +58,8 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
})) }))
}); });
} }
await seedDefaultWordFilterIfNeeded(prisma, guildId);
}
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) { export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureDefaultAutoModRules(prisma, guildId); await ensureDefaultAutoModRules(prisma, guildId);
@@ -71,7 +93,8 @@ export function parseWordList(raw: unknown): AutoModWordList {
const obj = raw as Record<string, unknown>; const obj = raw as Record<string, unknown>;
return { return {
words: Array.isArray(obj.words) ? obj.words.map(String) : [], 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 } : {})
}; };
} }

View File

@@ -6,8 +6,10 @@ import type {
AutomodConfigDashboard, AutomodConfigDashboard,
AutomodRuleDashboard AutomodRuleDashboard
} from '@nexumi/shared'; } from '@nexumi/shared';
import { mergeDefaultBlockedWords } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor'; import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select'; import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select'; import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
@@ -25,7 +27,12 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
try { try {
const cleaned: AutomodConfigDashboard = { const cleaned: AutomodConfigDashboard = {
...value, ...value,
rules: value.rules.map((rule) => ({ 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, ...rule,
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null, durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
threshold: { threshold: {
@@ -34,10 +41,12 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean) linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean)
}, },
wordList: { wordList: {
words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean), words,
regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean) regexPatterns,
...(words.length === 0 ? { userCleared: true as const } : {})
} }
})) };
})
}; };
const response = await fetch(`/api/guilds/${guildId}/automod`, { const response = await fetch(`/api/guilds/${guildId}/automod`, {
method: 'PATCH', method: 'PATCH',
@@ -279,7 +288,24 @@ function RuleCard({
{type === 'WORD_FILTER' ? ( {type === 'WORD_FILTER' ? (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>{t('modulePages.automod.wordList')}</Label> <Label>{t('modulePages.automod.wordList')}</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
onChange({
wordList: {
...rule.wordList,
words: mergeDefaultBlockedWords(rule.wordList.words ?? [])
}
})
}
>
{t('modulePages.automod.loadDefaultWords')}
</Button>
</div>
<StringListEditor <StringListEditor
value={rule.wordList.words} value={rule.wordList.words}
onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })} onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })}

View File

@@ -1,5 +1,7 @@
import { import {
DEFAULT_AUTOMOD_RULES, DEFAULT_AUTOMOD_RULES,
defaultAutoModWordList,
shouldSeedDefaultBlockedWords,
validateAutomodRegexPatterns, validateAutomodRegexPatterns,
type AutomodConfigDashboard, type AutomodConfigDashboard,
type AutomodConfigDashboardPatch, type AutomodConfigDashboardPatch,
@@ -19,6 +21,32 @@ async function ensureAutoModConfig(guildId: string) {
}); });
} }
async function seedDefaultWordFilterIfNeeded(guildId: string): Promise<void> {
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<void> { async function ensureAllRules(guildId: string): Promise<void> {
const existing = await prisma.autoModRule.findMany({ const existing = await prisma.autoModRule.findMany({
where: { guildId }, where: { guildId },
@@ -26,9 +54,7 @@ async function ensureAllRules(guildId: string): Promise<void> {
}); });
const have = new Set(existing.map((row) => row.ruleType)); const have = new Set(existing.map((row) => row.ruleType));
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType)); const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
if (missing.length === 0) { if (missing.length > 0) {
return;
}
await prisma.autoModRule.createMany({ await prisma.autoModRule.createMany({
data: missing.map((rule) => ({ data: missing.map((rule) => ({
guildId, guildId,
@@ -40,6 +66,8 @@ async function ensureAllRules(guildId: string): Promise<void> {
})) }))
}); });
} }
await seedDefaultWordFilterIfNeeded(guildId);
}
function parseRuleRow(row: { function parseRuleRow(row: {
ruleType: string; ruleType: string;
@@ -58,7 +86,7 @@ function parseRuleRow(row: {
: { roleIds: [], channelIds: [] }; : { roleIds: [], channelIds: [] };
const wordList = const wordList =
row.wordList && typeof row.wordList === 'object' row.wordList && typeof row.wordList === 'object'
? (row.wordList as AutomodRuleDashboard['wordList']) ? (row.wordList as AutomodRuleDashboard['wordList'] & { userCleared?: boolean })
: { words: [], regexPatterns: [] }; : { words: [], regexPatterns: [] };
return { return {
@@ -83,7 +111,8 @@ function parseRuleRow(row: {
}, },
wordList: { wordList: {
words: Array.isArray(wordList.words) ? wordList.words.map(String) : [], 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 } : {})
} }
}; };
} }

View File

@@ -401,6 +401,7 @@
"linkBlacklist": "Link-Blacklist (Hosts)", "linkBlacklist": "Link-Blacklist (Hosts)",
"addHost": "Host hinzufügen", "addHost": "Host hinzufügen",
"wordList": "Wortliste", "wordList": "Wortliste",
"loadDefaultWords": "Standardliste laden (DE/EN)",
"regexList": "Regex-Muster", "regexList": "Regex-Muster",
"wordPlaceholder": "verbotenes Wort", "wordPlaceholder": "verbotenes Wort",
"addWord": "Wort hinzufügen", "addWord": "Wort hinzufügen",

View File

@@ -401,6 +401,7 @@
"linkBlacklist": "Link blacklist (hosts)", "linkBlacklist": "Link blacklist (hosts)",
"addHost": "Add host", "addHost": "Add host",
"wordList": "Word list", "wordList": "Word list",
"loadDefaultWords": "Load default list (DE/EN)",
"regexList": "Regex patterns", "regexList": "Regex patterns",
"wordPlaceholder": "banned word", "wordPlaceholder": "banned word",
"addWord": "Add word", "addWord": "Add word",

View File

@@ -634,3 +634,18 @@
- [ ] AutoMod-WARN → ebenfalls DM + Case verknüpft - [ ] AutoMod-WARN → ebenfalls DM + Case verknüpft
- [ ] Dashboard: Warn-Tabelle zeigt `#`-Spalte - [ ] 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

View File

@@ -2,10 +2,14 @@ import { describe, expect, it } from 'vitest';
import { import {
applyWelcomePlaceholders, applyWelcomePlaceholders,
capsRatio, capsRatio,
contentMatchesBlockedWord,
countEmojis, countEmojis,
DEFAULT_AUTOMOD_BLOCKED_WORDS,
hasZalgo, hasZalgo,
isDiscordInvite, isDiscordInvite,
normalizeHost mergeDefaultBlockedWords,
normalizeHost,
shouldSeedDefaultBlockedWords
} from './phase2.js'; } from './phase2.js';
describe('phase2 helpers', () => { describe('phase2 helpers', () => {
@@ -73,4 +77,35 @@ describe('phase2 helpers', () => {
it('normalizes hostnames', () => { it('normalizes hostnames', () => {
expect(normalizeHost('https://www.Example.com/path')).toBe('example.com'); 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);
});
}); });

View File

@@ -38,10 +38,137 @@ export type AutoModThreshold = z.infer<typeof AutoModThresholdSchema>;
export const AutoModWordListSchema = z.object({ export const AutoModWordListSchema = z.object({
words: z.array(z.string()).default([]), 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<typeof AutoModWordListSchema>; export type AutoModWordList = z.infer<typeof AutoModWordListSchema>;
/**
* 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<string, unknown>;
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(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, 'iu').test(content);
}
export const DEFAULT_AUTOMOD_RULES: Array<{ export const DEFAULT_AUTOMOD_RULES: Array<{
ruleType: AutoModRuleType; ruleType: AutoModRuleType;
action: AutoModAction; action: AutoModAction;
@@ -75,7 +202,7 @@ export const DEFAULT_AUTOMOD_RULES: Array<{
{ {
ruleType: 'WORD_FILTER', ruleType: 'WORD_FILTER',
action: 'DELETE', action: 'DELETE',
wordList: { words: [], regexPatterns: [] } wordList: defaultAutoModWordList()
}, },
{ {
ruleType: 'DUPLICATE', ruleType: 'DUPLICATE',