From a44f4d6641718e6e1aa206692edd63a36eb59e04 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 12:06:16 +0200 Subject: [PATCH] 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. --- README.md | 10 +- .../20260722123000_init/migration.sql | 128 ++++++++++ .../bot/prisma/migrations/migration_lock.toml | 3 + apps/bot/src/commands.ts | 5 +- .../modules/moderation/command-definitions.ts | 213 +++++++++++++++++ apps/bot/src/modules/moderation/commands.ts | 186 +++------------ docs/DATABASE.md | 101 ++++++++ docs/PHASE-TRACKING.md | 13 +- packages/shared/src/command-locales.ts | 220 ++++++++++++++++++ .../shared/src/discord-localization.test.ts | 17 ++ packages/shared/src/discord-localization.ts | 48 ++++ packages/shared/src/index.ts | 4 + 12 files changed, 784 insertions(+), 164 deletions(-) create mode 100644 apps/bot/prisma/migrations/20260722123000_init/migration.sql create mode 100644 apps/bot/prisma/migrations/migration_lock.toml create mode 100644 apps/bot/src/modules/moderation/command-definitions.ts create mode 100644 docs/DATABASE.md create mode 100644 packages/shared/src/command-locales.ts create mode 100644 packages/shared/src/discord-localization.test.ts create mode 100644 packages/shared/src/discord-localization.ts diff --git a/README.md b/README.md index 0d9d24f..f609680 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ Nexumi is a self-hosted public Discord bot with dashboard architecture, built as - `pnpm install` 3. Generate Prisma client: - `pnpm prisma:generate` -4. Start stack: +4. Apply database migrations: + - `pnpm prisma:migrate` +5. Start stack: - `docker compose up -d` -5. Start bot locally: +6. Start bot locally: - `pnpm --filter @nexumi/bot dev` ## Verification commands @@ -30,6 +32,10 @@ Nexumi is a self-hosted public Discord bot with dashboard architecture, built as - `pnpm lint` - `pnpm test` +## Database + +See `docs/DATABASE.md` for migrations, backup and restore runbooks. + ## Backup and restore note Phase 1 includes the BullMQ foundation for scheduled jobs. Database backup rotation is configured via `BACKUP_RETENTION_DAYS` and the `backups` volume is already reserved in compose. diff --git a/apps/bot/prisma/migrations/20260722123000_init/migration.sql b/apps/bot/prisma/migrations/20260722123000_init/migration.sql new file mode 100644 index 0000000..44e5f8e --- /dev/null +++ b/apps/bot/prisma/migrations/20260722123000_init/migration.sql @@ -0,0 +1,128 @@ +-- CreateTable +CREATE TABLE "Guild" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Guild_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GuildSettings" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "locale" TEXT NOT NULL DEFAULT 'de', + "moderationEnabled" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "GuildSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Case" ( + "id" TEXT NOT NULL, + "caseNumber" INTEGER NOT NULL, + "guildId" TEXT NOT NULL, + "targetUserId" TEXT NOT NULL, + "moderatorId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "reason" TEXT, + "metadata" JSONB, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Case_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Warning" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "moderatorId" TEXT NOT NULL, + "reason" TEXT, + "caseId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Warning_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ModNote" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "moderatorId" TEXT NOT NULL, + "note" TEXT NOT NULL, + "caseId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ModNote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EscalationRule" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "warnCount" INTEGER NOT NULL, + "action" TEXT NOT NULL, + "durationMs" INTEGER, + "reason" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EscalationRule_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GuildSettings_guildId_key" ON "GuildSettings"("guildId"); + +-- CreateIndex +CREATE INDEX "Case_guildId_targetUserId_idx" ON "Case"("guildId", "targetUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Case_guildId_caseNumber_key" ON "Case"("guildId", "caseNumber"); + +-- CreateIndex +CREATE INDEX "Warning_guildId_userId_idx" ON "Warning"("guildId", "userId"); + +-- CreateIndex +CREATE INDEX "ModNote_guildId_userId_idx" ON "ModNote"("guildId", "userId"); + +-- CreateIndex +CREATE INDEX "EscalationRule_guildId_enabled_idx" ON "EscalationRule"("guildId", "enabled"); + +-- CreateIndex +CREATE UNIQUE INDEX "EscalationRule_guildId_warnCount_key" ON "EscalationRule"("guildId", "warnCount"); + +-- AddForeignKey +ALTER TABLE "GuildSettings" ADD CONSTRAINT "GuildSettings_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Case" ADD CONSTRAINT "Case_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Warning" ADD CONSTRAINT "Warning_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Warning" ADD CONSTRAINT "Warning_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EscalationRule" ADD CONSTRAINT "EscalationRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/bot/prisma/migrations/migration_lock.toml b/apps/bot/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/apps/bot/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 3c1f9dd..b623d9a 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -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); diff --git a/apps/bot/src/modules/moderation/command-definitions.ts b/apps/bot/src/modules/moderation/command-definitions.ts new file mode 100644 index 0000000..5842a32 --- /dev/null +++ b/apps/bot/src/modules/moderation/command-definitions.ts @@ -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') + ) + ); diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 7a3dec5..3ec072d 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -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 }); diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..03e6d2f --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,101 @@ +# Nexumi – Datenbank + +## Migrationen + +Prisma-Schema: `apps/bot/prisma/schema.prisma` + +Initiale Migration: + +- `apps/bot/prisma/migrations/20260722123000_init` + +### Lokale Entwicklung + +1. Stack starten: + +```bash +docker compose up -d postgres redis +``` + +2. `.env` setzen (Beispiel lokal): + +```env +DATABASE_URL=postgresql://nexumi:nexumi@localhost:5432/nexumi +``` + +Hinweis: Im Compose-Stack ist Postgres absichtlich nicht nach außen veröffentlicht. Für lokale Migrationen außerhalb von Docker entweder temporär einen Port mappen oder Migrationen im Bot-Container ausführen. + +3. Migration anwenden: + +```bash +pnpm prisma:migrate +``` + +Alternativ im Bot-Container: + +```bash +docker compose run --rm bot pnpm prisma:migrate +``` + +4. Client generieren: + +```bash +pnpm prisma:generate +``` + +## Backup (automatisch) + +- Job: BullMQ `dailyPgDump` +- Cron: `BACKUP_CRON` (Default `0 3 * * *`) +- Zielverzeichnis: `BACKUP_DIR` (Default `/backups`) +- Retention: `BACKUP_RETENTION_DAYS` (Default `14`) + +Backups liegen im Docker-Volume `backups`, am Bot-Container unter `/backups`. + +### Manuelles Backup + +```bash +docker compose exec bot sh -c "pg_dump --dbname \"$DATABASE_URL\" --file /backups/manual-$(date -u +%Y%m%dT%H%M%SZ).sql" +``` + +## Restore + +1. Bot/WebUI stoppen: + +```bash +docker compose stop bot webui +``` + +2. Backup-Datei in den Postgres-Container kopieren (Beispiel): + +```bash +docker compose cp ./backups/nexumi-2026-07-22.sql postgres:/tmp/restore.sql +``` + +3. Restore ausführen: + +```bash +docker compose exec -T postgres psql -U nexumi -d nexumi -f /tmp/restore.sql +``` + +4. Services wieder starten: + +```bash +docker compose up -d +``` + +## Audit-Metadaten (Cases) + +Standardisierte Case-Metadaten werden zentral über `buildCaseMetadata()` in `@nexumi/shared` erzeugt. + +Pflichtfelder: + +- `source` (`slash_command`, `button_confirmation`, `escalation`, `job`) +- `recordedAt` (ISO-Zeitstempel) + +Optionale Felder je nach Aktion: + +- `channelId` +- `confirmed` +- `escalation`, `warningCount`, `durationMs` +- `purge.requestedAmount`, `purge.deletedCount`, `purge.filters` +- `deletedCaseId`, `deletedCaseNumber` diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 0daa0d8..f93ec9d 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -56,21 +56,18 @@ 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`) + - Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations) - Bestätigungsflow für destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons + - standardisierte Case-Audit-Metadaten über `@nexumi/shared` (`buildCaseMetadata`) +- Prisma-Migrationen versioniert: + - `apps/bot/prisma/migrations/20260722123000_init` - Tests (aktuell vorhanden): - `packages/shared/src/i18n.test.ts` + - `packages/shared/src/audit.test.ts` - `apps/bot/src/modules/moderation/duration.test.ts` ### Noch offen für Phase 1 -- Vollständige i18n-Auslagerung: - - Restarbeiten: Command-Beschreibungen/-Optionstexte ebenfalls in zentralen Locale-Strukturen führen. -- Audit-/Case-System weiter härten: - - Einheitliche, nachvollziehbare Audit-Metadaten pro Moderationsaktion. -- Migrations-Workflow finalisieren: - - Neue Prisma-Migration für `EscalationRule` erzeugen und versionieren. -- Setup-Doku erweitern: - - Konkrete Restore-/Backup-Runbook-Schritte mit Beispielbefehlen pro Umgebung. - `docker compose up -d` End-to-End gegen echte `.env` und Test-Discord-Guild final verifizieren. ### Verifikation (letzter Lauf) diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts new file mode 100644 index 0000000..deb3b27 --- /dev/null +++ b/packages/shared/src/command-locales.ts @@ -0,0 +1,220 @@ +export const commandLocales = { + 'moderation.ban.description': { + de: 'Bannt einen Nutzer', + en: 'Ban a user' + }, + 'moderation.ban.options.user': { + de: 'Zielnutzer', + en: 'Target user' + }, + 'moderation.ban.options.reason': { + de: 'Grund', + en: 'Reason' + }, + 'moderation.ban.options.duration': { + de: 'Optionale Temp-Ban-Dauer (z. B. 7d)', + en: 'Optional temp-ban duration (e.g. 7d)' + }, + 'moderation.unban.description': { + de: 'Entbannt einen Nutzer', + en: 'Unban a user' + }, + 'moderation.unban.options.user_id': { + de: 'Discord-Nutzer-ID', + en: 'Discord user id' + }, + 'moderation.kick.description': { + de: 'Kickt einen Nutzer', + en: 'Kick a user' + }, + 'moderation.timeout.description': { + de: 'Timeout für einen Nutzer', + en: 'Timeout a user' + }, + 'moderation.timeout.options.duration': { + de: 'Dauer (z. B. 15m)', + en: 'Duration (e.g. 15m)' + }, + 'moderation.untimeout.description': { + de: 'Entfernt den Timeout eines Nutzers', + en: 'Remove timeout from a user' + }, + 'moderation.warn.description': { + de: 'Verwarnungen verwalten', + en: 'Manage user warnings' + }, + 'moderation.warn.add.description': { + de: 'Verwarnung hinzufügen', + en: 'Add warning' + }, + 'moderation.warn.list.description': { + de: 'Verwarnungen anzeigen', + en: 'List warnings' + }, + 'moderation.warn.remove.description': { + de: 'Verwarnung entfernen', + en: 'Remove warning' + }, + 'moderation.warn.clear.description': { + de: 'Verwarnungen eines Nutzers löschen', + en: 'Clear warnings for user' + }, + 'moderation.warn.escalation_set.description': { + de: 'Eskalationsregel erstellen oder aktualisieren', + en: 'Create or update escalation rule' + }, + 'moderation.warn.escalation_list.description': { + de: 'Eskalationsregeln anzeigen', + en: 'List escalation rules' + }, + 'moderation.warn.escalation_remove.description': { + de: 'Eskalationsregel entfernen', + en: 'Remove escalation rule' + }, + 'moderation.warn.options.count': { + de: 'Schwellenwert für Verwarnungen', + en: 'Warning count threshold' + }, + 'moderation.warn.options.action': { + de: 'Eskalationsaktion', + en: 'Escalation action' + }, + 'moderation.warn.options.escalation_duration': { + de: 'Timeout-Dauer (für TIMEOUT erforderlich)', + en: 'Timeout duration (required for TIMEOUT)' + }, + 'moderation.warn.options.escalation_reason': { + de: 'Optionaler Eskalationsgrund', + en: 'Optional escalation reason' + }, + 'moderation.warn.options.warning_id': { + de: 'Verwarnungs-ID', + en: 'Warning ID' + }, + 'moderation.choice.timeout': { + de: 'Timeout', + en: 'timeout' + }, + 'moderation.choice.kick': { + de: 'Kick', + en: 'kick' + }, + 'moderation.choice.ban': { + de: 'Ban', + en: 'ban' + }, + 'moderation.purge.description': { + de: 'Nachrichten massenhaft löschen', + en: 'Bulk delete messages' + }, + 'moderation.purge.options.amount': { + de: '2-100', + en: '2-100' + }, + 'moderation.purge.options.user': { + de: 'Nur Nachrichten dieses Nutzers löschen', + en: 'Only purge this user messages' + }, + 'moderation.purge.options.bots': { + de: 'Nur Bot-Nachrichten löschen', + en: 'Only purge bot messages' + }, + 'moderation.purge.options.links': { + de: 'Nur Nachrichten mit Links löschen', + en: 'Only purge messages containing links' + }, + 'moderation.purge.options.attachments': { + de: 'Nur Nachrichten mit Anhängen löschen', + en: 'Only purge messages with attachments' + }, + 'moderation.purge.options.regex': { + de: 'Nur Nachrichten per Regex löschen', + en: 'Only purge messages matching regex' + }, + 'moderation.slowmode.description': { + de: 'Slowmode für den Kanal setzen', + en: 'Set channel slowmode' + }, + 'moderation.slowmode.options.seconds': { + de: '0-21600', + en: '0-21600' + }, + 'moderation.lock.description': { + de: 'Aktuellen Textkanal sperren', + en: 'Lock current text channel' + }, + 'moderation.unlock.description': { + de: 'Aktuellen Textkanal entsperren', + en: 'Unlock current text channel' + }, + 'moderation.nick.description': { + de: 'Nicknames verwalten', + en: 'Manage nicknames' + }, + 'moderation.nick.set.description': { + de: 'Nickname setzen', + en: 'Set nickname' + }, + 'moderation.nick.reset.description': { + de: 'Nickname zurücksetzen', + en: 'Reset nickname' + }, + 'moderation.nick.options.nickname': { + de: 'Neuer Nickname', + en: 'New nickname' + }, + 'moderation.case.description': { + de: 'Moderationsfälle verwalten', + en: 'Manage moderation cases' + }, + 'moderation.case.view.description': { + de: 'Fall anzeigen', + en: 'View case' + }, + 'moderation.case.edit.description': { + de: 'Fallgrund bearbeiten', + en: 'Edit case reason' + }, + 'moderation.case.delete.description': { + de: 'Fall soft-löschen', + en: 'Soft-delete case' + }, + 'moderation.case.options.id': { + de: 'Fallnummer', + en: 'Case number' + }, + 'moderation.case.options.reason': { + de: 'Neuer Grund', + en: 'New reason' + }, + 'moderation.modnote.description': { + de: 'Mod-Notizen verwalten', + en: 'Manage mod notes' + }, + 'moderation.modnote.add.description': { + de: 'Notiz hinzufügen', + en: 'Add note' + }, + 'moderation.modnote.list.description': { + de: 'Notizen anzeigen', + en: 'List notes' + }, + 'moderation.modnote.options.note': { + de: 'Notiz', + en: 'Note' + }, + 'moderation.common.options.user': { + de: 'Zielnutzer', + en: 'Target user' + }, + 'moderation.common.options.reason': { + de: 'Grund', + en: 'Reason' + }, + 'moderation.common.options.target': { + de: 'Ziel', + en: 'Target' + } +} as const; + +export type CommandLocaleKey = keyof typeof commandLocales; diff --git a/packages/shared/src/discord-localization.test.ts b/packages/shared/src/discord-localization.test.ts new file mode 100644 index 0000000..44f6eb9 --- /dev/null +++ b/packages/shared/src/discord-localization.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { commandLocale, localizedChoice } from './discord-localization.js'; + +describe('discord localization helpers', () => { + it('maps command locales to Discord localization keys', () => { + const locale = commandLocale('moderation.ban.description'); + expect(locale.description).toBe('Ban a user'); + expect(locale.descriptionLocalizations.de).toBe('Bannt einen Nutzer'); + expect(locale.descriptionLocalizations['en-US']).toBe('Ban a user'); + }); + + it('maps choice labels with localizations', () => { + const choice = localizedChoice('moderation.choice.ban', 'BAN'); + expect(choice.value).toBe('BAN'); + expect(choice.name_localizations?.de).toBe('Ban'); + }); +}); diff --git a/packages/shared/src/discord-localization.ts b/packages/shared/src/discord-localization.ts new file mode 100644 index 0000000..d6d6511 --- /dev/null +++ b/packages/shared/src/discord-localization.ts @@ -0,0 +1,48 @@ +import { commandLocales, type CommandLocaleKey } from './command-locales.js'; + +export function commandLocale(key: CommandLocaleKey): { + description: string; + descriptionLocalizations: Record; +} { + const entry = commandLocales[key]; + return { + description: entry.en, + descriptionLocalizations: { + de: entry.de, + 'en-US': entry.en + } + }; +} + +export function localizedChoice(key: CommandLocaleKey, value: string) { + const entry = commandLocales[key]; + return { + name: entry.en, + name_localizations: { + de: entry.de, + 'en-US': entry.en + }, + value + }; +} + +export function applyCommandDescription< + T extends { + setDescription: (description: string) => T; + setDescriptionLocalizations: (localizations: Record) => T; + } +>(builder: T, key: CommandLocaleKey): T { + const locale = commandLocale(key); + return builder + .setDescription(locale.description) + .setDescriptionLocalizations(locale.descriptionLocalizations); +} + +export function applyOptionDescription< + T extends { + setDescription: (description: string) => T; + setDescriptionLocalizations: (localizations: Record) => T; + } +>(option: T, key: CommandLocaleKey): T { + return applyCommandDescription(option, key); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index be5672d..4ee6950 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; export * from './audit.js'; +export * from './command-locales.js'; +export * from './discord-localization.js'; export const LocaleSchema = z.enum(['de', 'en']); export type Locale = z.infer; @@ -40,6 +42,7 @@ const de: Dictionary = { 'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).', 'generic.invalidRegex': 'Ungültiges Regex-Muster.', 'generic.error': 'Es ist ein Fehler aufgetreten.', + 'generic.unknownCommand': 'Unbekannter Befehl.', 'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.reason.none': 'Kein Grund angegeben', 'moderation.warning.created': 'Verwarnung erstellt: {warningId}', @@ -71,6 +74,7 @@ const en: Dictionary = { 'generic.botMissingPermission': 'Bot lacks required permission ({permission}).', 'generic.invalidRegex': 'Invalid regex pattern.', 'generic.error': 'An error occurred.', + 'generic.unknownCommand': 'Unknown command.', 'moderation.success': 'Action executed successfully.', 'moderation.reason.none': 'No reason provided', 'moderation.warning.created': 'Warning created: {warningId}',