Implement lockdown management in automod system

- Added functionality to activate and deactivate lockdowns via the automod worker, responding to specific job names.
- Enhanced the anti-raid join handler to apply a verification role when lockdown is active and the anti-raid action is set to VERIFY.
- Updated the automod configuration to ensure default rules are created if missing, improving the setup process for new guilds.
- Introduced new API endpoints for managing cases and warnings, allowing for soft deletion and reason updates.
- Enhanced the moderation dashboard to include warnings management, improving user experience in moderation tasks.
- Updated localization files to reflect new features and improve user guidance in both English and German.
This commit is contained in:
smueller
2026-07-24 10:21:49 +02:00
parent fba597a6f2
commit bd1b55fff7
22 changed files with 1517 additions and 242 deletions

View File

@@ -130,6 +130,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
hash: 'field-mod-cases',
keywords: ['cases', 'fälle']
},
{
id: 'setting-mod-warnings',
kind: 'setting',
labelKey: 'modulePages.moderation.warningsTitle',
href: 'moderation',
hash: 'field-mod-warnings',
keywords: ['warn', 'verwarnung']
},
// Automod
{
id: 'setting-automod-enabled',
@@ -138,6 +146,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'automod',
hash: 'field-automod-enabled'
},
{
id: 'setting-automod-rules',
kind: 'setting',
labelKey: 'modulePages.automod.rulesTitle',
href: 'automod',
hash: 'field-automod-rules',
keywords: ['filter', 'word', 'whitelist', 'blacklist', 'regex']
},
{
id: 'setting-anti-raid',
kind: 'setting',

View File

@@ -1,5 +1,14 @@
import type { AutomodConfigDashboard, AutomodConfigDashboardPatch } from '@nexumi/shared';
import {
DEFAULT_AUTOMOD_RULES,
validateAutomodRegexPatterns,
type AutomodConfigDashboard,
type AutomodConfigDashboardPatch,
type AutomodRuleDashboard,
type AutoModRuleType
} from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
import { getAutomodQueue } from '../queues';
async function ensureAutoModConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
@@ -10,34 +19,172 @@ async function ensureAutoModConfig(guildId: string) {
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureAutoModConfig>>): AutomodConfigDashboard {
async function ensureAllRules(guildId: string): Promise<void> {
const existing = await prisma.autoModRule.findMany({
where: { guildId },
select: { ruleType: true }
});
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;
}
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)
}))
});
}
function parseRuleRow(row: {
ruleType: string;
enabled: boolean;
action: string;
durationMs: number | null;
threshold: unknown;
exceptions: unknown;
wordList: unknown;
}): AutomodRuleDashboard {
const threshold =
row.threshold && typeof row.threshold === 'object' ? (row.threshold as AutomodRuleDashboard['threshold']) : {};
const exceptions =
row.exceptions && typeof row.exceptions === 'object'
? (row.exceptions as AutomodRuleDashboard['exceptions'])
: { roleIds: [], channelIds: [] };
const wordList =
row.wordList && typeof row.wordList === 'object'
? (row.wordList as AutomodRuleDashboard['wordList'])
: { words: [], regexPatterns: [] };
return {
ruleType: row.ruleType as AutoModRuleType,
enabled: row.enabled,
action: row.action as AutomodRuleDashboard['action'],
durationMs: row.durationMs,
threshold: {
maxMentions: threshold.maxMentions,
capsPercent: threshold.capsPercent,
minLength: threshold.minLength,
maxMessages: threshold.maxMessages,
windowSeconds: threshold.windowSeconds,
maxEmojis: threshold.maxEmojis,
duplicateCount: threshold.duplicateCount,
linkWhitelist: threshold.linkWhitelist ?? [],
linkBlacklist: threshold.linkBlacklist ?? []
},
exceptions: {
roleIds: Array.isArray(exceptions.roleIds) ? exceptions.roleIds.map(String) : [],
channelIds: Array.isArray(exceptions.channelIds) ? exceptions.channelIds.map(String) : []
},
wordList: {
words: Array.isArray(wordList.words) ? wordList.words.map(String) : [],
regexPatterns: Array.isArray(wordList.regexPatterns) ? wordList.regexPatterns.map(String) : []
}
};
}
function toDashboard(
config: Awaited<ReturnType<typeof ensureAutoModConfig>>,
rules: AutomodRuleDashboard[]
): AutomodConfigDashboard {
const order = DEFAULT_AUTOMOD_RULES.map((rule) => rule.ruleType);
const byType = new Map(rules.map((rule) => [rule.ruleType, rule]));
const ordered = order
.map((type) => byType.get(type))
.filter((rule): rule is AutomodRuleDashboard => Boolean(rule));
return {
enabled: config.enabled,
antiRaidEnabled: config.antiRaidEnabled,
antiRaidJoinThreshold: config.antiRaidJoinThreshold,
antiRaidWindowSeconds: config.antiRaidWindowSeconds,
antiRaidAction: 'LOCKDOWN',
antiRaidAction: (config.antiRaidAction === 'VERIFY' ? 'VERIFY' : 'LOCKDOWN') as AutomodConfigDashboard['antiRaidAction'],
antiNukeEnabled: config.antiNukeEnabled,
antiNukeBanThreshold: config.antiNukeBanThreshold,
antiNukeChannelThreshold: config.antiNukeChannelThreshold,
antiNukeWindowSeconds: config.antiNukeWindowSeconds,
lockdownActive: config.lockdownActive
lockdownActive: config.lockdownActive,
rules: ordered
};
}
export async function getAutomodDashboard(guildId: string): Promise<AutomodConfigDashboard> {
const config = await ensureAutoModConfig(guildId);
return toDashboard(config);
await ensureAllRules(guildId);
const rows = await prisma.autoModRule.findMany({ where: { guildId } });
return toDashboard(config, rows.map(parseRuleRow));
}
export async function updateAutomodDashboard(
guildId: string,
patch: AutomodConfigDashboardPatch
): Promise<AutomodConfigDashboard> {
await ensureAutoModConfig(guildId);
const updated = await prisma.autoModConfig.update({
where: { guildId },
data: patch
const before = await getAutomodDashboard(guildId);
if (patch.rules) {
for (const rule of patch.rules) {
const bad = validateAutomodRegexPatterns(rule.wordList?.regexPatterns ?? []);
if (bad) {
throw new Error(`Invalid regex pattern: ${bad}`);
}
}
}
const { rules, ...configPatch } = patch;
const data: Prisma.AutoModConfigUpdateInput = { ...configPatch };
await prisma.$transaction(async (tx) => {
if (Object.keys(data).length > 0) {
await tx.autoModConfig.update({ where: { guildId }, data });
}
if (rules) {
for (const rule of rules) {
await tx.autoModRule.upsert({
where: { guildId_ruleType: { guildId, ruleType: rule.ruleType } },
create: {
guildId,
ruleType: rule.ruleType,
enabled: rule.enabled,
action: rule.action,
durationMs: rule.durationMs ?? null,
threshold: rule.threshold as Prisma.InputJsonValue,
exceptions: rule.exceptions as Prisma.InputJsonValue,
wordList: rule.wordList as Prisma.InputJsonValue
},
update: {
enabled: rule.enabled,
action: rule.action,
durationMs: rule.durationMs ?? null,
threshold: rule.threshold as Prisma.InputJsonValue,
exceptions: rule.exceptions as Prisma.InputJsonValue,
wordList: rule.wordList as Prisma.InputJsonValue
}
});
}
}
});
return toDashboard(updated);
const after = await getAutomodDashboard(guildId);
// Lockdown permission restore must run on the bot (discord.js), not in WebUI.
if (before.lockdownActive && after.lockdownActive === false) {
await getAutomodQueue().add(
'deactivateLockdown',
{ guildId },
{ removeOnComplete: 100, removeOnFail: 50 }
);
} else if (!before.lockdownActive && after.lockdownActive === true && after.antiRaidAction === 'LOCKDOWN') {
await getAutomodQueue().add(
'activateLockdown',
{ guildId },
{ removeOnComplete: 100, removeOnFail: 50 }
);
}
return after;
}

View File

@@ -1,4 +1,4 @@
import type { CaseDashboard, CaseListQuery } from '@nexumi/shared';
import type { CaseDashboard, CaseListQuery, CaseUpdateDashboard } from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
@@ -37,3 +37,44 @@ export async function listCases(guildId: string, query: CaseListQuery): Promise<
deletedAt: entry.deletedAt ? entry.deletedAt.toISOString() : null
}));
}
export async function updateCaseReason(
guildId: string,
caseId: string,
patch: CaseUpdateDashboard
): Promise<CaseDashboard | null> {
const existing = await prisma.case.findFirst({
where: { id: caseId, guildId, deletedAt: null }
});
if (!existing) {
return null;
}
const updated = await prisma.case.update({
where: { id: existing.id },
data: { reason: patch.reason }
});
return {
id: updated.id,
caseNumber: updated.caseNumber,
targetUserId: updated.targetUserId,
moderatorId: updated.moderatorId,
action: updated.action,
reason: updated.reason,
createdAt: updated.createdAt.toISOString(),
deletedAt: updated.deletedAt ? updated.deletedAt.toISOString() : null
};
}
export async function softDeleteCase(guildId: string, caseId: string): Promise<boolean> {
const existing = await prisma.case.findFirst({
where: { id: caseId, guildId, deletedAt: null }
});
if (!existing) {
return false;
}
await prisma.case.update({
where: { id: existing.id },
data: { deletedAt: new Date() }
});
return true;
}

