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:
157
apps/bot/src/modules/moderation/actions.ts
Normal file
157
apps/bot/src/modules/moderation/actions.ts
Normal 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: []
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user