From bd1b55fff7322498bc8821c1743e6b1b313fefb1 Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 10:21:49 +0200 Subject: [PATCH] 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. --- apps/bot/src/jobs.ts | 24 +- apps/bot/src/modules/automod/actions.ts | 45 +- apps/bot/src/modules/automod/service.ts | 11 +- .../modules/moderation/command-definitions.ts | 10 +- apps/bot/src/modules/moderation/commands.ts | 38 +- .../app/api/guilds/[guildId]/cases/route.ts | 66 +- .../api/guilds/[guildId]/warnings/route.ts | 35 +- .../dashboard/[guildId]/moderation/page.tsx | 10 +- .../src/components/modules/automod-form.tsx | 568 +++++++++++++++--- .../src/components/modules/cases-table.tsx | 226 ++++--- .../src/components/modules/warnings-table.tsx | 147 +++++ .../src/components/ui/string-list-editor.tsx | 53 ++ apps/webui/src/lib/dashboard-search-index.ts | 16 + apps/webui/src/lib/module-configs/automod.ts | 167 ++++- apps/webui/src/lib/module-configs/cases.ts | 43 +- apps/webui/src/lib/module-configs/warnings.ts | 11 + apps/webui/src/lib/queues.ts | 9 + apps/webui/src/messages/de.json | 103 +++- apps/webui/src/messages/en.json | 103 +++- docs/PHASE-TRACKING.md | 19 + packages/shared/src/command-locales.ts | 16 +- packages/shared/src/dashboard.ts | 39 +- 22 files changed, 1517 insertions(+), 242 deletions(-) create mode 100644 apps/webui/src/components/modules/warnings-table.tsx create mode 100644 apps/webui/src/components/ui/string-list-editor.tsx diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index fd371ea..f32e65c 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -8,6 +8,7 @@ import { logger } from './logger.js'; import type { BotContext } from './types.js'; import { env } from './env.js'; import { refreshPhishingDomains } from './modules/automod/filters.js'; +import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js'; import { completeVerification } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; @@ -212,11 +213,28 @@ export function startWorkers(context: BotContext): Worker[] { const automodWorker = new Worker( automodQueueName, async (job) => { - if (job.name !== 'refreshPhishingList') { + if (job.name === 'refreshPhishingList') { + const count = await refreshPhishingDomains(redis); + logger.info({ domainCount: count }, 'Phishing domain list refreshed'); return; } - const count = await refreshPhishingDomains(redis); - logger.info({ domainCount: count }, 'Phishing domain list refreshed'); + if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') { + const guildId = (job.data as { guildId?: string }).guildId; + if (!guildId) { + return; + } + const guild = await context.client.guilds.fetch(guildId).catch(() => null); + if (!guild) { + return; + } + if (job.name === 'deactivateLockdown') { + await deactivateLockdown(guild); + logger.info({ guildId }, 'Lockdown deactivated via dashboard'); + return; + } + await activateLockdown(guild); + logger.info({ guildId }, 'Lockdown activated via dashboard'); + } }, { connection: redis } ); diff --git a/apps/bot/src/modules/automod/actions.ts b/apps/bot/src/modules/automod/actions.ts index 1ea2695..df2188d 100644 --- a/apps/bot/src/modules/automod/actions.ts +++ b/apps/bot/src/modules/automod/actions.ts @@ -204,6 +204,16 @@ export async function handleAntiRaidJoin( return; } + // While VERIFY raid-response is active, keep gating new joins with the unverified role. + if (config.lockdownActive && config.antiRaidAction === 'VERIFY') { + await applyRaidVerifyRole(context, member); + return; + } + + if (config.lockdownActive) { + return; + } + const key = `automod:raid:${member.guild.id}`; const count = await context.redis.incr(key); if (count === 1) { @@ -214,10 +224,6 @@ export async function handleAntiRaidJoin( return; } - if (config.lockdownActive) { - return; - } - await context.prisma.autoModConfig.update({ where: { guildId: member.guild.id }, data: { lockdownActive: true } @@ -225,16 +231,45 @@ export async function handleAntiRaidJoin( if (config.antiRaidAction === 'LOCKDOWN') { await activateLockdown(member.guild); + } else if (config.antiRaidAction === 'VERIFY') { + await applyRaidVerifyRole(context, member); } const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel; if (alertChannel && alertChannel.isTextBased()) { await (alertChannel as TextChannel).send( - `Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s.` + `Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s (${config.antiRaidAction}).` ); } } +async function applyRaidVerifyRole(context: BotContext, member: GuildMember): Promise { + const verification = await context.prisma.verificationConfig.findUnique({ + where: { guildId: member.guild.id } + }); + const roleId = verification?.unverifiedRoleId; + if (!roleId) { + logger.warn( + { guildId: member.guild.id }, + 'Anti-raid VERIFY triggered but no unverified role is configured' + ); + return; + } + const me = member.guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return; + } + const role = member.guild.roles.cache.get(roleId); + if (!role || role.position >= me.roles.highest.position) { + return; + } + if (!member.roles.cache.has(roleId)) { + await member.roles.add(roleId).catch((error) => { + logger.warn({ error, memberId: member.id }, 'Failed to apply unverified role during raid verify'); + }); + } +} + export async function handleAntiNukeAction( context: BotContext, guildId: string, diff --git a/apps/bot/src/modules/automod/service.ts b/apps/bot/src/modules/automod/service.ts index b70bf97..1600281 100644 --- a/apps/bot/src/modules/automod/service.ts +++ b/apps/bot/src/modules/automod/service.ts @@ -18,12 +18,17 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) { export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) { await ensureGuild(prisma, guildId); - const existing = await prisma.autoModRule.count({ where: { guildId } }); - if (existing > 0) { + 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: DEFAULT_AUTOMOD_RULES.map((rule) => ({ + data: missing.map((rule) => ({ guildId, ruleType: rule.ruleType, action: rule.action, diff --git a/apps/bot/src/modules/moderation/command-definitions.ts b/apps/bot/src/modules/moderation/command-definitions.ts index eea3827..1122075 100644 --- a/apps/bot/src/modules/moderation/command-definitions.ts +++ b/apps/bot/src/modules/moderation/command-definitions.ts @@ -164,14 +164,20 @@ export const slowmodeCommandData = applyCommandDescription( export const lockCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('lock') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) + .addBooleanOption((o) => + applyOptionDescription(o.setName('server'), 'moderation.lock.options.server') + ), 'moderation.lock.description' ); export const unlockCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('unlock') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) + .addBooleanOption((o) => + applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server') + ), 'moderation.unlock.description' ); diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 0db6929..c42cc21 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -456,18 +456,31 @@ const lockCommand: SlashCommand = { async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return; - if (interaction.channel?.type === ChannelType.GuildText) { - await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { + const serverWide = interaction.options.getBoolean('server') ?? false; + const guild = interaction.guild!; + + if (serverWide) { + for (const channel of guild.channels.cache.values()) { + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + continue; + } + await channel.permissionOverwrites.edit(guild.roles.everyone, { + SendMessages: false + }); + } + } else if (interaction.channel?.type === ChannelType.GuildText) { + await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: false }); } + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'LOCK', - reason: 'Channel locked' + reason: serverWide ? 'Server locked' : 'Channel locked' }); await replySuccessCase(interaction, locale, modCase.caseNumber); } @@ -478,18 +491,31 @@ const unlockCommand: SlashCommand = { async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return; - if (interaction.channel?.type === ChannelType.GuildText) { - await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { + const serverWide = interaction.options.getBoolean('server') ?? false; + const guild = interaction.guild!; + + if (serverWide) { + for (const channel of guild.channels.cache.values()) { + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + continue; + } + await channel.permissionOverwrites.edit(guild.roles.everyone, { + SendMessages: null + }); + } + } else if (interaction.channel?.type === ChannelType.GuildText) { + await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: null }); } + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'UNLOCK', - reason: 'Channel unlocked' + reason: serverWide ? 'Server unlocked' : 'Channel unlocked' }); await replySuccessCase(interaction, locale, modCase.caseNumber); } diff --git a/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts b/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts index d25bf54..a040bbf 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/cases/route.ts @@ -1,7 +1,9 @@ -import { CaseListQuerySchema } from '@nexumi/shared'; +import { CaseListQuerySchema, CaseUpdateDashboardSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; -import { listCases } from '@/lib/module-configs/cases'; +import { writeDashboardAudit } from '@/lib/audit'; +import { listCases, softDeleteCase, updateCaseReason } from '@/lib/module-configs/cases'; +import { prisma } from '@/lib/prisma'; interface RouteParams { params: Promise<{ guildId: string }>; @@ -11,15 +13,63 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { guildId } = await params; try { await requireGuildAccess(guildId); - const searchParams = request.nextUrl.searchParams; - const query = CaseListQuerySchema.parse({ - search: searchParams.get('search') ?? undefined, - action: searchParams.get('action') ?? undefined, - limit: searchParams.get('limit') ?? undefined - }); + const query = CaseListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); const cases = await listCases(guildId, query); return NextResponse.json({ cases }); } catch (error) { return toApiErrorResponse(error); } } + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = (await request.json()) as { id?: string; reason?: string }; + if (!body.id) { + return NextResponse.json({ error: 'Missing case id' }, { status: 400 }); + } + const patch = CaseUpdateDashboardSchema.parse({ reason: body.reason }); + const updated = await updateCaseReason(guildId, body.id, patch); + if (!updated) { + return NextResponse.json({ error: 'Case not found' }, { status: 404 }); + } + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'case.update', + path: `/dashboard/${guildId}/moderation`, + before: { id: body.id }, + after: updated + }); + return NextResponse.json({ case: updated }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const id = request.nextUrl.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing case id' }, { status: 400 }); + } + const ok = await softDeleteCase(guildId, id); + if (!ok) { + return NextResponse.json({ error: 'Case not found' }, { status: 404 }); + } + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'case.delete', + path: `/dashboard/${guildId}/moderation`, + before: { id }, + after: { deleted: true } + }); + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts b/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts index 6d7a77f..1f3d288 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/warnings/route.ts @@ -1,7 +1,9 @@ import { WarningListQuerySchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; -import { listWarnings } from '@/lib/module-configs/warnings'; +import { writeDashboardAudit } from '@/lib/audit'; +import { deleteWarning, listWarnings } from '@/lib/module-configs/warnings'; +import { prisma } from '@/lib/prisma'; interface RouteParams { params: Promise<{ guildId: string }>; @@ -11,13 +13,36 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { guildId } = await params; try { await requireGuildAccess(guildId); - const searchParams = request.nextUrl.searchParams; - const query = WarningListQuerySchema.parse({ - userId: searchParams.get('userId') ?? undefined - }); + const query = WarningListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); const warnings = await listWarnings(guildId, query); return NextResponse.json({ warnings }); } catch (error) { return toApiErrorResponse(error); } } + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const id = request.nextUrl.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing warning id' }, { status: 400 }); + } + const ok = await deleteWarning(guildId, id); + if (!ok) { + return NextResponse.json({ error: 'Warning not found' }, { status: 404 }); + } + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'warning.delete', + path: `/dashboard/${guildId}/moderation`, + before: { id }, + after: { deleted: true } + }); + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx b/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx index e70627b..2097460 100644 --- a/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/moderation/page.tsx @@ -1,9 +1,11 @@ -import { CaseListQuerySchema } from '@nexumi/shared'; +import { CaseListQuerySchema, WarningListQuerySchema } from '@nexumi/shared'; import { CasesTable } from '@/components/modules/cases-table'; import { ModerationForm } from '@/components/modules/moderation-form'; +import { WarningsTable } from '@/components/modules/warnings-table'; import { getLocale, t } from '@/lib/i18n'; import { listCases } from '@/lib/module-configs/cases'; import { getModerationDashboard } from '@/lib/module-configs/moderation'; +import { listWarnings } from '@/lib/module-configs/warnings'; interface ModerationPageProps { params: Promise<{ guildId: string }>; @@ -11,10 +13,11 @@ interface ModerationPageProps { export default async function ModerationPage({ params }: ModerationPageProps) { const { guildId } = await params; - const [locale, moderation, cases] = await Promise.all([ + const [locale, moderation, cases, warnings] = await Promise.all([ getLocale(), getModerationDashboard(guildId), - listCases(guildId, CaseListQuerySchema.parse({})) + listCases(guildId, CaseListQuerySchema.parse({})), + listWarnings(guildId, WarningListQuerySchema.parse({})) ]); return ( @@ -24,6 +27,7 @@ export default async function ModerationPage({ params }: ModerationPageProps) {