View File

@@ -20,3 +20,14 @@ export async function listWarnings(guildId: string, query: WarningListQuery): Pr
createdAt: warning.createdAt.toISOString()
}));
}
export async function deleteWarning(guildId: string, warningId: string): Promise<boolean> {
const existing = await prisma.warning.findFirst({
where: { id: warningId, guildId }
});
if (!existing) {
return false;
}
await prisma.warning.delete({ where: { id: existing.id } });
return true;
}

View File

@@ -20,6 +20,7 @@ export const guildBackupQueueName = 'guild-backups';
export const suggestionsQueueName = 'suggestions';
export const messagesQueueName = 'messages';
export const verificationQueueName = 'verification';
export const automodQueueName = 'automod';
const globalForQueues = globalThis as unknown as {
__nexumiGiveawayQueue?: Queue;
@@ -28,6 +29,7 @@ const globalForQueues = globalThis as unknown as {
__nexumiSuggestionsQueue?: Queue;
__nexumiMessagesQueue?: Queue;
__nexumiVerificationQueue?: Queue;
__nexumiAutomodQueue?: Queue;
__nexumiGiveawayQueueEvents?: QueueEvents;
__nexumiGuildBackupQueueEvents?: QueueEvents;
__nexumiSuggestionsQueueEvents?: QueueEvents;
@@ -85,6 +87,13 @@ export function getVerificationQueue(): Queue {
return globalForQueues.__nexumiVerificationQueue;
}
export function getAutomodQueue(): Queue {
globalForQueues.__nexumiAutomodQueue ??= new Queue(automodQueueName, {
connection: queueConnection()
});
return globalForQueues.__nexumiAutomodQueue;
}
/**
* QueueEvents instances are only needed for jobs where the dashboard needs
* to wait for the bot to finish (e.g. ending a giveaway so the winners list