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

@@ -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.

View File

@@ -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;

View File

@@ -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"

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

101
docs/DATABASE.md Normal file
View File

@@ -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`

View File

@@ -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)

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,48 @@
import { commandLocales, type CommandLocaleKey } from './command-locales.js';
export function commandLocale(key: CommandLocaleKey): {
description: string;
descriptionLocalizations: Record<string, string>;
} {
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<string, string>) => 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<string, string>) => T;
}
>(option: T, key: CommandLocaleKey): T {
return applyCommandDescription(option, key);
}

View File

@@ -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<typeof LocaleSchema>;
@@ -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}',