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 {
|
||||
capsRatio,
|
||||
contentMatchesBlockedWord,
|
||||
countEmojis,
|
||||
extractUrls,
|
||||
hasZalgo,
|
||||
@@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter
|
||||
}
|
||||
|
||||
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
|
||||
const lower = content.toLowerCase();
|
||||
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' };
|
||||
}
|
||||
}
|
||||
for (const pattern of wordList.regexPatterns) {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
const regex = new RegExp(pattern, 'iu');
|
||||
if (regex.test(content)) {
|
||||
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 {
|
||||
DEFAULT_AUTOMOD_RULES,
|
||||
defaultAutoModWordList,
|
||||
shouldSeedDefaultBlockedWords,
|
||||
type AutoModExceptions,
|
||||
type AutoModThreshold,
|
||||
type AutoModWordList
|
||||
@@ -16,6 +18,26 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
|
||||
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) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
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 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(prisma, guildId);
|
||||
}
|
||||
|
||||
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>;
|
||||
return {
|
||||
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,
|
||||
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 } })}
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user