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:
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +46,19 @@ 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({
|
||||||
|
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({
|
await seedDefaultWordFilterIfNeeded(prisma, guildId);
|
||||||
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)
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
|
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||||
@@ -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 } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,19 +27,26 @@ 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) => {
|
||||||
...rule,
|
const words = (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean);
|
||||||
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
|
const regexPatterns = (rule.wordList.regexPatterns ?? [])
|
||||||
threshold: {
|
.map((s) => s.trim())
|
||||||
...rule.threshold,
|
.filter(Boolean);
|
||||||
linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean),
|
return {
|
||||||
linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean)
|
...rule,
|
||||||
},
|
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
|
||||||
wordList: {
|
threshold: {
|
||||||
words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean),
|
...rule.threshold,
|
||||||
regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean)
|
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`, {
|
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">
|
||||||
<Label>{t('modulePages.automod.wordList')}</Label>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<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 } })}
|
||||||
|
|||||||
@@ -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,19 +54,19 @@ 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({
|
||||||
|
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({
|
await seedDefaultWordFilterIfNeeded(guildId);
|
||||||
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)
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRuleRow(row: {
|
function parseRuleRow(row: {
|
||||||
@@ -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 } : {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user