// Nächtliche SQLite-Backups: Online-Backup (WAL-sicher) → gzip → data/backups/, // 14 Tage Rotation, optional Upload in einen privaten Discord-Kanal. import { createReadStream, createWriteStream, mkdirSync, readdirSync, statSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { pipeline } from 'node:stream/promises'; import { createGzip } from 'node:zlib'; import { join, dirname, resolve } from 'node:path'; import { AttachmentBuilder } from 'discord.js'; import { db, getSetting, setSetting } from './db.js'; import { config } from './config.js'; const CHECK_INTERVAL_MS = 10 * 60 * 1000; const KEEP_DAYS = 14; const MAX_UPLOAD_BYTES = 9 * 1024 * 1024; // Discord-Limit für Bot-Uploads (10 MB, Puffer) export const backupDir = join(dirname(resolve(config.dbPath)), 'backups'); mkdirSync(backupDir, { recursive: true }); /** Backup erstellen; gibt { file, sizeBytes } zurück */ export async function runBackup(client) { const stamp = new Date().toISOString().slice(0, 10); const rawFile = join(backupDir, `d4rkbot-${stamp}.db`); const gzFile = `${rawFile}.gz`; // SQLite-Online-Backup (konsistent trotz laufender Writes), dann komprimieren await db.backup(rawFile); await pipeline(createReadStream(rawFile), createGzip({ level: 9 }), createWriteStream(gzFile)); await rm(rawFile, { force: true }); // Rotation: alles älter als KEEP_DAYS löschen const cutoff = Date.now() - KEEP_DAYS * 86400000; for (const f of readdirSync(backupDir)) { const path = join(backupDir, f); if (f.endsWith('.db.gz') && statSync(path).mtimeMs < cutoff) { await rm(path, { force: true }); } } const sizeBytes = statSync(gzFile).size; // Optionaler Upload in einen (privaten!) Discord-Kanal — Offsite-Kopie const channelId = getSetting('backup_channel_id'); if (channelId && client) { if (sizeBytes <= MAX_UPLOAD_BYTES) { const channel = await client.channels.fetch(channelId).catch(() => null); if (channel?.isTextBased()) { await channel.send({ content: `💾 Backup ${stamp} (${(sizeBytes / 1024).toFixed(0)} KB)`, files: [new AttachmentBuilder(gzFile)], }); } } else { console.warn(`[backup] ${gzFile} zu groß für Discord-Upload (${sizeBytes} B)`); } } setSetting('last_backup', new Date().toISOString()); console.log(`[backup] ${gzFile} erstellt (${(sizeBytes / 1024).toFixed(0)} KB)`); return { file: gzFile, sizeBytes }; } /** Scheduler: täglich zwischen 03:00 und 04:00 (lokale TZ), einmal pro Tag */ export function scheduleBackups(client) { const check = async () => { if (getSetting('backup_enabled') === '0') return; const now = new Date(); if (now.getHours() !== 3) return; const today = now.toISOString().slice(0, 10); if ((getSetting('last_backup') ?? '').slice(0, 10) === today) return; try { await runBackup(client); } catch (error) { console.error('[backup] Fehlgeschlagen:', error); } }; setInterval(check, CHECK_INTERVAL_MS); check(); }