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:
10
README.md
10
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.
|
||||
|
||||
128
apps/bot/prisma/migrations/20260722123000_init/migration.sql
Normal file
128
apps/bot/prisma/migrations/20260722123000_init/migration.sql
Normal 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;
|
||||
3
apps/bot/prisma/migrations/migration_lock.toml
Normal file
3
apps/bot/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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);
|
||||
|
||||
213
apps/bot/src/modules/moderation/command-definitions.ts
Normal file
213
apps/bot/src/modules/moderation/command-definitions.ts
Normal 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')
|
||||
)
|
||||
);
|
||||
@@ -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
101
docs/DATABASE.md
Normal 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`
|
||||
@@ -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)
|
||||
|
||||
220
packages/shared/src/command-locales.ts
Normal file
220
packages/shared/src/command-locales.ts
Normal 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;
|
||||
17
packages/shared/src/discord-localization.test.ts
Normal file
17
packages/shared/src/discord-localization.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
48
packages/shared/src/discord-localization.ts
Normal file
48
packages/shared/src/discord-localization.ts
Normal 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);
|
||||
}
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user