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