Update README and moderation commands for localization and migration support

- Revised README to include database migration steps and a new section for database management.
- Enhanced moderation command responses to utilize localized messages for unknown commands.
- Refactored moderation commands to use centralized command data definitions, improving maintainability and consistency.
- Added localization support for command descriptions and options in both German and English.
- Versioned Prisma migrations for better tracking of database changes.
This commit is contained in:
smueller
2026-07-22 12:06:16 +02:00
parent 405b70bdfc
commit a44f4d6641
12 changed files with 784 additions and 164 deletions

View File

@@ -1,6 +1,8 @@
import { REST, Routes, type ChatInputCommandInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { moderationCommands } from './modules/moderation/commands.js';
import type { BotContext, SlashCommand } from './types.js';
@@ -17,8 +19,9 @@ export async function registerCommands() {
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const command = map.get(interaction.commandName);
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!command) {
await interaction.reply({ content: 'Unknown command.', ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;
}
await command.execute(interaction, context);

View File

@@ -0,0 +1,213 @@
import {
SlashCommandBuilder,
type SlashCommandStringOption,
type SlashCommandUserOption
} from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice,
type CommandLocaleKey
} from '@nexumi/shared';
function userOpt(
o: SlashCommandUserOption,
key: CommandLocaleKey = 'moderation.common.options.user'
) {
return applyOptionDescription(o.setName('user').setRequired(true), key);
}
function reasonOpt(o: SlashCommandStringOption, required = false) {
const option = o.setName('reason');
if (required) {
option.setRequired(true);
}
return applyOptionDescription(option, 'moderation.common.options.reason');
}
export const banCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('ban'),
'moderation.ban.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o))
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'moderation.ban.options.duration')
);
export const unbanCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unban'),
'moderation.unban.description'
)
.addStringOption((o) =>
applyOptionDescription(o.setName('user_id').setRequired(true), 'moderation.unban.options.user_id')
)
.addStringOption((o) => reasonOpt(o));
export const kickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('kick'),
'moderation.kick.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const timeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('timeout'),
'moderation.timeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) =>
applyOptionDescription(o.setName('duration').setRequired(true), 'moderation.timeout.options.duration')
)
.addStringOption((o) => reasonOpt(o));
export const untimeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('untimeout'),
'moderation.untimeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const warnCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('warn'),
'moderation.warn.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('add'), 'moderation.warn.add.description')
.addUserOption((o) => userOpt(o, 'moderation.common.options.target'))
.addStringOption((o) => reasonOpt(o, true))
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('list'), 'moderation.warn.list.description').addUserOption((o) =>
userOpt(o, 'moderation.common.options.target')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('warning_id').setRequired(true), 'moderation.warn.options.warning_id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('clear'), 'moderation.warn.clear.description').addUserOption((o) =>
userOpt(o, 'moderation.common.options.target')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('escalation-set'), 'moderation.warn.escalation_set.description')
.addIntegerOption((o) =>
applyOptionDescription(o.setName('count').setRequired(true).setMinValue(1).setMaxValue(50), 'moderation.warn.options.count')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('action').setRequired(true), 'moderation.warn.options.action').addChoices(
localizedChoice('moderation.choice.timeout', 'TIMEOUT'),
localizedChoice('moderation.choice.kick', 'KICK'),
localizedChoice('moderation.choice.ban', 'BAN')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'moderation.warn.options.escalation_duration')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason'), 'moderation.warn.options.escalation_reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('escalation-list'), 'moderation.warn.escalation_list.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('escalation-remove'), 'moderation.warn.escalation_remove.description').addIntegerOption(
(o) =>
applyOptionDescription(o.setName('count').setRequired(true).setMinValue(1).setMaxValue(50), 'moderation.warn.options.count')
)
);
export const purgeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('purge'),
'moderation.purge.description'
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('amount').setRequired(true).setMinValue(2).setMaxValue(100), 'moderation.purge.options.amount')
)
.addUserOption((o) => applyOptionDescription(o.setName('user'), 'moderation.purge.options.user'))
.addBooleanOption((o) => applyOptionDescription(o.setName('bots'), 'moderation.purge.options.bots'))
.addBooleanOption((o) => applyOptionDescription(o.setName('links'), 'moderation.purge.options.links'))
.addBooleanOption((o) =>
applyOptionDescription(o.setName('attachments'), 'moderation.purge.options.attachments')
)
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
export const slowmodeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('slowmode'),
'moderation.slowmode.description'
).addIntegerOption((o) =>
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
);
export const lockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('lock'),
'moderation.lock.description'
);
export const unlockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unlock'),
'moderation.unlock.description'
);
export const nickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('nick'),
'moderation.nick.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('set'), 'moderation.nick.set.description')
.addUserOption((o) => userOpt(o, 'moderation.common.options.target'))
.addStringOption((o) =>
applyOptionDescription(o.setName('nickname').setRequired(true), 'moderation.nick.options.nickname')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('reset'), 'moderation.nick.reset.description').addUserOption((o) =>
userOpt(o, 'moderation.common.options.target')
)
);
export const caseCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('case'),
'moderation.case.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('view'), 'moderation.case.view.description').addIntegerOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'moderation.case.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('edit'), 'moderation.case.edit.description')
.addIntegerOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'moderation.case.options.id')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'moderation.case.options.reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('delete'), 'moderation.case.delete.description').addIntegerOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'moderation.case.options.id')
)
);
export const modNoteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('modnote'),
'moderation.modnote.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('add'), 'moderation.modnote.add.description')
.addUserOption((o) => userOpt(o, 'moderation.common.options.target'))
.addStringOption((o) =>
applyOptionDescription(o.setName('note').setRequired(true), 'moderation.modnote.options.note')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('list'), 'moderation.modnote.list.description').addUserOption((o) =>
userOpt(o, 'moderation.common.options.target')
)
);