{t(locale, 'modules.moderation.description')}

+ ); diff --git a/apps/webui/src/components/modules/automod-form.tsx b/apps/webui/src/components/modules/automod-form.tsx index 7817c8b..c295ec8 100644 --- a/apps/webui/src/components/modules/automod-form.tsx +++ b/apps/webui/src/components/modules/automod-form.tsx @@ -1,21 +1,48 @@ 'use client'; -import type { AutomodConfigDashboard } from '@nexumi/shared'; +import type { + AutoModAction, + AutoModRuleType, + AutomodConfigDashboard, + AutomodRuleDashboard +} from '@nexumi/shared'; import { FieldAnchor } from '@/components/layout/field-anchor'; import { useTranslations } from '@/components/locale-provider'; 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'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { StringListEditor } from '@/components/ui/string-list-editor'; import { Switch } from '@/components/ui/switch'; import type { SettingsSaveResult } from '@/components/settings/settings-form'; import { SettingsForm } from '@/components/settings/settings-form'; +const ACTIONS: AutoModAction[] = ['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN']; + async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise { 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) + } + })) + }; const response = await fetch(`/api/guilds/${guildId}/automod`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(value) + body: JSON.stringify(cleaned) }); if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string } | null; @@ -27,6 +54,281 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom } } +function updateRule( + rules: AutomodRuleDashboard[], + ruleType: AutoModRuleType, + patch: Partial +): AutomodRuleDashboard[] { + return rules.map((rule) => (rule.ruleType === ruleType ? { ...rule, ...patch } : rule)); +} + +function RuleCard({ + guildId, + rule, + onChange +}: { + guildId: string; + rule: AutomodRuleDashboard; + onChange: (patch: Partial) => void; +}) { + const t = useTranslations(); + const type = rule.ruleType; + + return ( + + + +
+
+ + {t(`modulePages.automod.rules.${type}.title`)} + + {t(`modulePages.automod.rules.${type}.description`)} +
+ onChange({ enabled })} + aria-label={t(`modulePages.automod.rules.${type}.title`)} + /> +
+
+ +
+
+ + +
+ {rule.action === 'TIMEOUT' ? ( +
+ + + onChange({ + durationMs: Math.max(1, Number(event.target.value) || 1) * 60_000 + }) + } + /> +
+ ) : null} +
+ + {type === 'SPAM' || type === 'DUPLICATE' ? ( +
+
+ + + onChange({ + threshold: { + ...rule.threshold, + ...(type === 'SPAM' + ? { maxMessages: Number(event.target.value) || 1 } + : { duplicateCount: Number(event.target.value) || 1 }) + } + }) + } + /> +
+
+ + + onChange({ + threshold: { + ...rule.threshold, + windowSeconds: Number(event.target.value) || 1 + } + }) + } + /> +
+
+ ) : null} + + {type === 'MASS_MENTION' ? ( +
+ + + onChange({ + threshold: { ...rule.threshold, maxMentions: Number(event.target.value) || 1 } + }) + } + /> +
+ ) : null} + + {type === 'CAPS' ? ( +
+
+ + + onChange({ + threshold: { + ...rule.threshold, + capsPercent: Number(event.target.value) || 0 + } + }) + } + /> +
+
+ + + onChange({ + threshold: { + ...rule.threshold, + minLength: Number(event.target.value) || 0 + } + }) + } + /> +
+
+ ) : null} + + {type === 'EMOJI_SPAM' ? ( +
+ + + onChange({ + threshold: { ...rule.threshold, maxEmojis: Number(event.target.value) || 1 } + }) + } + /> +
+ ) : null} + + {type === 'EXTERNAL_LINK' ? ( +
+
+ + + onChange({ threshold: { ...rule.threshold, linkWhitelist } }) + } + placeholder="discord.com" + addLabel={t('modulePages.automod.addHost')} + /> +
+
+ + + onChange({ threshold: { ...rule.threshold, linkBlacklist } }) + } + placeholder="example.com" + addLabel={t('modulePages.automod.addHost')} + /> +
+
+ ) : null} + + {type === 'WORD_FILTER' ? ( +
+
+ + onChange({ wordList: { ...rule.wordList, words } })} + placeholder={t('modulePages.automod.wordPlaceholder')} + addLabel={t('modulePages.automod.addWord')} + /> +
+
+ + + onChange({ wordList: { ...rule.wordList, regexPatterns } }) + } + placeholder="\\bspam\\b" + addLabel={t('modulePages.automod.addRegex')} + /> +
+
+ ) : null} + +
+
+ + + onChange({ exceptions: { ...rule.exceptions, roleIds } }) + } + /> +
+
+ + + onChange({ exceptions: { ...rule.exceptions, channelIds } }) + } + /> +
+
+
+
+
+ ); +} + interface AutomodFormProps { guildId: string; initialValue: AutomodConfigDashboard; @@ -36,7 +338,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) { const t = useTranslations(); return ( - initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}> + + initialValue={initialValue} + onSave={(value) => saveAutomod(guildId, value)} + > {({ value, setValue }) => (
@@ -60,112 +365,181 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) { - - - - {t('modulePages.automod.antiRaidTitle')} - {t('modulePages.automod.antiRaidDescription')} - - -
- - setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))} + +
+
+

