From 878478e4fc442e7bb741aa5e1716162919c33ac8 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 11:26:49 +0200 Subject: [PATCH] Implement confirmation flow for destructive moderation actions - Added confirmation prompts for destructive commands such as `/ban`, `/purge`, and `/case delete` to enhance user safety. - Integrated locale support for confirmation messages, ensuring clarity in both German and English. - Refactored command logic to utilize the new confirmation system, improving user experience and reducing accidental actions. --- apps/bot/src/index.ts | 31 +++- apps/bot/src/modules/moderation/actions.ts | 157 +++++++++++++++++ apps/bot/src/modules/moderation/commands.ts | 69 +++----- .../src/modules/moderation/confirmations.ts | 166 ++++++++++++++++++ docs/PHASE-TRACKING.md | 3 +- packages/shared/src/index.ts | 27 ++- 6 files changed, 402 insertions(+), 51 deletions(-) create mode 100644 apps/bot/src/modules/moderation/actions.ts create mode 100644 apps/bot/src/modules/moderation/confirmations.ts diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index d7fcc64..2d4ea07 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -14,6 +14,12 @@ import { registerCommands, routeCommand } from './commands.js'; import { ensureRecurringJobs, startWorkers } from './jobs.js'; import type { BotContext } from './types.js'; import { startHealthServer } from './health.js'; +import { + handleModerationConfirmation, + isModerationConfirmation +} from './modules/moderation/confirmations.js'; +import { getGuildLocale } from './i18n.js'; +import { t } from '@nexumi/shared'; const isShard = process.argv.includes('--shard'); @@ -47,15 +53,26 @@ if (!isShard) { }); client.on(Events.InteractionCreate, async (interaction: Interaction) => { - if (!interaction.isChatInputCommand()) return; try { - await routeCommand(interaction, context); + if (interaction.isChatInputCommand()) { + await routeCommand(interaction, context); + return; + } + + if (interaction.isButton() && isModerationConfirmation(interaction.customId)) { + await handleModerationConfirmation(interaction, context); + return; + } } catch (error) { - logger.error({ error }, 'Command execution failed'); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content: 'An error occurred.', ephemeral: true }); - } else { - await interaction.reply({ content: 'An error occurred.', ephemeral: true }); + logger.error({ error }, 'Interaction handling failed'); + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const message = t(locale, 'generic.error'); + if (interaction.isRepliable()) { + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: message, ephemeral: true }); + } else { + await interaction.reply({ content: message, ephemeral: true }); + } } } }); diff --git a/apps/bot/src/modules/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts new file mode 100644 index 0000000..87880b0 --- /dev/null +++ b/apps/bot/src/modules/moderation/actions.ts @@ -0,0 +1,157 @@ +import { TextChannel, type ButtonInteraction } from 'discord.js'; +import { type Locale, t } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { createCase, scheduleTempBanExpire } from './service.js'; +import { parseDuration } from './duration.js'; + +type BanPayload = { + userId: string; + reason: string; + duration: string | null; +}; + +type PurgePayload = { + channelId: string; + amount: number; + targetUserId: string | null; + botsOnly: boolean; + linksOnly: boolean; + attachmentsOnly: boolean; + regex: string | null; +}; + +type CaseDeletePayload = { + caseNumber: number; +}; + +export async function executeBan( + interaction: ButtonInteraction, + context: BotContext, + locale: Locale, + payload: BanPayload +): Promise { + const guild = interaction.guild; + if (!guild) { + await interaction.update({ content: t(locale, 'generic.guildOnly'), components: [] }); + return; + } + + await guild.members.ban(payload.userId, { reason: payload.reason }); + await createCase(context, { + guildId: guild.id, + targetUserId: payload.userId, + moderatorId: interaction.user.id, + action: 'BAN', + reason: payload.reason, + metadata: { confirmed: true } + }); + + if (payload.duration) { + await scheduleTempBanExpire( + guild.id, + payload.userId, + parseDuration(payload.duration), + payload.reason + ); + } + + await interaction.update({ + content: t(locale, 'moderation.success'), + components: [] + }); +} + +export async function executePurge( + interaction: ButtonInteraction, + context: BotContext, + locale: Locale, + payload: PurgePayload +): Promise { + const guild = interaction.guild; + if (!guild) { + await interaction.update({ content: t(locale, 'generic.guildOnly'), components: [] }); + return; + } + + const channel = await guild.channels.fetch(payload.channelId); + let deletedCount = 0; + const regex = payload.regex ? new RegExp(payload.regex, 'i') : null; + + if (channel instanceof TextChannel) { + const fetched = await channel.messages.fetch({ limit: 100 }); + const filtered = fetched.filter((message) => { + if (payload.targetUserId && message.author.id !== payload.targetUserId) return false; + if (payload.botsOnly && !message.author.bot) return false; + if (payload.linksOnly && !/(https?:\/\/|discord\.gg\/)/i.test(message.content)) return false; + if (payload.attachmentsOnly && message.attachments.size === 0) return false; + if (regex && !regex.test(message.content)) return false; + return true; + }); + const toDelete = filtered.first(payload.amount); + const deleted = await channel.bulkDelete(toDelete, true); + deletedCount = deleted.size; + } + + await createCase(context, { + guildId: guild.id, + targetUserId: interaction.user.id, + moderatorId: interaction.user.id, + action: 'PURGE', + reason: `Deleted ${deletedCount} messages`, + metadata: { + confirmed: true, + channelId: payload.channelId, + requestedAmount: payload.amount, + deletedCount + } + }); + + await interaction.update({ + content: t(locale, 'moderation.success'), + components: [] + }); +} + +export async function executeCaseDelete( + interaction: ButtonInteraction, + context: BotContext, + locale: Locale, + payload: CaseDeletePayload +): Promise { + const guildId = interaction.guildId; + if (!guildId) { + await interaction.update({ content: t(locale, 'generic.guildOnly'), components: [] }); + return; + } + + const existing = await context.prisma.case.findUnique({ + where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } } + }); + + if (!existing) { + await interaction.update({ + content: t(locale, 'moderation.case.notFound'), + components: [] + }); + return; + } + + await context.prisma.case.update({ + where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }, + data: { deletedAt: new Date() } + }); + + await createCase(context, { + guildId, + targetUserId: existing.targetUserId, + moderatorId: interaction.user.id, + action: 'CASE_DELETE', + reason: `Soft-deleted case #${payload.caseNumber}`, + metadata: { confirmed: true, deletedCaseId: existing.id } + }); + + await interaction.update({ + content: t(locale, 'moderation.success'), + components: [] + }); +} diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 29db9c8..a49bb9d 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -9,9 +9,10 @@ import { import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { requirePermission } from '../../permissions.js'; -import { applyWarnEscalation, createCase, scheduleTempBanExpire } from './service.js'; +import { applyWarnEscalation, createCase } from './service.js'; import { parseDuration } from './duration.js'; import { getGuildLocale } from '../../i18n.js'; +import { promptDestructiveConfirmation } from './confirmations.js'; function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string { return i.options.getString('reason') ?? t(locale, 'moderation.reason.none'); @@ -41,19 +42,15 @@ const banCommand: SlashCommand = { if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); - await interaction.guild!.members.ban(user.id, { reason }); - await createCase(context, { - guildId: interaction.guildId!, - targetUserId: user.id, - moderatorId: interaction.user.id, - action: 'BAN', - reason - }); const duration = interaction.options.getString('duration'); - if (duration) { - await scheduleTempBanExpire(interaction.guildId!, user.id, parseDuration(duration), reason); - } - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await promptDestructiveConfirmation(interaction, locale, { + action: 'ban', + payload: { + userId: user.id, + reason, + duration + } + }); } }; @@ -404,39 +401,32 @@ const purgeCommand: SlashCommand = { const linksOnly = interaction.options.getBoolean('links') ?? false; const attachmentsOnly = interaction.options.getBoolean('attachments') ?? false; const regexInput = interaction.options.getString('regex'); - let regex: RegExp | null = null; if (regexInput) { try { - regex = new RegExp(regexInput, 'i'); + new RegExp(regexInput, 'i'); } catch { await interaction.reply({ content: t(locale, 'generic.invalidRegex'), ephemeral: true }); return; } } - let deletedCount = 0; - if (interaction.channel instanceof TextChannel) { - const fetched = await interaction.channel.messages.fetch({ limit: 100 }); - const filtered = fetched.filter((message) => { - if (targetUser && message.author.id !== targetUser.id) return false; - if (botsOnly && !message.author.bot) return false; - if (linksOnly && !/(https?:\/\/|discord\.gg\/)/i.test(message.content)) return false; - if (attachmentsOnly && message.attachments.size === 0) return false; - if (regex && !regex.test(message.content)) return false; - return true; - }); - const toDelete = filtered.first(amount); - const deleted = await interaction.channel.bulkDelete(toDelete, true); - deletedCount = deleted.size; + if (!(interaction.channel instanceof TextChannel)) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return; } - await createCase(context, { - guildId: interaction.guildId!, - targetUserId: interaction.user.id, - moderatorId: interaction.user.id, - action: 'PURGE', - reason: `Deleted ${deletedCount} messages` + + await promptDestructiveConfirmation(interaction, locale, { + action: 'purge', + payload: { + channelId: interaction.channel.id, + amount, + targetUserId: targetUser?.id ?? null, + botsOnly, + linksOnly, + attachmentsOnly, + regex: regexInput + } }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); } }; @@ -593,11 +583,10 @@ const caseCommand: SlashCommand = { await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); return; } - await context.prisma.case.update({ - where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, - data: { deletedAt: new Date() } + await promptDestructiveConfirmation(interaction, locale, { + action: 'case_delete', + payload: { caseNumber } }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); } }; diff --git a/apps/bot/src/modules/moderation/confirmations.ts b/apps/bot/src/modules/moderation/confirmations.ts new file mode 100644 index 0000000..eab1f0f --- /dev/null +++ b/apps/bot/src/modules/moderation/confirmations.ts @@ -0,0 +1,166 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type ButtonInteraction, + type ChatInputCommandInteraction +} from 'discord.js'; +import { randomUUID } from 'node:crypto'; +import { type Locale, t, tf } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { executeBan, executeCaseDelete, executePurge } from './actions.js'; + +const CONFIRM_PREFIX = 'mod:confirm:'; +const CANCEL_PREFIX = 'mod:cancel:'; +const TTL_MS = 60_000; + +type BanPayload = { + userId: string; + reason: string; + duration: string | null; +}; + +type PurgePayload = { + channelId: string; + amount: number; + targetUserId: string | null; + botsOnly: boolean; + linksOnly: boolean; + attachmentsOnly: boolean; + regex: string | null; +}; + +type CaseDeletePayload = { + caseNumber: number; +}; + +type PendingConfirmation = + | { action: 'ban'; payload: BanPayload } + | { action: 'purge'; payload: PurgePayload } + | { action: 'case_delete'; payload: CaseDeletePayload }; + +type PendingEntry = PendingConfirmation & { + moderatorId: string; + guildId: string; + locale: Locale; + expiresAt: number; +}; + +const pending = new Map(); + +function pruneExpired(): void { + const now = Date.now(); + for (const [token, entry] of pending) { + if (entry.expiresAt <= now) { + pending.delete(token); + } + } +} + +function confirmationMessage(locale: Locale, entry: PendingConfirmation): string { + if (entry.action === 'ban') { + return tf(locale, 'moderation.confirm.ban', { + userId: entry.payload.userId, + reason: entry.payload.reason, + duration: entry.payload.duration ?? t(locale, 'moderation.confirm.permanent') + }); + } + if (entry.action === 'purge') { + return tf(locale, 'moderation.confirm.purge', { amount: entry.payload.amount }); + } + return tf(locale, 'moderation.confirm.caseDelete', { caseNumber: entry.payload.caseNumber }); +} + +function buildRows(token: string, locale: Locale): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${CONFIRM_PREFIX}${token}`) + .setLabel(t(locale, 'moderation.confirm.button.confirm')) + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`${CANCEL_PREFIX}${token}`) + .setLabel(t(locale, 'moderation.confirm.button.cancel')) + .setStyle(ButtonStyle.Secondary) + ) + ]; +} + +export async function promptDestructiveConfirmation( + interaction: ChatInputCommandInteraction, + locale: Locale, + entry: PendingConfirmation +): Promise { + pruneExpired(); + const token = randomUUID(); + pending.set(token, { + ...entry, + moderatorId: interaction.user.id, + guildId: interaction.guildId!, + locale, + expiresAt: Date.now() + TTL_MS + }); + + await interaction.reply({ + content: confirmationMessage(locale, entry), + components: buildRows(token, locale), + ephemeral: true + }); +} + +export async function handleModerationConfirmation( + interaction: ButtonInteraction, + context: BotContext +): Promise { + pruneExpired(); + + const customId = interaction.customId; + const isConfirm = customId.startsWith(CONFIRM_PREFIX); + const isCancel = customId.startsWith(CANCEL_PREFIX); + if (!isConfirm && !isCancel) { + return; + } + + const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length); + const entry = pending.get(token); + if (!entry) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true }); + return; + } + + if (interaction.user.id !== entry.moderatorId) { + await interaction.reply({ + content: t(entry.locale, 'moderation.confirm.unauthorized'), + ephemeral: true + }); + return; + } + + pending.delete(token); + + if (isCancel) { + await interaction.update({ + content: t(entry.locale, 'moderation.confirm.cancelled'), + components: [] + }); + return; + } + + if (entry.action === 'ban') { + await executeBan(interaction, context, entry.locale, entry.payload); + return; + } + + if (entry.action === 'purge') { + await executePurge(interaction, context, entry.locale, entry.payload); + return; + } + + await executeCaseDelete(interaction, context, entry.locale, entry.payload); +} + +export function isModerationConfirmation(customId: string): boolean { + return customId.startsWith(CONFIRM_PREFIX) || customId.startsWith(CANCEL_PREFIX); +} diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index d16925d..0daa0d8 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -56,6 +56,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - `/case view|edit|delete` - `/modnote add|list` - user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`) + - Bestätigungsflow für destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons - Tests (aktuell vorhanden): - `packages/shared/src/i18n.test.ts` - `apps/bot/src/modules/moderation/duration.test.ts` @@ -64,8 +65,6 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - Vollständige i18n-Auslagerung: - Restarbeiten: Command-Beschreibungen/-Optionstexte ebenfalls in zentralen Locale-Strukturen führen. -- Slash-Command-Bestätigungen für destruktive Aktionen gemäß SPEC-Regel: - - Ban, Purge und weitere destruktive Flows sollen explizite Bestätigungsschritte erhalten. - Audit-/Case-System weiter härten: - Einheitliche, nachvollziehbare Audit-Metadaten pro Moderationsaktion. - Migrations-Workflow finalisieren: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b380026..efe2cce 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -37,6 +37,7 @@ const de: Dictionary = { 'generic.guildOnly': 'Dieser Command kann nur auf einem Server verwendet werden.', 'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).', 'generic.invalidRegex': 'Ungültiges Regex-Muster.', + 'generic.error': 'Es ist ein Fehler aufgetreten.', 'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.reason.none': 'Kein Grund angegeben', 'moderation.warning.created': 'Verwarnung erstellt: {warningId}', @@ -50,13 +51,24 @@ const de: Dictionary = { 'moderation.escalation.saved': 'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.', 'moderation.escalation.removed': - 'Eskalationsregel für {warnCount} Verwarnungen entfernt.' + 'Eskalationsregel für {warnCount} Verwarnungen entfernt.', + 'moderation.confirm.button.confirm': 'Bestätigen', + 'moderation.confirm.button.cancel': 'Abbrechen', + 'moderation.confirm.permanent': 'dauerhaft', + 'moderation.confirm.ban': + 'Ban bestätigen für <@{userId}>?\nGrund: {reason}\nDauer: {duration}', + 'moderation.confirm.purge': '{amount} Nachrichten löschen? Diese Aktion kann nicht rückgängig gemacht werden.', + 'moderation.confirm.caseDelete': 'Fall #{caseNumber} wirklich löschen?', + 'moderation.confirm.expired': 'Bestätigung abgelaufen. Bitte den Befehl erneut ausführen.', + 'moderation.confirm.cancelled': 'Aktion abgebrochen.', + 'moderation.confirm.unauthorized': 'Nur der ausführende Moderator kann diese Bestätigung nutzen.' }; const en: Dictionary = { 'generic.noPermission': 'You do not have permission for this action.', 'generic.guildOnly': 'This command can only be used in a server.', 'generic.botMissingPermission': 'Bot lacks required permission ({permission}).', 'generic.invalidRegex': 'Invalid regex pattern.', + 'generic.error': 'An error occurred.', 'moderation.success': 'Action executed successfully.', 'moderation.reason.none': 'No reason provided', 'moderation.warning.created': 'Warning created: {warningId}', @@ -68,7 +80,18 @@ const en: Dictionary = { 'moderation.escalation.noneConfigured': 'No escalation rules configured.', 'moderation.escalation.saved': 'Escalation rule saved: {warnCount} warnings => {action}{duration}.', - 'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.' + 'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.', + 'moderation.confirm.button.confirm': 'Confirm', + 'moderation.confirm.button.cancel': 'Cancel', + 'moderation.confirm.permanent': 'permanent', + 'moderation.confirm.ban': + 'Confirm ban for <@{userId}>?\nReason: {reason}\nDuration: {duration}', + 'moderation.confirm.purge': + 'Delete {amount} messages? This action cannot be undone.', + 'moderation.confirm.caseDelete': 'Really delete case #{caseNumber}?', + 'moderation.confirm.expired': 'Confirmation expired. Please run the command again.', + 'moderation.confirm.cancelled': 'Action cancelled.', + 'moderation.confirm.unauthorized': 'Only the moderator who started this action can confirm it.' }; const locales: Record = { de, en };