View File

@@ -1,7 +1,6 @@
import {
ChannelType,
PermissionFlagsBits,
SlashCommandBuilder,
TextChannel,
type ChatInputCommandInteraction,
type GuildMember
@@ -13,6 +12,21 @@ import { applyWarnEscalation, createCase } from './service.js';
import { parseDuration } from './duration.js';
import { getGuildLocale } from '../../i18n.js';
import { promptDestructiveConfirmation } from './confirmations.js';
import {
banCommandData,
caseCommandData,
kickCommandData,
lockCommandData,
modNoteCommandData,
nickCommandData,
purgeCommandData,
slowmodeCommandData,
timeoutCommandData,
unbanCommandData,
unlockCommandData,
untimeoutCommandData,
warnCommandData
} from './command-definitions.js';
function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string {
return i.options.getString('reason') ?? t(locale, 'moderation.reason.none');
@@ -38,12 +52,7 @@ async function ensureMod(
}
const banCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('ban')
.setDescription('Ban a user')
.addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Reason'))
.addStringOption((opt) => opt.setName('duration').setDescription('Optional temp-ban duration (e.g. 7d)')),
data: banCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers, locale))) return;
@@ -62,11 +71,7 @@ const banCommand: SlashCommand = {
};
const unbanCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('unban')
.setDescription('Unban a user')
.addStringOption((opt) => opt.setName('user_id').setDescription('Discord user id').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
data: unbanCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers, locale))) return;
@@ -86,11 +91,7 @@ const unbanCommand: SlashCommand = {
};
const kickCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('kick')
.setDescription('Kick a user')
.addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
data: kickCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
@@ -111,14 +112,7 @@ const kickCommand: SlashCommand = {
};
const timeoutCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('timeout')
.setDescription('Timeout a user')
.addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true))
.addStringOption((opt) =>
opt.setName('duration').setDescription('Duration (e.g. 15m)').setRequired(true)
)
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
data: timeoutCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
@@ -141,11 +135,7 @@ const timeoutCommand: SlashCommand = {
};
const untimeoutCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('untimeout')
.setDescription('Remove timeout from a user')
.addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
data: untimeoutCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
@@ -166,60 +156,7 @@ const untimeoutCommand: SlashCommand = {
};
const warnCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('warn')
.setDescription('Manage user warnings')
.addSubcommand((s) =>
s
.setName('add')
.setDescription('Add warning')
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
.addStringOption((o) => o.setName('reason').setDescription('Reason').setRequired(true))
)
.addSubcommand((s) =>
s
.setName('list')
.setDescription('List warnings')
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
)
.addSubcommand((s) =>
s
.setName('remove')
.setDescription('Remove warning')
.addStringOption((o) => o.setName('warning_id').setDescription('Warning ID').setRequired(true))
)
.addSubcommand((s) =>
s
.setName('clear')
.setDescription('Clear warnings for user')
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
)
.addSubcommand((s) =>
s
.setName('escalation-set')
.setDescription('Create or update escalation rule')
.addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50))
.addStringOption((o) =>
o
.setName('action')
.setDescription('Escalation action')
.setRequired(true)
.addChoices(
{ name: 'timeout', value: 'TIMEOUT' },
{ name: 'kick', value: 'KICK' },
{ name: 'ban', value: 'BAN' }
)
)
.addStringOption((o) => o.setName('duration').setDescription('Timeout duration (required for TIMEOUT)'))
.addStringOption((o) => o.setName('reason').setDescription('Optional escalation reason'))
)
.addSubcommand((s) => s.setName('escalation-list').setDescription('List escalation rules'))
.addSubcommand((s) =>
s
.setName('escalation-remove')
.setDescription('Remove escalation rule')
.addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50))
),
data: warnCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
@@ -308,6 +245,7 @@ const warnCommand: SlashCommand = {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'WARN_ADD',
reason
});
@@ -367,6 +305,7 @@ const warnCommand: SlashCommand = {
guildId: interaction.guildId!,
targetUserId: warning.userId,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed`
});
@@ -389,21 +328,7 @@ const warnCommand: SlashCommand = {
};
const purgeCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('purge')
.setDescription('Bulk delete messages')
.addIntegerOption((o) =>
o.setName('amount').setDescription('2-100').setRequired(true).setMinValue(2).setMaxValue(100)
)
.addUserOption((o) => o.setName('user').setDescription('Only purge this user messages'))
.addBooleanOption((o) => o.setName('bots').setDescription('Only purge bot messages'))
.addBooleanOption((o) => o.setName('links').setDescription('Only purge messages containing links'))
.addBooleanOption((o) =>
o.setName('attachments').setDescription('Only purge messages with attachments')
)
.addStringOption((o) =>
o.setName('regex').setDescription('Only purge messages matching regex')
),
data: purgeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages, locale))) return;
@@ -443,10 +368,7 @@ const purgeCommand: SlashCommand = {
};
const slowmodeCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('slowmode')
.setDescription('Set channel slowmode')
.addIntegerOption((o) => o.setName('seconds').setDescription('0-21600').setRequired(true).setMinValue(0).setMaxValue(21600)),
data: slowmodeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
@@ -467,7 +389,7 @@ const slowmodeCommand: SlashCommand = {
};
const lockCommand: SlashCommand = {
data: new SlashCommandBuilder().setName('lock').setDescription('Lock current text channel'),
data: lockCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
@@ -489,7 +411,7 @@ const lockCommand: SlashCommand = {
};
const unlockCommand: SlashCommand = {
data: new SlashCommandBuilder().setName('unlock').setDescription('Unlock current text channel'),
data: unlockCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
@@ -511,22 +433,7 @@ const unlockCommand: SlashCommand = {
};
const nickCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('nick')
.setDescription('Manage nicknames')
.addSubcommand((s) =>
s
.setName('set')
.setDescription('Set nickname')
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
.addStringOption((o) => o.setName('nickname').setDescription('New nickname').setRequired(true))
)
.addSubcommand((s) =>
s
.setName('reset')
.setDescription('Reset nickname')
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
),
data: nickCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
@@ -540,6 +447,7 @@ const nickCommand: SlashCommand = {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'NICK_SET',
reason: nickname
});
@@ -549,6 +457,7 @@ const nickCommand: SlashCommand = {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'NICK_RESET',
reason: 'Nickname reset'
});
@@ -558,22 +467,7 @@ const nickCommand: SlashCommand = {
};
const caseCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('case')
.setDescription('Manage moderation cases')
.addSubcommand((s) =>
s.setName('view').setDescription('View case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true))
)
.addSubcommand((s) =>
s
.setName('edit')
.setDescription('Edit case reason')
.addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true))
.addStringOption((o) => o.setName('reason').setDescription('New reason').setRequired(true))
)
.addSubcommand((s) =>
s.setName('delete').setDescription('Soft-delete case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true))
),
data: caseCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
@@ -606,22 +500,7 @@ const caseCommand: SlashCommand = {
};
const modNoteCommand: SlashCommand = {
data: new SlashCommandBuilder()
.setName('modnote')
.setDescription('Manage mod notes')
.addSubcommand((s) =>
s
.setName('add')
.setDescription('Add note')
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
.addStringOption((o) => o.setName('note').setDescription('Note').setRequired(true))
)
.addSubcommand((s) =>
s
.setName('list')
.setDescription('List notes')
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
),
data: modNoteCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
@@ -642,6 +521,7 @@ const modNoteCommand: SlashCommand = {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'MODNOTE_ADD',
reason: note
});