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

@@ -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' ? (
<div className="grid gap-4 sm:grid-cols-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
value={rule.wordList.words}
onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })}

View File

@@ -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<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> {
const existing = await prisma.autoModRule.findMany({
where: { guildId },
@@ -26,19 +54,19 @@ async function ensureAllRules(guildId: string): Promise<void> {
});
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 } : {})
}
};
}

View File

@@ -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",

View File

@@ -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",