From dd3b132f7388563ef3e50216cc1523da12b23ea7 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 11:16:39 +0200 Subject: [PATCH] Add backup functionality with escalation rules for moderation commands - Updated .env.example to include backup configuration options. - Modified docker-compose.yml to mount backup volume. - Enhanced Dockerfile to install PostgreSQL client. - Added EscalationRule model to Prisma schema for managing warning escalation. - Updated environment validation in env.ts to include backup settings. - Refactored job handling in jobs.ts to implement backup jobs and cleanup. - Introduced escalation commands in moderation service for managing user warnings. - Updated moderation commands to apply escalation rules based on warning count. --- .env.example | 3 + README.md | 43 ++++++++ apps/bot/Dockerfile | 1 + apps/bot/prisma/schema.prisma | 17 +++ apps/bot/src/env.ts | 2 + apps/bot/src/index.ts | 3 +- apps/bot/src/jobs.ts | 84 ++++++++++++++- apps/bot/src/modules/moderation/commands.ts | 109 +++++++++++++++++++- apps/bot/src/modules/moderation/service.ts | 83 +++++++++++++++ docker-compose.yml | 2 + packages/shared/src/i18n.test.ts | 8 ++ 11 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 README.md create mode 100644 packages/shared/src/i18n.test.ts diff --git a/.env.example b/.env.example index aef5ea9..a5ac9eb 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ DEFAULT_LOCALE=de METRICS_TOKEN= SENTRY_DSN= BACKUP_RETENTION_DAYS=14 +BACKUP_CRON=0 3 * * * +BACKUP_DIR=/backups +HEALTH_PORT=8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d9d24f --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Nexumi + +Nexumi is a self-hosted public Discord bot with dashboard architecture, built as a TypeScript monorepo. + +## Phase 1 scope + +- Monorepo foundation with `apps/bot` and `packages/shared` +- PostgreSQL + Redis compose stack +- Prisma core schema (`Guild`, `GuildSettings`, `User`, `Case`) plus moderation tables (`Warning`, `ModNote`) +- Command and module framework for slash command routing +- Permission layer and i18n starter (`de` + `en`) +- BullMQ integration for persistent moderation jobs (temp-ban expiration) +- Complete moderation reference command set for Phase 1 + +## Quickstart + +1. Copy `.env.example` to `.env` and fill required values. +2. Install dependencies: + - `pnpm install` +3. Generate Prisma client: + - `pnpm prisma:generate` +4. Start stack: + - `docker compose up -d` +5. Start bot locally: + - `pnpm --filter @nexumi/bot dev` + +## Verification commands + +- `pnpm typecheck` +- `pnpm lint` +- `pnpm test` + +## 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. + +Restore flow (manual, PostgreSQL container): + +1. Stop bot/web services. +2. Copy dump into postgres container. +3. Run `psql` restore against `nexumi` database: + - `docker compose exec -T postgres psql -U nexumi -d nexumi < /path/to/backup.sql` +4. Start services again. diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile index a02a319..3d28408 100644 --- a/apps/bot/Dockerfile +++ b/apps/bot/Dockerfile @@ -17,6 +17,7 @@ RUN pnpm --filter @nexumi/bot build FROM node:22-alpine AS runner WORKDIR /app RUN corepack enable +RUN apk add --no-cache postgresql16-client COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/apps/bot/dist ./apps/bot/dist COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 8b4b3af..f3859a0 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -13,6 +13,7 @@ model Guild { updatedAt DateTime @updatedAt settings GuildSettings? cases Case[] + escalationRules EscalationRule[] } model GuildSettings { @@ -79,3 +80,19 @@ model ModNote { @@index([guildId, userId]) } + +model EscalationRule { + id String @id @default(cuid()) + guildId String + warnCount Int + action String + durationMs Int? + reason String? + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, warnCount]) + @@index([guildId, enabled]) +} diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index a0a8587..d48770f 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -12,6 +12,8 @@ const EnvSchema = z.object({ LOG_LEVEL: z.string().default('info'), DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'), BACKUP_RETENTION_DAYS: z.coerce.number().int().positive().default(14), + BACKUP_CRON: z.string().default('0 3 * * *'), + BACKUP_DIR: z.string().default('/backups'), METRICS_TOKEN: z.string().optional(), HEALTH_PORT: z.coerce.number().int().positive().default(8080) }); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 5511df1..d7fcc64 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -11,7 +11,7 @@ import { logger } from './logger.js'; import { prisma } from './db.js'; import { redis } from './redis.js'; import { registerCommands, routeCommand } from './commands.js'; -import { startWorkers } from './jobs.js'; +import { ensureRecurringJobs, startWorkers } from './jobs.js'; import type { BotContext } from './types.js'; import { startHealthServer } from './health.js'; @@ -39,6 +39,7 @@ if (!isShard) { const context: BotContext = { client, prisma, redis }; startWorkers(context); + await ensureRecurringJobs(); client.on(Events.ClientReady, async () => { logger.info({ tag: client.user?.tag }, 'Bot ready'); diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 320a244..d212650 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -1,11 +1,17 @@ import { Queue, Worker } from 'bullmq'; import { PermissionFlagsBits } from 'discord.js'; +import { mkdir, readdir, rm, stat } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { spawn } from 'node:child_process'; import { redis } from './redis.js'; import { logger } from './logger.js'; import type { BotContext } from './types.js'; +import { env } from './env.js'; export const moderationQueueName = 'moderation'; export const moderationQueue = new Queue(moderationQueueName, { connection: redis }); +export const backupQueueName = 'backups'; +export const backupQueue = new Queue(backupQueueName, { connection: redis }); type TempBanJob = { guildId: string; @@ -14,7 +20,7 @@ type TempBanJob = { }; export function startWorkers(context: BotContext): Worker[] { - const worker = new Worker( + const moderationWorker = new Worker( moderationQueueName, async (job) => { if (job.name !== 'tempBanExpire') { @@ -31,9 +37,81 @@ export function startWorkers(context: BotContext): Worker[] { { connection: redis } ); - worker.on('failed', (job, error) => { + moderationWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Moderation job failed'); }); - return [worker]; + const backupWorker = new Worker( + backupQueueName, + async (job) => { + if (job.name !== 'dailyPgDump') { + return; + } + await runPgDumpBackup(); + }, + { connection: redis } + ); + + backupWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Backup job failed'); + }); + + return [moderationWorker, backupWorker]; +} + +export async function ensureRecurringJobs(): Promise { + await backupQueue.add( + 'dailyPgDump', + {}, + { + repeat: { pattern: env.BACKUP_CRON }, + removeOnComplete: 100, + removeOnFail: 100 + } + ); +} + +async function runPgDumpBackup(): Promise { + await mkdir(env.BACKUP_DIR, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `nexumi-${timestamp}.sql`; + const outputPath = join(env.BACKUP_DIR, filename); + + await new Promise((resolve, reject) => { + const dump = spawn('pg_dump', ['--dbname', env.DATABASE_URL, '--file', outputPath], { + stdio: 'pipe' + }); + let stderr = ''; + dump.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + dump.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(stderr || `pg_dump exited with code ${code ?? 'unknown'}`)); + }); + }); + + await cleanupOldBackups(); + logger.info({ backupFile: basename(outputPath) }, 'Database backup finished'); +} + +async function cleanupOldBackups(): Promise { + const files = await readdir(env.BACKUP_DIR, { withFileTypes: true }); + const cutoff = Date.now() - env.BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000; + await Promise.all( + files + .filter((entry) => entry.isFile() && entry.name.endsWith('.sql')) + .map(async (entry) => { + const fullPath = join(env.BACKUP_DIR, entry.name); + const fileStat = await stat(fullPath); + const created = fileStat.mtime.getTime(); + if (Number.isNaN(created) || created >= cutoff) { + return; + } + await rm(fullPath, { force: true }); + }) + ); } diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 9d5e3a0..0c204d3 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -7,9 +7,9 @@ import { type GuildMember } from 'discord.js'; import { t } from '@nexumi/shared'; -import type { SlashCommand, BotContext } from '../../types.js'; +import type { SlashCommand } from '../../types.js'; import { requirePermission } from '../../permissions.js'; -import { createCase, scheduleTempBanExpire } from './service.js'; +import { applyWarnEscalation, createCase, scheduleTempBanExpire } from './service.js'; import { parseDuration } from './duration.js'; function reasonFrom(i: ChatInputCommandInteraction): string { @@ -175,10 +175,102 @@ const warnCommand: SlashCommand = { .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)) ), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const sub = interaction.options.getSubcommand(); + if (sub === 'escalation-set') { + const warnCount = interaction.options.getInteger('count', true); + const action = interaction.options.getString('action', true); + const durationInput = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + const durationMs = durationInput ? parseDuration(durationInput) : null; + if (action === 'TIMEOUT' && !durationMs) { + await interaction.reply({ content: 'Duration is required for TIMEOUT escalation.', ephemeral: true }); + return; + } + await context.prisma.escalationRule.upsert({ + where: { + guildId_warnCount: { + guildId: interaction.guildId!, + warnCount + } + }, + update: { + action, + durationMs, + reason, + enabled: true + }, + create: { + guildId: interaction.guildId!, + warnCount, + action, + durationMs, + reason, + enabled: true + } + }); + await interaction.reply({ + content: `Escalation rule saved: ${warnCount} warnings => ${action}${durationMs ? ` (${durationMs} ms)` : ''}.`, + ephemeral: true + }); + return; + } + + if (sub === 'escalation-list') { + const rules = await context.prisma.escalationRule.findMany({ + where: { guildId: interaction.guildId!, enabled: true }, + orderBy: { warnCount: 'asc' } + }); + const content = + rules.length === 0 + ? 'No escalation rules configured.' + : rules + .map( + (rule) => + `- ${rule.warnCount} warnings => ${rule.action}${rule.durationMs ? ` (${rule.durationMs} ms)` : ''}${rule.reason ? ` | ${rule.reason}` : ''}` + ) + .join('\n'); + await interaction.reply({ content, ephemeral: true }); + return; + } + + if (sub === 'escalation-remove') { + const warnCount = interaction.options.getInteger('count', true); + await context.prisma.escalationRule.delete({ + where: { guildId_warnCount: { guildId: interaction.guildId!, warnCount } } + }); + await interaction.reply({ content: `Escalation rule removed for ${warnCount} warnings.`, ephemeral: true }); + return; + } + if (sub === 'add') { const user = interaction.options.getUser('user', true); const reason = interaction.options.getString('reason', true); @@ -203,7 +295,18 @@ const warnCommand: SlashCommand = { caseId: modCase.id } }); - await interaction.reply({ content: `Warning created: ${warning.id}`, ephemeral: true }); + const escalationResult = await applyWarnEscalation( + interaction, + context, + user.id, + interaction.user.id + ); + await interaction.reply({ + content: escalationResult + ? `Warning created: ${warning.id}\n${escalationResult}` + : `Warning created: ${warning.id}`, + ephemeral: true + }); return; } diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index 3565e1d..ad3d743 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -1,5 +1,6 @@ import { moderationQueue } from '../../jobs.js'; import type { BotContext } from '../../types.js'; +import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; type CreateCaseInput = { guildId: string; @@ -11,6 +12,12 @@ type CreateCaseInput = { }; export async function createCase(context: BotContext, input: CreateCaseInput) { + await context.prisma.guild.upsert({ + where: { id: input.guildId }, + update: {}, + create: { id: input.guildId } + }); + const lastCase = await context.prisma.case.findFirst({ where: { guildId: input.guildId }, orderBy: { caseNumber: 'desc' } @@ -45,3 +52,79 @@ export async function scheduleTempBanExpire( } ); } + +export async function applyWarnEscalation( + interaction: ChatInputCommandInteraction, + context: BotContext, + userId: string, + moderatorId: string +): Promise { + const guildId = interaction.guildId; + if (!guildId || !interaction.guild) { + return null; + } + + const warningCount = await context.prisma.warning.count({ + where: { guildId, userId } + }); + + const rule = await context.prisma.escalationRule.findUnique({ + where: { guildId_warnCount: { guildId, warnCount: warningCount } } + }); + + if (!rule || !rule.enabled) { + return null; + } + + const member = await interaction.guild.members.fetch(userId); + const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`; + + if (rule.action === 'TIMEOUT') { + if (!rule.durationMs) { + return `Escalation rule for ${warningCount} warns skipped (missing duration).`; + } + if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) { + return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + } + await member.timeout(rule.durationMs, reason); + await createCase(context, { + guildId, + targetUserId: userId, + moderatorId, + action: 'TIMEOUT', + reason, + metadata: { escalation: true, durationMs: rule.durationMs, warningCount } + }); + return `Auto escalation applied: timeout (${rule.durationMs} ms).`; + } + + if (rule.action === 'KICK') { + if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) { + return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + } + await member.kick(reason); + await createCase(context, { + guildId, + targetUserId: userId, + moderatorId, + action: 'KICK', + reason, + metadata: { escalation: true, warningCount } + }); + return 'Auto escalation applied: kick.'; + } + + if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) { + return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + } + await interaction.guild.members.ban(userId, { reason }); + await createCase(context, { + guildId, + targetUserId: userId, + moderatorId, + action: 'BAN', + reason, + metadata: { escalation: true, warningCount } + }); + return 'Auto escalation applied: ban.'; +} diff --git a/docker-compose.yml b/docker-compose.yml index 770e164..476fc4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,8 @@ services: condition: service_healthy redis: condition: service_healthy + volumes: + - backups:/backups webui: image: node:22-alpine diff --git a/packages/shared/src/i18n.test.ts b/packages/shared/src/i18n.test.ts new file mode 100644 index 0000000..95e8658 --- /dev/null +++ b/packages/shared/src/i18n.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { t } from './index.js'; + +describe('i18n helper', () => { + it('returns german translations', () => { + expect(t('de', 'moderation.success')).toBe('Aktion erfolgreich ausgeführt.'); + }); +});