{t('modulePages.automod.rulesTitle')}

+

{t('modulePages.automod.rulesDescription')}

+
+ {value.rules.map((rule) => ( + + setValue((prev) => ({ + ...prev, + rules: updateRule(prev.rules, rule.ruleType, patch) + })) + } /> -
-
-
- - - setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 })) + ))} +
+ + + + + + {t('modulePages.automod.antiRaidTitle')} + {t('modulePages.automod.antiRaidDescription')} + + +
+ + + setValue((prev) => ({ ...prev, antiRaidEnabled: checked })) } />
-
- - - setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 })) - } - /> +
+
+ + + setValue((prev) => ({ + ...prev, + antiRaidJoinThreshold: Number(event.target.value) || 1 + })) + } + /> +
+
+ + + setValue((prev) => ({ + ...prev, + antiRaidWindowSeconds: Number(event.target.value) || 1 + })) + } + /> +
+
+ + +
-
-

{t('modulePages.automod.antiRaidActionHint')}

-
-
+

{t('modulePages.automod.antiRaidActionHint')}

+ +
- - - {t('modulePages.automod.antiNukeTitle')} - {t('modulePages.automod.antiNukeDescription')} - - -
- - setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))} - /> -
-
-
- - - setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 })) - } - /> -
-
- - - setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 })) - } - /> -
-
- - - setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 })) - } - /> -
-
- + + + {t('modulePages.automod.antiNukeTitle')} + {t('modulePages.automod.antiNukeDescription')} + +
-
- -

