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.
This commit is contained in:
smueller
2026-07-22 11:16:39 +02:00
parent bc97d1d74c
commit dd3b132f73
11 changed files with 348 additions and 7 deletions

View File

@@ -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<TempBanJob>(
const moderationWorker = new Worker<TempBanJob>(
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<void> {
await backupQueue.add(
'dailyPgDump',
{},
{
repeat: { pattern: env.BACKUP_CRON },
removeOnComplete: 100,
removeOnFail: 100
}
);
}
async function runPgDumpBackup(): Promise<void> {
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<void>((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<void> {
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 });
})
);
}