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.
This commit is contained in:
smueller
2026-07-22 11:26:49 +02:00
parent a17d161c06
commit 878478e4fc
6 changed files with 402 additions and 51 deletions

View File

@@ -14,6 +14,12 @@ import { registerCommands, routeCommand } from './commands.js';
import { ensureRecurringJobs, startWorkers } from './jobs.js'; import { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js'; import type { BotContext } from './types.js';
import { startHealthServer } from './health.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'); const isShard = process.argv.includes('--shard');
@@ -47,15 +53,26 @@ if (!isShard) {
}); });
client.on(Events.InteractionCreate, async (interaction: Interaction) => { client.on(Events.InteractionCreate, async (interaction: Interaction) => {
if (!interaction.isChatInputCommand()) return;
try { 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) { } catch (error) {
logger.error({ error }, 'Command execution failed'); logger.error({ error }, 'Interaction handling failed');
if (interaction.replied || interaction.deferred) { const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.followUp({ content: 'An error occurred.', ephemeral: true }); const message = t(locale, 'generic.error');
} else { if (interaction.isRepliable()) {
await interaction.reply({ content: 'An error occurred.', ephemeral: true }); if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: message, ephemeral: true });
} else {
await interaction.reply({ content: message, ephemeral: true });
}
} }
} }
}); });

View File

@@ -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<void> {
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<void> {
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<void> {
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: []
});
}

View File

@@ -9,9 +9,10 @@ import {
import { t, tf } from '@nexumi/shared'; import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.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 { parseDuration } from './duration.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import { promptDestructiveConfirmation } from './confirmations.js';
function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string { function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string {
return i.options.getString('reason') ?? t(locale, 'moderation.reason.none'); 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; if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers, locale))) return;
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale); 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'); const duration = interaction.options.getString('duration');
if (duration) { await promptDestructiveConfirmation(interaction, locale, {
await scheduleTempBanExpire(interaction.guildId!, user.id, parseDuration(duration), reason); action: 'ban',
} payload: {
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); userId: user.id,
reason,
duration
}
});
} }
}; };
@@ -404,39 +401,32 @@ const purgeCommand: SlashCommand = {
const linksOnly = interaction.options.getBoolean('links') ?? false; const linksOnly = interaction.options.getBoolean('links') ?? false;
const attachmentsOnly = interaction.options.getBoolean('attachments') ?? false; const attachmentsOnly = interaction.options.getBoolean('attachments') ?? false;
const regexInput = interaction.options.getString('regex'); const regexInput = interaction.options.getString('regex');
let regex: RegExp | null = null;
if (regexInput) { if (regexInput) {
try { try {
regex = new RegExp(regexInput, 'i'); new RegExp(regexInput, 'i');
} catch { } catch {
await interaction.reply({ content: t(locale, 'generic.invalidRegex'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.invalidRegex'), ephemeral: true });
return; return;
} }
} }
let deletedCount = 0; if (!(interaction.channel instanceof TextChannel)) {
if (interaction.channel instanceof TextChannel) { await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
const fetched = await interaction.channel.messages.fetch({ limit: 100 }); return;
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;
} }
await createCase(context, {
guildId: interaction.guildId!, await promptDestructiveConfirmation(interaction, locale, {
targetUserId: interaction.user.id, action: 'purge',
moderatorId: interaction.user.id, payload: {
action: 'PURGE', channelId: interaction.channel.id,
reason: `Deleted ${deletedCount} messages` 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 }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
return; return;
} }
await context.prisma.case.update({ await promptDestructiveConfirmation(interaction, locale, {
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, action: 'case_delete',
data: { deletedAt: new Date() } payload: { caseNumber }
}); });
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };

View File

@@ -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<string, PendingEntry>();
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<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().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<void> {
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<void> {
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);
}

View File

@@ -56,6 +56,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
- `/case view|edit|delete` - `/case view|edit|delete`
- `/modnote add|list` - `/modnote add|list`
- user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`) - 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): - Tests (aktuell vorhanden):
- `packages/shared/src/i18n.test.ts` - `packages/shared/src/i18n.test.ts`
- `apps/bot/src/modules/moderation/duration.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: - Vollständige i18n-Auslagerung:
- Restarbeiten: Command-Beschreibungen/-Optionstexte ebenfalls in zentralen Locale-Strukturen führen. - 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: - Audit-/Case-System weiter härten:
- Einheitliche, nachvollziehbare Audit-Metadaten pro Moderationsaktion. - Einheitliche, nachvollziehbare Audit-Metadaten pro Moderationsaktion.
- Migrations-Workflow finalisieren: - Migrations-Workflow finalisieren:

View File

@@ -37,6 +37,7 @@ const de: Dictionary = {
'generic.guildOnly': 'Dieser Command kann nur auf einem Server verwendet werden.', 'generic.guildOnly': 'Dieser Command kann nur auf einem Server verwendet werden.',
'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).', 'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).',
'generic.invalidRegex': 'Ungültiges Regex-Muster.', 'generic.invalidRegex': 'Ungültiges Regex-Muster.',
'generic.error': 'Es ist ein Fehler aufgetreten.',
'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.success': 'Aktion erfolgreich ausgeführt.',
'moderation.reason.none': 'Kein Grund angegeben', 'moderation.reason.none': 'Kein Grund angegeben',
'moderation.warning.created': 'Verwarnung erstellt: {warningId}', 'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
@@ -50,13 +51,24 @@ const de: Dictionary = {
'moderation.escalation.saved': 'moderation.escalation.saved':
'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.', 'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.',
'moderation.escalation.removed': '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 = { const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.', 'generic.noPermission': 'You do not have permission for this action.',
'generic.guildOnly': 'This command can only be used in a server.', 'generic.guildOnly': 'This command can only be used in a server.',
'generic.botMissingPermission': 'Bot lacks required permission ({permission}).', 'generic.botMissingPermission': 'Bot lacks required permission ({permission}).',
'generic.invalidRegex': 'Invalid regex pattern.', 'generic.invalidRegex': 'Invalid regex pattern.',
'generic.error': 'An error occurred.',
'moderation.success': 'Action executed successfully.', 'moderation.success': 'Action executed successfully.',
'moderation.reason.none': 'No reason provided', 'moderation.reason.none': 'No reason provided',
'moderation.warning.created': 'Warning created: {warningId}', 'moderation.warning.created': 'Warning created: {warningId}',
@@ -68,7 +80,18 @@ const en: Dictionary = {
'moderation.escalation.noneConfigured': 'No escalation rules configured.', 'moderation.escalation.noneConfigured': 'No escalation rules configured.',
'moderation.escalation.saved': 'moderation.escalation.saved':
'Escalation rule saved: {warnCount} warnings => {action}{duration}.', '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<Locale, Dictionary> = { de, en }; const locales: Record<Locale, Dictionary> = { de, en };