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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user