{t('modulePages.automod.lockdownActiveHint')}

-
+ setValue((prev) => ({ ...prev, lockdownActive: checked }))} + checked={value.antiNukeEnabled} + onCheckedChange={(checked) => + setValue((prev) => ({ ...prev, antiNukeEnabled: checked })) + } />
-
-
-
+
+
+ + + setValue((prev) => ({ + ...prev, + antiNukeBanThreshold: Number(event.target.value) || 1 + })) + } + /> +
+
+ + + setValue((prev) => ({ + ...prev, + antiNukeChannelThreshold: Number(event.target.value) || 1 + })) + } + /> +
+
+ + + setValue((prev) => ({ + ...prev, + antiNukeWindowSeconds: Number(event.target.value) || 1 + })) + } + /> +
+
+ +
+
+ +

+ {t('modulePages.automod.lockdownActiveHint')} +

+
+ + setValue((prev) => ({ ...prev, lockdownActive: checked })) + } + /> +
+
+ +
)} diff --git a/apps/webui/src/components/modules/cases-table.tsx b/apps/webui/src/components/modules/cases-table.tsx index 5f8a37f..f288c33 100644 --- a/apps/webui/src/components/modules/cases-table.tsx +++ b/apps/webui/src/components/modules/cases-table.tsx @@ -1,7 +1,7 @@ 'use client'; import type { CaseDashboard } from '@nexumi/shared'; -import { Search } from 'lucide-react'; +import { Pencil, Search, Trash2 } from 'lucide-react'; import { useCallback, useState } from 'react'; import { FieldAnchor } from '@/components/layout/field-anchor'; import { useTranslations } from '@/components/locale-provider'; @@ -26,6 +26,8 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) { const [action, setAction] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editReason, setEditReason] = useState(''); const fetchCases = useCallback(async () => { setLoading(true); @@ -48,86 +50,158 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) { } }, [action, guildId, search, t]); + async function saveReason(caseId: string) { + setError(null); + const response = await fetch(`/api/guilds/${guildId}/cases`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: caseId, reason: editReason }) + }); + if (!response.ok) { + setError(t('common.saveError')); + return; + } + const body = (await response.json()) as { case: CaseDashboard }; + setCases((prev) => prev.map((entry) => (entry.id === caseId ? body.case : entry))); + setEditingId(null); + } + + async function removeCase(caseId: string) { + if (!window.confirm(t('modulePages.moderation.confirmDeleteCase'))) { + return; + } + setError(null); + const response = await fetch(`/api/guilds/${guildId}/cases?id=${encodeURIComponent(caseId)}`, { + method: 'DELETE' + }); + if (!response.ok) { + setError(t('common.saveError')); + return; + } + setCases((prev) => prev.filter((entry) => entry.id !== caseId)); + } + return ( - - - {t('modulePages.moderation.casesTitle')} - {t('modulePages.moderation.casesDescription')} - - -
{ - event.preventDefault(); - void fetchCases(); - }} - > -
- setSearch(event.target.value)} - placeholder={t('modulePages.moderation.searchPlaceholder')} - /> -
-
- setAction(event.target.value)} - placeholder={t('modulePages.moderation.actionPlaceholder')} - className="w-40" - /> -
- -
+ + + {t('modulePages.moderation.casesTitle')} + {t('modulePages.moderation.casesDescription')} + + +
{ + event.preventDefault(); + void fetchCases(); + }} + > +
+ setSearch(event.target.value)} + placeholder={t('modulePages.moderation.searchPlaceholder')} + /> +
+
+ setAction(event.target.value)} + placeholder={t('modulePages.moderation.actionPlaceholder')} + className="w-40" + /> +
+ +
- {error &&

{error}

} + {error &&

{error}

} - {loading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
- ) : cases.length === 0 ? ( -

{t('modulePages.moderation.casesEmpty')}

- ) : ( -
- - - - - - - - - - - - - {cases.map((entry) => ( - - - - - - - + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ) : cases.length === 0 ? ( +

{t('modulePages.moderation.casesEmpty')}

+ ) : ( +
+
#{t('modulePages.moderation.action')}{t('modulePages.moderation.target')}{t('modulePages.moderation.moderator')}{t('modulePages.moderation.reason')}{t('modulePages.moderation.createdAt')}
#{entry.caseNumber}{entry.action}{entry.targetUserId}{entry.moderatorId} - {entry.reason ?? t('modulePages.moderation.noReason')} - - {formatDate(entry.createdAt)} -
+ + + + + + + + + - ))} - -
#{t('modulePages.moderation.action')}{t('modulePages.moderation.target')}{t('modulePages.moderation.moderator')}{t('modulePages.moderation.reason')}{t('modulePages.moderation.createdAt')}
-
- )} -
-
+ + + {cases.map((entry) => ( + + #{entry.caseNumber} + {entry.action} + {entry.targetUserId} + {entry.moderatorId} + + {editingId === entry.id ? ( +
+ setEditReason(event.target.value)} + aria-label={t('modulePages.moderation.editReason')} + /> + +
+ ) : ( + + {entry.reason ?? t('modulePages.moderation.noReason')} + + )} + + + {formatDate(entry.createdAt)} + + +
+ + +
+ + + ))} + + +
+ )} +
+
); } diff --git a/apps/webui/src/components/modules/warnings-table.tsx b/apps/webui/src/components/modules/warnings-table.tsx new file mode 100644 index 0000000..4d7df11 --- /dev/null +++ b/apps/webui/src/components/modules/warnings-table.tsx @@ -0,0 +1,147 @@ +'use client'; + +import type { WarningDashboard } from '@nexumi/shared'; +import { Search, Trash2 } from 'lucide-react'; +import { useCallback, useState } from 'react'; +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 { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; + +interface WarningsTableProps { + guildId: string; + initialWarnings: WarningDashboard[]; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +export function WarningsTable({ guildId, initialWarnings }: WarningsTableProps) { + const t = useTranslations(); + const [warnings, setWarnings] = useState(initialWarnings); + const [userId, setUserId] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchWarnings = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (userId.trim()) params.set('userId', userId.trim()); + const response = await fetch(`/api/guilds/${guildId}/warnings?${params.toString()}`); + if (!response.ok) { + setError(t('common.saveError')); + return; + } + const body = (await response.json()) as { warnings: WarningDashboard[] }; + setWarnings(body.warnings); + } catch { + setError(t('common.saveError')); + } finally { + setLoading(false); + } + }, [guildId, t, userId]); + + async function removeWarning(warningId: string) { + if (!window.confirm(t('modulePages.moderation.confirmRemoveWarning'))) { + return; + } + setError(null); + const response = await fetch( + `/api/guilds/${guildId}/warnings?id=${encodeURIComponent(warningId)}`, + { method: 'DELETE' } + ); + if (!response.ok) { + setError(t('common.saveError')); + return; + } + setWarnings((prev) => prev.filter((entry) => entry.id !== warningId)); + } + + return ( + + + + {t('modulePages.moderation.warningsTitle')} + {t('modulePages.moderation.warningsDescription')} + + +
{ + event.preventDefault(); + void fetchWarnings(); + }} + > +
+ setUserId(event.target.value)} + placeholder={t('modulePages.moderation.warningsSearchUser')} + /> +
+ +
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : warnings.length === 0 ? ( +

{t('modulePages.moderation.warningsEmpty')}

+ ) : ( +
+ + + + + + + + + + + {warnings.map((entry) => ( + + + + + + + + ))} + +
{t('modulePages.moderation.target')}{t('modulePages.moderation.moderator')}{t('modulePages.moderation.reason')}{t('modulePages.moderation.createdAt')} +
{entry.userId}{entry.moderatorId} + {entry.reason ?? t('modulePages.moderation.noReason')} + + {formatDate(entry.createdAt)} + + +
+
+ )} +
+
+
+ ); +} diff --git a/apps/webui/src/components/ui/string-list-editor.tsx b/apps/webui/src/components/ui/string-list-editor.tsx new file mode 100644 index 0000000..991c433 --- /dev/null +++ b/apps/webui/src/components/ui/string-list-editor.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Plus, Trash2 } from 'lucide-react'; +import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface StringListEditorProps { + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + addLabel?: string; +} + +export function StringListEditor({ + value, + onChange, + placeholder, + addLabel +}: StringListEditorProps) { + const t = useTranslations(); + + return ( +
+ {value.map((entry, index) => ( +
+ { + const next = [...value]; + next[index] = event.target.value; + onChange(next); + }} + /> + +
+ ))} + +
+ ); +} diff --git a/apps/webui/src/lib/dashboard-search-index.ts b/apps/webui/src/lib/dashboard-search-index.ts index e94e172..b6d83dd 100644 --- a/apps/webui/src/lib/dashboard-search-index.ts +++ b/apps/webui/src/lib/dashboard-search-index.ts @@ -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', diff --git a/apps/webui/src/lib/module-configs/automod.ts b/apps/webui/src/lib/module-configs/automod.ts index ab769f7..8637b86 100644 --- a/apps/webui/src/lib/module-configs/automod.ts +++ b/apps/webui/src/lib/module-configs/automod.ts @@ -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>): AutomodConfigDashboard { +async function ensureAllRules(guildId: string): Promise { + 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>, + 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 { 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 { - 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; } diff --git a/apps/webui/src/lib/module-configs/cases.ts b/apps/webui/src/lib/module-configs/cases.ts index d7a84c1..fe943af 100644 --- a/apps/webui/src/lib/module-configs/cases.ts +++ b/apps/webui/src/lib/module-configs/cases.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/webui/src/lib/module-configs/warnings.ts b/apps/webui/src/lib/module-configs/warnings.ts index 8628986..cb5d2e2 100644 --- a/apps/webui/src/lib/module-configs/warnings.ts +++ b/apps/webui/src/lib/module-configs/warnings.ts @@ -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 { + const existing = await prisma.warning.findFirst({ + where: { id: warningId, guildId } + }); + if (!existing) { + return false; + } + await prisma.warning.delete({ where: { id: existing.id } }); + return true; +} diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index ae23b59..54c6194 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -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 diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 4b1be50..a53ec0a 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -347,34 +347,121 @@ "reason": "Grund", "reasonPlaceholder": "Optionaler Standardgrund", "casesTitle": "Fälle", - "casesDescription": "Liste der zuletzt erstellten Moderations-Fälle (nur Lesezugriff).", + "casesDescription": "Moderations-Fälle anzeigen, Grund bearbeiten oder soft-löschen.", "searchPlaceholder": "Suche nach User- oder Moderator-ID / Grund", "actionPlaceholder": "Aktion filtern, z. B. BAN", "casesEmpty": "Keine Fälle gefunden.", "target": "Ziel", "moderator": "Moderator", "createdAt": "Erstellt am", - "noReason": "Kein Grund angegeben" + "noReason": "Kein Grund angegeben", + "editReason": "Grund bearbeiten", + "saveReason": "Speichern", + "deleteCase": "Fall löschen", + "confirmDeleteCase": "Diesen Fall wirklich löschen?", + "warningsTitle": "Verwarnungen", + "warningsDescription": "Letzte Verwarnungen. Entfernen löscht den Warn-Eintrag.", + "warningsEmpty": "Keine Verwarnungen gefunden.", + "warningsSearchUser": "User-ID filtern", + "removeWarning": "Verwarnung entfernen", + "confirmRemoveWarning": "Diese Verwarnung wirklich entfernen?" }, "automod": { "title": "Auto-Moderation", - "description": "Grundeinstellungen sowie Anti-Raid- und Anti-Nuke-Schutz.", + "description": "Filterregeln, Anti-Raid und Anti-Nuke für diesen Server.", "enabledLabel": "Auto-Moderation aktiviert", - "enabledHint": "Aktiviert alle Auto-Moderations-Regeln (Spam, Wortfilter, Links, …) auf diesem Server.", + "enabledHint": "Master-Schalter: nur aktivierte Regeln unten greifen, wenn dieser Schalter an ist.", + "rulesTitle": "Filterregeln", + "rulesDescription": "Pro Regel: Aktion, Schwellenwerte, Wort-/Linklisten und Ausnahmen.", + "ruleAction": "Aktion", + "timeoutMinutes": "Timeout (Minuten)", + "actions": { + "DELETE": "Nachricht löschen", + "WARN": "Verwarnen", + "TIMEOUT": "Timeout", + "KICK": "Kick", + "BAN": "Ban" + }, + "thresholds": { + "maxMessages": "Max. Nachrichten", + "windowSeconds": "Zeitfenster (Sekunden)", + "maxMentions": "Max. Mentions", + "capsPercent": "Caps-Anteil (%)", + "minLength": "Min. Nachrichtenlänge", + "maxEmojis": "Max. Emojis", + "duplicateCount": "Duplikat-Anzahl" + }, + "linkWhitelist": "Link-Whitelist (Hosts)", + "linkBlacklist": "Link-Blacklist (Hosts)", + "addHost": "Host hinzufügen", + "wordList": "Wortliste", + "regexList": "Regex-Muster", + "wordPlaceholder": "verbotenes Wort", + "addWord": "Wort hinzufügen", + "addRegex": "Regex hinzufügen", + "exceptionRoles": "Ausnahme-Rollen", + "exceptionChannels": "Ausnahme-Kanäle", + "rules": { + "SPAM": { + "title": "Spam", + "description": "Zu viele Nachrichten in kurzer Zeit." + }, + "MASS_MENTION": { + "title": "Massen-Mentions", + "description": "Zu viele @-Mentions in einer Nachricht." + }, + "CAPS": { + "title": "Caps", + "description": "Überwiegend Großbuchstaben." + }, + "INVITE_LINK": { + "title": "Invite-Links", + "description": "Discord-Invite-Links blockieren." + }, + "EXTERNAL_LINK": { + "title": "Externe Links", + "description": "Links gegen Whitelist/Blacklist prüfen." + }, + "WORD_FILTER": { + "title": "Wortfilter", + "description": "Wörter und Regex-Muster." + }, + "DUPLICATE": { + "title": "Duplikate", + "description": "Wiederholte gleiche Nachrichten." + }, + "EMOJI_SPAM": { + "title": "Emoji-Spam", + "description": "Zu viele Emojis in einer Nachricht." + }, + "ZALGO": { + "title": "Zalgo", + "description": "Zalgo-/Combining-Zeichen blockieren." + }, + "PHISHING": { + "title": "Phishing", + "description": "Bekannte Scam-/Phishing-Domains." + } + }, "antiRaidTitle": "Anti-Raid", - "antiRaidDescription": "Erkennt ungewöhnlich schnelle Join-Wellen und aktiviert automatisch einen Lockdown.", + "antiRaidDescription": "Erkennt ungewöhnlich schnelle Join-Wellen.", "antiRaidEnabled": "Anti-Raid aktiviert", "antiRaidJoinThreshold": "Schwellenwert (Joins)", "antiRaidWindowSeconds": "Zeitfenster (Sekunden)", - "antiRaidActionHint": "Aktuell implementierte Reaktion: Server-Lockdown.", + "antiRaidAction": "Reaktion", + "antiRaidActions": { + "LOCKDOWN": "Lockdown (Kanäle sperren)", + "VERIFY": "Verify (Unverified-Rolle)" + }, + "antiRaidActionHint": "Verify setzt die Unverified-Rolle aus dem Verifizierungsmodul und gilt für weitere Joins, bis der Lockdown-Status zurückgesetzt wird.", "antiNukeTitle": "Anti-Nuke", "antiNukeDescription": "Schützt vor Massen-Bans oder Massen-Kanal-Löschungen durch kompromittierte Accounts.", "antiNukeEnabled": "Anti-Nuke aktiviert", "antiNukeBanThreshold": "Schwellenwert (Bans)", "antiNukeChannelThreshold": "Schwellenwert (Kanal-Löschungen)", "antiNukeWindowSeconds": "Zeitfenster (Sekunden)", - "lockdownActive": "Lockdown aktiv", - "lockdownActiveHint": "Wird automatisch bei einem erkannten Raid gesetzt. Manuell zurücksetzen, um den Lockdown zu beenden." + "lockdownActive": "Raid-Reaktion aktiv", + "lockdownActiveHint": "Bei Lockdown: Kanäle entsperren beim Ausschalten. Bei Verify: nur Status zurücksetzen." }, "logging": { "title": "Logging", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index 714f449..c1c8c67 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -347,34 +347,121 @@ "reason": "Reason", "reasonPlaceholder": "Optional default reason", "casesTitle": "Cases", - "casesDescription": "List of the most recent moderation cases (read-only).", + "casesDescription": "View moderation cases, edit the reason, or soft-delete.", "searchPlaceholder": "Search by user/moderator ID or reason", "actionPlaceholder": "Filter by action, e.g. BAN", "casesEmpty": "No cases found.", "target": "Target", "moderator": "Moderator", "createdAt": "Created at", - "noReason": "No reason provided" + "noReason": "No reason provided", + "editReason": "Edit reason", + "saveReason": "Save", + "deleteCase": "Delete case", + "confirmDeleteCase": "Really delete this case?", + "warningsTitle": "Warnings", + "warningsDescription": "Recent warnings. Remove deletes the warning entry.", + "warningsEmpty": "No warnings found.", + "warningsSearchUser": "Filter by user ID", + "removeWarning": "Remove warning", + "confirmRemoveWarning": "Really remove this warning?" }, "automod": { "title": "Auto-Moderation", - "description": "Base settings plus anti-raid and anti-nuke protection.", + "description": "Filter rules, anti-raid and anti-nuke for this server.", "enabledLabel": "Auto-moderation enabled", - "enabledHint": "Enables all auto-moderation rules (spam, word filter, links, …) on this server.", + "enabledHint": "Master switch: only enabled rules below apply when this is on.", + "rulesTitle": "Filter rules", + "rulesDescription": "Per rule: action, thresholds, word/link lists and exceptions.", + "ruleAction": "Action", + "timeoutMinutes": "Timeout (minutes)", + "actions": { + "DELETE": "Delete message", + "WARN": "Warn", + "TIMEOUT": "Timeout", + "KICK": "Kick", + "BAN": "Ban" + }, + "thresholds": { + "maxMessages": "Max messages", + "windowSeconds": "Time window (seconds)", + "maxMentions": "Max mentions", + "capsPercent": "Caps ratio (%)", + "minLength": "Min message length", + "maxEmojis": "Max emojis", + "duplicateCount": "Duplicate count" + }, + "linkWhitelist": "Link whitelist (hosts)", + "linkBlacklist": "Link blacklist (hosts)", + "addHost": "Add host", + "wordList": "Word list", + "regexList": "Regex patterns", + "wordPlaceholder": "banned word", + "addWord": "Add word", + "addRegex": "Add regex", + "exceptionRoles": "Exception roles", + "exceptionChannels": "Exception channels", + "rules": { + "SPAM": { + "title": "Spam", + "description": "Too many messages in a short time." + }, + "MASS_MENTION": { + "title": "Mass mentions", + "description": "Too many @-mentions in one message." + }, + "CAPS": { + "title": "Caps", + "description": "Mostly uppercase text." + }, + "INVITE_LINK": { + "title": "Invite links", + "description": "Block Discord invite links." + }, + "EXTERNAL_LINK": { + "title": "External links", + "description": "Check links against whitelist/blacklist." + }, + "WORD_FILTER": { + "title": "Word filter", + "description": "Words and regex patterns." + }, + "DUPLICATE": { + "title": "Duplicates", + "description": "Repeated identical messages." + }, + "EMOJI_SPAM": { + "title": "Emoji spam", + "description": "Too many emojis in one message." + }, + "ZALGO": { + "title": "Zalgo", + "description": "Block zalgo/combining characters." + }, + "PHISHING": { + "title": "Phishing", + "description": "Known scam/phishing domains." + } + }, "antiRaidTitle": "Anti-Raid", - "antiRaidDescription": "Detects unusually fast join waves and automatically activates a lockdown.", + "antiRaidDescription": "Detects unusually fast join waves.", "antiRaidEnabled": "Anti-raid enabled", "antiRaidJoinThreshold": "Join threshold", "antiRaidWindowSeconds": "Time window (seconds)", - "antiRaidActionHint": "Currently implemented response: server lockdown.", + "antiRaidAction": "Response", + "antiRaidActions": { + "LOCKDOWN": "Lockdown (lock channels)", + "VERIFY": "Verify (unverified role)" + }, + "antiRaidActionHint": "Verify applies the unverified role from the Verification module and continues for new joins until the raid status is cleared.", "antiNukeTitle": "Anti-Nuke", "antiNukeDescription": "Protects against mass bans or mass channel deletions by compromised accounts.", "antiNukeEnabled": "Anti-nuke enabled", "antiNukeBanThreshold": "Ban threshold", "antiNukeChannelThreshold": "Channel deletion threshold", "antiNukeWindowSeconds": "Time window (seconds)", - "lockdownActive": "Lockdown active", - "lockdownActiveHint": "Automatically set when a raid is detected. Toggle off manually to end the lockdown." + "lockdownActive": "Raid response active", + "lockdownActiveHint": "Lockdown: unlock channels when turned off. Verify: only clears the status flag." }, "logging": { "title": "Logging", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 297779b..5380244 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -258,6 +258,25 @@ - [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle - [ ] Doppeltes Level → Fehlermeldung, speichern blockiert +## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert) + +### Abgeschlossen (Code) + +- Automod: alle 10 Filterregeln im Dashboard (Aktion, Schwellen, Link-WL/BL, Wörter/Regex, Ausnahmen) +- Lockdown an/aus enqueued `activateLockdown` / `deactivateLockdown` an den Bot +- Anti-Raid-Aktion `VERIFY` (Unverified-Rolle) zusätzlich zu `LOCKDOWN` +- Moderation: Warn-Tabelle + Case edit/delete im Dashboard +- `/lock` `/unlock` Option `server` für guild-weiten Lock +- Shared: `AutomodRuleDashboardSchema`, `CaseUpdateDashboardSchema`, `AntiRaidAction` inkl. VERIFY + +### Manuell testen + +- [ ] Automod: WORD_FILTER Wörter setzen, EXTERNAL_LINK Whitelist, Regel speichern → Filter greift +- [ ] Lockdown-Toggle aus → Kanäle wieder beschreibbar +- [ ] Anti-Raid VERIFY: Unverified-Rolle in Verification konfiguriert, Join-Welle → Rolle +- [ ] Cases: Grund editieren / löschen; Warnings entfernen +- [ ] `/lock server:true` und `/unlock server:true` + ## Nächster geplanter Schritt - Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe). diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index cfa30fb..ae71498 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -140,12 +140,20 @@ export const commandLocales = { en: '0-21600' }, 'moderation.lock.description': { - de: 'Aktuellen Textkanal sperren', - en: 'Lock current text channel' + de: 'Kanal oder gesamten Server sperren', + en: 'Lock channel or entire server' + }, + 'moderation.lock.options.server': { + de: 'Gesamten Server sperren (alle Textkanäle)', + en: 'Lock the entire server (all text channels)' }, 'moderation.unlock.description': { - de: 'Aktuellen Textkanal entsperren', - en: 'Unlock current text channel' + de: 'Kanal oder gesamten Server entsperren', + en: 'Unlock channel or entire server' + }, + 'moderation.unlock.options.server': { + de: 'Gesamten Server entsperren', + en: 'Unlock the entire server' }, 'moderation.nick.description': { de: 'Nicknames verwalten', diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 890a32a..cdc0f7d 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -6,7 +6,12 @@ import { import { embedHasContent, LeaveMessageTypeSchema, - WelcomeEmbedSchema + WelcomeEmbedSchema, + AutoModRuleTypeSchema, + AutoModActionSchema, + AutoModExceptionsSchema, + AutoModThresholdSchema, + AutoModWordListSchema } from './phase2.js'; import { SelfRoleBehaviorSchema, @@ -111,7 +116,18 @@ export type WarningListQuery = z.infer; // AutoMod // --------------------------------------------------------------------------- -export const AntiRaidActionSchema = z.enum(['LOCKDOWN']); +export const AntiRaidActionSchema = z.enum(['LOCKDOWN', 'VERIFY']); + +export const AutomodRuleDashboardSchema = z.object({ + ruleType: AutoModRuleTypeSchema, + enabled: z.boolean(), + action: AutoModActionSchema, + durationMs: z.number().int().positive().nullable().optional(), + threshold: AutoModThresholdSchema.default({}), + exceptions: AutoModExceptionsSchema.default({ roleIds: [], channelIds: [] }), + wordList: AutoModWordListSchema.default({ words: [], regexPatterns: [] }) +}); +export type AutomodRuleDashboard = z.infer; export const AutomodConfigDashboardSchema = z.object({ enabled: z.boolean(), @@ -123,13 +139,30 @@ export const AutomodConfigDashboardSchema = z.object({ antiNukeBanThreshold: z.number().int().positive(), antiNukeChannelThreshold: z.number().int().positive(), antiNukeWindowSeconds: z.number().int().positive(), - lockdownActive: z.boolean() + lockdownActive: z.boolean(), + rules: z.array(AutomodRuleDashboardSchema) }); export type AutomodConfigDashboard = z.infer; export const AutomodConfigDashboardPatchSchema = nonEmptyPatch(AutomodConfigDashboardSchema); export type AutomodConfigDashboardPatch = z.infer; +export const CaseUpdateDashboardSchema = z.object({ + reason: z.string().trim().min(1).max(1000) +}); +export type CaseUpdateDashboard = z.infer; + +/** Returns true when every regex pattern compiles. */ +export function validateAutomodRegexPatterns(patterns: string[]): string | null { + for (const pattern of patterns) { + try { + RegExp(pattern, 'iu'); + } catch { + return pattern; + } + } + return null; +} // --------------------------------------------------------------------------- // Logging // ---------------------------------------------------------------------------