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:
@@ -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 {
|
||||
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');
|
||||
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: 'An error occurred.', ephemeral: true });
|
||||
await interaction.followUp({ content: message, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content: 'An error occurred.', ephemeral: true });
|
||||
await interaction.reply({ content: message, ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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: []
|
||||
});
|
||||
}
|
||||
@@ -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 promptDestructiveConfirmation(interaction, locale, {
|
||||
action: 'ban',
|
||||
payload: {
|
||||
userId: user.id,
|
||||
reason,
|
||||
duration
|
||||
}
|
||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 promptDestructiveConfirmation(interaction, locale, {
|
||||
action: 'purge',
|
||||
payload: {
|
||||
channelId: interaction.channel.id,
|
||||
amount,
|
||||
targetUserId: targetUser?.id ?? null,
|
||||
botsOnly,
|
||||
linksOnly,
|
||||
attachmentsOnly,
|
||||
regex: regexInput
|
||||
}
|
||||
await createCase(context, {
|
||||
guildId: interaction.guildId!,
|
||||
targetUserId: interaction.user.id,
|
||||
moderatorId: interaction.user.id,
|
||||
action: 'PURGE',
|
||||
reason: `Deleted ${deletedCount} messages`
|
||||
});
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
166
apps/bot/src/modules/moderation/confirmations.ts
Normal file
166
apps/bot/src/modules/moderation/confirmations.ts
Normal 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);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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<Locale, Dictionary> = { de, en };
|
||||
|
||||
Reference in New Issue
Block a user