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

@@ -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);
});
});

View File

@@ -38,10 +38,137 @@ export type AutoModThreshold = z.infer<typeof AutoModThresholdSchema>;
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<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<{
ruleType: AutoModRuleType;
action: AutoModAction;
@@ -75,7 +202,7 @@ export const DEFAULT_AUTOMOD_RULES: Array<{
{
ruleType: 'WORD_FILTER',
action: 'DELETE',
wordList: { words: [], regexPatterns: [] }
wordList: defaultAutoModWordList()
},
{
ruleType: 'DUPLICATE',