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 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 });
}
}
}
});

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 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 });
}
};

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);
}