diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 014918c..148d880 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,7 +8,8 @@ import { notifyLater, type Notifier } from './notifier.js'; import { clientIp, LOOPBACK, type RateLimiter } from './rateLimit.js'; export type AppDeps = { - db: Db; + /** Геттер, а не объект: восстановление из бэкапа подменяет базу на лету. */ + getDb: () => Db; now: () => Date; rateLimiter: RateLimiter; notifier: Notifier; @@ -79,7 +80,7 @@ export function createApp(deps: AppDeps) { if (result.data.honeypot) return reply({ ok: true, duplicate: false }, 201); const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null; - const saved = saveLead(deps.db, result.data, { now: deps.now(), userAgent }); + const saved = saveLead(deps.getDb(), result.data, { now: deps.now(), userAgent }); if (saved.kind === 'unchanged') return reply({ ok: true, duplicate: true, clarified: false }, 200); notifyLater(deps.notifier, saved.event); if (saved.kind === 'clarified') return reply({ ok: true, duplicate: true, clarified: true }, 200); diff --git a/apps/api/src/backup.test.ts b/apps/api/src/backup.test.ts new file mode 100644 index 0000000..de655b8 --- /dev/null +++ b/apps/api/src/backup.test.ts @@ -0,0 +1,223 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { gzipSync } from 'node:zlib'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { validateLead } from '@kvarta/shared/lead'; +import { + backupDirFor, + backupFileName, + createBackup, + discardInspected, + hasDailyBackupToday, + inspectBackup, + leadsMissingFrom, + listBackups, + nextDailyRun, + pluralLeads, + restoreFromInspected, + RestoreRejected, + rotateBackups, + runBackupAndReport, +} from './backup.js'; +import { openDb, type DbHolder } from './db/index.js'; +import { MIGRATIONS } from './db/migrations.js'; +import { saveLead } from './leads.js'; +import { validBody } from './testing.js'; +import { fakeTelegram } from './telegram/fake.js'; +import type { SystemReporter } from './telegram/system.js'; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function tempDb() { + const root = mkdtempSync(join(tmpdir(), 'kvarta-backup-')); + dirs.push(root); + const dbPath = join(root, 'kvarta.sqlite'); + const holder: DbHolder = { db: openDb(dbPath), paused: false }; + return { root, dbPath, holder, dir: backupDirFor(dbPath) }; +} + +let phoneSeq = 1000000; +function addLead(holder: DbHolder, at: Date) { + const r = validateLead(validBody({ phone: `+37529${phoneSeq++}` })); + if (!r.ok) throw new Error('invalid'); + return saveLead(holder.db, r.data, { now: at, userAgent: null }); +} + +const leadIds = (holder: DbHolder) => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id); + +// 18.10.2026 03:00 по Минску = 00:00 UTC +const NIGHT = new Date('2026-10-18T00:00:00.000Z'); + +describe('расписание и имена', () => { + it('следующий запуск — 03:00 по Минску', () => { + expect(nextDailyRun(new Date('2026-10-17T12:00:00Z')).toISOString()).toBe('2026-10-18T00:00:00.000Z'); + // 02:59 по Минску — ещё сегодня + expect(nextDailyRun(new Date('2026-10-17T23:59:00Z')).toISOString()).toBe('2026-10-18T00:00:00.000Z'); + // ровно 03:00 — уже завтра + expect(nextDailyRun(new Date('2026-10-18T00:00:00Z')).toISOString()).toBe('2026-10-19T00:00:00.000Z'); + // 01:00 по Минску, но по UTC ещё вчера + expect(nextDailyRun(new Date('2026-10-17T22:00:00Z')).toISOString()).toBe('2026-10-18T00:00:00.000Z'); + // через границу года + expect(nextDailyRun(new Date('2026-12-31T21:30:00Z')).toISOString()).toBe('2027-01-01T00:00:00.000Z'); + }); + + it('имя файла во времени Минска', () => { + expect(backupFileName(NIGHT, 'daily')).toBe('kvarta-2026-10-18T0300.sqlite.gz'); + expect(backupFileName(NIGHT, 'pre-restore')).toBe('pre-restore-2026-10-18T0300.sqlite.gz'); + }); + + it('склонение', () => { + expect([0, 1, 2, 5, 11, 12, 21, 24, 111].map(pluralLeads)).toEqual([ + '0 заявок', '1 заявка', '2 заявки', '5 заявок', '11 заявок', '12 заявок', '21 заявка', '24 заявки', '111 заявок', + ]); + }); +}); + +describe('createBackup и ротация', () => { + it('gzip-копия с данными, без временных файлов', async () => { + const { holder, dir } = tempDb(); + addLead(holder, NIGHT); + addLead(holder, NIGHT); + const result = await createBackup(holder.db, dir, NIGHT); + + expect(result).toMatchObject({ file: 'kvarta-2026-10-18T0300.sqlite.gz', kind: 'daily', leads: 2 }); + expect(readdirSync(dir)).toEqual([result.file]); + const inspected = await inspectBackup(join(dir, result.file), dir); + expect(inspected.leads.map((l) => l.id)).toEqual([1, 2]); + expect(inspected.userVersion).toBe(MIGRATIONS.length); + discardInspected(inspected); + expect(readdirSync(dir)).toEqual([result.file]); + expect(hasDailyBackupToday(dir, new Date('2026-10-18T20:00:00Z'))).toBe(true); + // 19.10 00:30 по Минску — уже другой день + expect(hasDailyBackupToday(dir, new Date('2026-10-18T21:30:00Z'))).toBe(false); + }); + + it('14 ежедневных, pre-restore — 30 дней, чужие файлы не трогаем', () => { + const { dir } = tempDb(); + mkdirSync(dir, { recursive: true }); + const day = 24 * 60 * 60 * 1000; + for (let i = 0; i < 20; i++) writeFileSync(join(dir, backupFileName(new Date(NIGHT.getTime() - i * day), 'daily')), 'x'); + writeFileSync(join(dir, backupFileName(new Date(NIGHT.getTime() - 29 * day), 'pre-restore')), 'x'); + writeFileSync(join(dir, backupFileName(new Date(NIGHT.getTime() - 31 * day), 'pre-restore')), 'x'); + writeFileSync(join(dir, 'notes.txt'), 'x'); + + const removed = rotateBackups(dir, NIGHT); + expect(removed).toHaveLength(7); + const left = listBackups(dir); + expect(left.filter((b) => b.kind === 'daily')).toHaveLength(14); + expect(left.filter((b) => b.kind === 'daily')[0]!.file).toBe('kvarta-2026-10-18T0300.sqlite.gz'); + expect(left.filter((b) => b.kind === 'pre-restore').map((b) => b.file)).toEqual(['pre-restore-2026-09-19T0300.sqlite.gz']); + expect(existsSync(join(dir, 'notes.txt'))).toBe(true); + }); + + it('runBackupAndReport: файл админу, отчёт в «Систему»', async () => { + const { holder, dir } = tempDb(); + addLead(holder, NIGHT); + const tg = fakeTelegram(); + const reports: string[] = []; + const system: SystemReporter = { report: async (_k, t) => void reports.push(t) }; + await runBackupAndReport({ holder, dir, now: NIGHT, client: tg.client, adminId: 42, system }); + + const doc = tg.sent('sendDocument')[0]; + expect(doc).toMatchObject({ chat_id: 42, file: { name: 'kvarta-2026-10-18T0300.sqlite.gz' } }); + expect(reports).toEqual([expect.stringMatching(/^Бэкап 18\.10 03:00: 1 заявка, \d+ КБ ✅$/)]); + }); + + it('runBackupAndReport: ошибка — в «Систему», не бросает', async () => { + const { holder, dir } = tempDb(); + holder.db.close(); + const reports: string[] = []; + const system: SystemReporter = { report: async (_k, t) => void reports.push(t) }; + await expect(runBackupAndReport({ holder, dir, now: NIGHT, system })).resolves.toBeUndefined(); + expect(reports[0]).toMatch(/^Бэкап 18\.10 03:00 не удался ❌/); + }); +}); + +describe('восстановление', () => { + it('подменяет данные, создаёт pre-restore, пауза на время подмены', async () => { + const { holder, dbPath, dir } = tempDb(); + addLead(holder, NIGHT); + const backup = await createBackup(holder.db, dir, NIGHT); + const later = new Date(NIGHT.getTime() + 60 * 60 * 1000); + addLead(holder, later); + addLead(holder, later); + + const inspected = await inspectBackup(join(dir, backup.file), dir); + expect(leadsMissingFrom(holder.db, inspected.leads)).toEqual([2, 3]); + + const oldDb = holder.db; + let pausedDuringIdle: boolean | undefined; + const result = await restoreFromInspected({ + holder, + dbPath, + inspected, + now: later, + waitIdle: async () => void (pausedDuringIdle = holder.paused), + }); + + expect(pausedDuringIdle).toBe(true); + expect(holder.paused).toBe(false); + expect(holder.db).not.toBe(oldDb); + expect(oldDb.open).toBe(false); + expect(leadIds(holder)).toEqual([1]); + expect(result.leads).toBe(1); + expect(result.preRestore.file).toBe('pre-restore-2026-10-18T0400.sqlite.gz'); + const pre = await inspectBackup(join(dir, result.preRestore.file), dir); + expect(pre.leads.map((l) => l.id)).toEqual([1, 2, 3]); + // Временные файлы убраны, рядом с базой только копии. + discardInspected(pre); + expect(readdirSync(dir).sort()).toEqual([backup.file, result.preRestore.file].sort()); + expect(existsSync(`${dbPath}.rollback`)).toBe(false); + // Новая база рабочая: пишем дальше. + addLead(holder, later); + expect(leadIds(holder)).toEqual([1, 2]); + }); + + it('id после отката переиспользуются — пропавшие считаем по id и created_at', async () => { + const { holder, dir } = tempDb(); + addLead(holder, NIGHT); + const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir); + // Заявку #1 пересоздали в другое время (как после прошлого восстановления). + holder.db.prepare('UPDATE leads SET created_at = ? WHERE id = 1').run('2026-10-19T00:00:00.000Z'); + expect(leadsMissingFrom(holder.db, inspected.leads)).toEqual([1]); + }); + + it('битый файл отклоняется, база не тронута', async () => { + const { holder, dir } = tempDb(); + addLead(holder, NIGHT); + await expect(inspectBackup(new Uint8Array([1, 2, 3]), dir)).rejects.toBeInstanceOf(RestoreRejected); + await expect(inspectBackup(gzipSync(Buffer.from('not sqlite at all')), dir)).rejects.toThrow(/не база SQLite/); + expect(leadIds(holder)).toEqual([1]); + expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]); + }); + + it('копия с более новой схемой отклоняется', async () => { + const { root, dir } = tempDb(); + const futurePath = join(root, 'future.sqlite'); + const future = new Database(futurePath); + future.exec(MIGRATIONS[0]!); + future.pragma(`user_version = ${MIGRATIONS.length + 1}`); + future.close(); + await expect(inspectBackup(gzipSync(readFileSync(futurePath)), dir)).rejects.toThrow(/более новой версии/); + }); + + it('сбой после снимка — возвращаем старую базу и снимаем паузу', async () => { + const { holder, dbPath, dir } = tempDb(); + addLead(holder, NIGHT); + addLead(holder, NIGHT); + const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir); + // Портим распакованную копию уже после проверки: openDb на ней упадёт. + writeFileSync(inspected.path, 'garbage'); + + await expect(restoreFromInspected({ holder, dbPath, inspected, now: NIGHT })).rejects.toThrow(); + expect(holder.paused).toBe(false); + expect(holder.db.open).toBe(true); + expect(leadIds(holder)).toEqual([1, 2]); + expect(existsSync(`${dbPath}.rollback`)).toBe(false); + }); +}); diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts new file mode 100644 index 0000000..9d2a4e9 --- /dev/null +++ b/apps/api/src/backup.ts @@ -0,0 +1,371 @@ +// Бэкапы базы и восстановление из копии. Копии лежат рядом с базой (/data/backups), +// поэтому временные файлы на той же файловой системе и rename при подмене атомарен. +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, +} from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { createGunzip, createGzip } from 'node:zlib'; +import Database from 'better-sqlite3'; +import { openDb, type Db, type DbHolder } from './db/index.js'; +import { MIGRATIONS } from './db/migrations.js'; +import type { TelegramClient } from './telegram/client.js'; +import { shortError, type SystemReporter } from './telegram/system.js'; + +export const TIME_ZONE = 'Europe/Minsk'; +export const KEEP_DAILY = 14; +export const KEEP_PRE_RESTORE_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; + +export type BackupKind = 'daily' | 'pre-restore'; +export type BackupFile = { file: string; kind: BackupKind; at: Date; bytes: number }; + +const NAME_RE = /^(kvarta|pre-restore)-(\d{4})-(\d{2})-(\d{2})T(\d{2})(\d{2})\.sqlite\.gz$/; + +export function backupDirFor(dbPath: string): string { + return join(dirname(dbPath), 'backups'); +} + +type LocalParts = { year: number; month: number; day: number; hour: number; minute: number }; + +/** Часы и дата по Минску. Через Intl, а не +3: правило могут поменять, tzdata — обновится. */ +export function localParts(date: Date, timeZone = TIME_ZONE): LocalParts { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(date); + const get = (type: string) => Number(parts.find((p) => p.type === type)!.value); + return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour'), minute: get('minute') }; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** «18.10 03:00» — для кнопок и отчётов. */ +export function shortLabel(date: Date): string { + const p = localParts(date); + return `${pad(p.day)}.${pad(p.month)} ${pad(p.hour)}:${pad(p.minute)}`; +} + +export function backupFileName(date: Date, kind: BackupKind): string { + const p = localParts(date); + const prefix = kind === 'daily' ? 'kvarta' : 'pre-restore'; + return `${prefix}-${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}${pad(p.minute)}.sqlite.gz`; +} + +/** Минское локальное время → UTC-момент (смещение берём на этот самый момент). */ +function fromLocal(p: LocalParts, timeZone = TIME_ZONE): Date { + const guess = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute); + const shown = localParts(new Date(guess), timeZone); + const offset = Date.UTC(shown.year, shown.month - 1, shown.day, shown.hour, shown.minute) - guess; + return new Date(guess - offset); +} + +function parseName(file: string): { kind: BackupKind; at: Date } | null { + const m = NAME_RE.exec(file); + if (!m) return null; + const [, prefix, y, mo, d, h, mi] = m; + return { + kind: prefix === 'kvarta' ? 'daily' : 'pre-restore', + at: fromLocal({ year: +y!, month: +mo!, day: +d!, hour: +h!, minute: +mi! }), + }; +} + +/** Следующий запуск ежедневного бэкапа: ближайшие 03:00 по Минску строго после now. */ +export function nextDailyRun(now: Date, hour = 3): Date { + const today = localParts(now); + let run = fromLocal({ ...today, hour, minute: 0 }); + if (run.getTime() <= now.getTime()) { + const tomorrow = localParts(new Date(run.getTime() + DAY_MS)); + run = fromLocal({ ...tomorrow, hour, minute: 0 }); + } + return run; +} + +export function listBackups(dir: string): BackupFile[] { + if (!existsSync(dir)) return []; + const out: BackupFile[] = []; + for (const file of readdirSync(dir)) { + const parsed = parseName(file); + if (parsed) out.push({ file, ...parsed, bytes: statSync(join(dir, file)).size }); + } + return out.sort((a, b) => b.at.getTime() - a.at.getTime() || b.file.localeCompare(a.file)); +} + +export function hasDailyBackupToday(dir: string, now: Date): boolean { + const today = localParts(now); + return listBackups(dir).some((b) => { + const p = localParts(b.at); + return b.kind === 'daily' && p.year === today.year && p.month === today.month && p.day === today.day; + }); +} + +/** 14 последних ежедневных; pre-restore — 30 дней. Возвращает удалённые имена. */ +export function rotateBackups(dir: string, now: Date): string[] { + const removed: string[] = []; + const all = listBackups(dir); + for (const b of all.filter((b) => b.kind === 'daily').slice(KEEP_DAILY)) removed.push(b.file); + for (const b of all.filter((b) => b.kind === 'pre-restore')) { + if (now.getTime() - b.at.getTime() > KEEP_PRE_RESTORE_DAYS * DAY_MS) removed.push(b.file); + } + for (const file of removed) rmSync(join(dir, file), { force: true }); + return removed; +} + +function countLeads(db: Db): number { + return (db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n; +} + +export type BackupResult = BackupFile & { leads: number }; + +/** Снимок через db.backup() (консистентен при работающей записи) → gzip → атомарный rename. */ +export async function createBackup(db: Db, dir: string, now: Date, kind: BackupKind = 'daily'): Promise { + mkdirSync(dir, { recursive: true }); + const file = backupFileName(now, kind); + const raw = join(dir, `.tmp-${process.pid}-${Date.now()}.sqlite`); + const gz = join(dir, `.tmp-${file}`); + try { + await db.backup(raw); + // Копия наследует WAL из заголовка: переводим в DELETE, иначе рядом остаются -wal/-shm, + // а в архив попадает база, которой нужны файлы-спутники. + const snapshot = new Database(raw); + snapshot.pragma('journal_mode = DELETE'); + const leads = countLeads(snapshot); + snapshot.close(); + await pipeline(createReadStream(raw), createGzip(), createWriteStream(gz)); + renameSync(gz, join(dir, file)); + return { file, kind, at: now, bytes: statSync(join(dir, file)).size, leads }; + } finally { + rmSync(raw, { force: true }); + removeSidecars(raw); + rmSync(gz, { force: true }); + } +} + +export class RestoreRejected extends Error {} + +export type InspectedBackup = { + /** Распакованная копия во временном файле; удаляет вызывающий (discard) */ + path: string; + userVersion: number; + leads: { id: number; created_at: string }[]; +}; + +/** gunzip → integrity_check → версия схемы. Кидает RestoreRejected с понятным текстом. */ +export async function inspectBackup(source: string | Uint8Array, dir: string): Promise { + mkdirSync(dir, { recursive: true }); + const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const path = join(dir, `.restore-${stamp}.sqlite`); + const gzPath = typeof source === 'string' ? source : join(dir, `.upload-${stamp}.sqlite.gz`); + try { + if (typeof source !== 'string') await writeFile(gzPath, source); + try { + await pipeline(createReadStream(gzPath), createGunzip(), createWriteStream(path)); + } catch { + throw new RestoreRejected('Файл не распаковывается: это не .sqlite.gz или он повреждён.'); + } + // Открываем на запись: это наша временная копия, а readonly на WAL-базе оставил бы -wal/-shm. + let copy: Database.Database; + let integrity: unknown; + try { + copy = new Database(path, { fileMustExist: true }); + } catch { + throw new RestoreRejected('Внутри архива не база SQLite.'); + } + try { + try { + copy.pragma('journal_mode = DELETE'); + integrity = copy.pragma('integrity_check', { simple: true }); + } catch { + throw new RestoreRejected('Внутри архива не база SQLite.'); + } + if (integrity !== 'ok') throw new RestoreRejected('Копия повреждена: integrity_check не прошёл.'); + const userVersion = copy.pragma('user_version', { simple: true }) as number; + if (userVersion > MIGRATIONS.length) { + throw new RestoreRejected(`Копия от более новой версии api (схема v${userVersion}, у нас v${MIGRATIONS.length}).`); + } + const hasLeads = copy.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'leads'").get(); + if (!hasLeads) throw new RestoreRejected('В копии нет таблицы заявок.'); + const leads = copy.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads']; + return { path, userVersion, leads }; + } finally { + copy.close(); + } + } catch (err) { + rmSync(path, { force: true }); + removeSidecars(path); + throw err; + } finally { + if (typeof source !== 'string') rmSync(gzPath, { force: true }); + } +} + +export function discardInspected(inspected: InspectedBackup): void { + rmSync(inspected.path, { force: true }); +} + +/** Заявки текущей базы, которых нет в копии (по паре id + created_at: id после отката переиспользуются). */ +export function leadsMissingFrom(db: Db, backupLeads: InspectedBackup['leads']): number[] { + const inCopy = new Set(backupLeads.map((l) => `${l.id}|${l.created_at}`)); + const current = db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads']; + return current.filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id); +} + +function removeSidecars(path: string) { + rmSync(`${path}-wal`, { force: true }); + rmSync(`${path}-shm`, { force: true }); +} + +export type RestoreResult = { preRestore: BackupResult; leads: number }; + +/** + * Подменить базу копией. Приём заявок на паузе с самого начала: заявка, пришедшая + * между снимком pre-restore и подменой, иначе пропала бы без следа. + */ +export async function restoreFromInspected({ + holder, + dbPath, + inspected, + now, + waitIdle, +}: { + holder: DbHolder; + dbPath: string; + inspected: InspectedBackup; + now: Date; + /** Дождаться фоновых записей в базу (очередь Telegram), прежде чем её закрыть. */ + waitIdle?: () => Promise; +}): Promise { + const dir = backupDirFor(dbPath); + const rollback = `${dbPath}.rollback`; + holder.paused = true; + try { + const preRestore = await createBackup(holder.db, dir, now, 'pre-restore'); + await waitIdle?.(); + + // От close до open — без await: никакой другой код не увидит закрытую базу. + holder.db.close(); + removeSidecars(dbPath); + renameSync(dbPath, rollback); + try { + renameSync(inspected.path, dbPath); + holder.db = openDb(dbPath); + } catch (err) { + rmSync(dbPath, { force: true }); + removeSidecars(dbPath); + renameSync(rollback, dbPath); + holder.db = openDb(dbPath); + throw err; + } + rmSync(rollback, { force: true }); + return { preRestore, leads: countLeads(holder.db) }; + } finally { + holder.paused = false; + discardInspected(inspected); + } +} + +export type Scheduler = { start(): void; stop(): void }; + +/** Ежедневно в 03:00 по Минску и сразу при старте, если сегодняшней копии ещё нет. */ +export function createBackupScheduler({ + run, + now, + hasToday, +}: { + run: () => Promise; + now: () => Date; + hasToday: () => boolean; +}): Scheduler { + let timer: NodeJS.Timeout | undefined; + let stopped = false; + + const safeRun = () => run().catch((err: unknown) => console.error('backup job failed', err)); + + function schedule() { + if (stopped) return; + const delay = Math.max(1000, nextDailyRun(now()).getTime() - now().getTime()); + timer = setTimeout(() => void safeRun().then(schedule), delay); + timer.unref(); + } + + return { + start() { + stopped = false; + if (!hasToday()) void safeRun(); + schedule(); + }, + stop() { + stopped = true; + if (timer) clearTimeout(timer); + }, + }; +} + +export function pluralLeads(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + const word = + mod10 === 1 && mod100 !== 11 + ? 'заявка' + : mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) + ? 'заявки' + : 'заявок'; + return `${n} ${word}`; +} + +const kb = (bytes: number) => `${Math.max(1, Math.round(bytes / 1024))} КБ`; + +/** Копия → ротация → файл администратору → строка в «Систему». Не бросает: только отчёт. */ +export async function runBackupAndReport({ + holder, + dir, + now, + client, + adminId, + system, +}: { + holder: DbHolder; + dir: string; + now: Date; + client?: Pick; + adminId?: number; + system: SystemReporter; +}): Promise { + let result: BackupResult; + try { + result = await createBackup(holder.db, dir, now, 'daily'); + rotateBackups(dir, now); + } catch (err) { + await system.report('backup failed', `Бэкап ${shortLabel(now)} не удался ❌\n${shortError(err)}`); + return undefined; + } + const summary = `Бэкап ${shortLabel(result.at)}: ${pluralLeads(result.leads)}, ${kb(result.bytes)}`; + let delivery = ''; + if (client && adminId !== undefined) { + try { + await client.sendDocument({ + chat_id: adminId, + file: { name: result.file, data: readFileSync(join(dir, result.file)) }, + caption: summary, + }); + } catch (err) { + delivery = `, но в личку не отправлен: ${err instanceof Error ? err.message : String(err)}`; + } + } + await system.report('backup', `${summary} ✅${delivery}`); + return result; +} diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index be737e0..140f506 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -5,6 +5,9 @@ import { MIGRATIONS } from './migrations.js'; export type Db = Database.Database; +/** Текущая база и пауза приёма. Восстановление подменяет db, поэтому все берут её отсюда, а не держат ссылку. */ +export type DbHolder = { db: Db; paused: boolean }; + export const DEFAULT_DB_PATH = './data/kvarta.sqlite'; /** Открывает базу и доводит схему до текущей версии. `:memory:` — для тестов. */ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e9bd156..77c12ed 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,9 +1,11 @@ import { serve } from '@hono/node-server'; import { createApp } from './app.js'; +import { backupDirFor, createBackupScheduler, hasDailyBackupToday, runBackupAndReport } from './backup.js'; import { loadConfig } from './config.js'; -import { openDb, type Db } from './db/index.js'; +import { openDb, type DbHolder } from './db/index.js'; import { noopNotifier, type Notifier } from './notifier.js'; import { createRateLimiter } from './rateLimit.js'; +import { createBackupCommands } from './telegram/backupCommands.js'; import { createBot, type Bot } from './telegram/bot.js'; import { createTelegramClient } from './telegram/client.js'; import { createOutbox, type Outbox } from './telegram/outbox.js'; @@ -15,37 +17,64 @@ const now = () => new Date(); const client = tg.token ? createTelegramClient({ token: tg.token }) : undefined; const system = createSystemReporter({ client, chatId: tg.chatId, threadId: tg.systemThreadId, now }); -let db: Db; +let holder: DbHolder; try { - db = openDb(config.dbPath); + holder = { db: openDb(config.dbPath), paused: false }; } catch (err) { // Best effort: база не открылась или миграция упала — сообщаем и выходим, docker перезапустит. await system.report('startup', `Сбой старта api ${config.version}: ${shortError(err)}`); process.exit(1); } +const getDb = () => holder.db; +const isPaused = () => holder.paused; +const backupDir = backupDirFor(config.dbPath); let outbox: Outbox | undefined; let bot: Bot | undefined; if (client) { - outbox = createOutbox({ getDb: () => db, client, chatId: tg.chatId, threadId: tg.leadsThreadId, now, system }); - bot = createBot({ client, telegram: tg, system }); + outbox = createOutbox({ getDb, isPaused, client, chatId: tg.chatId, threadId: tg.leadsThreadId, now, system }); + const backups = + tg.adminId !== undefined + ? createBackupCommands({ + client, + adminId: tg.adminId, + holder, + dbPath: config.dbPath, + now, + system, + waitIdle: outbox.idle, + }) + : undefined; + bot = createBot({ client, telegram: tg, system, backups }); } const notifier: Notifier = outbox ?? noopNotifier; +// Бэкапы делаем и без Telegram: копии на диске пригодятся при ручном восстановлении. +const scheduler = createBackupScheduler({ + now, + hasToday: () => hasDailyBackupToday(backupDir, now()), + run: async () => { + await runBackupAndReport({ holder, dir: backupDir, now: now(), client, adminId: tg.adminId, system }); + }, +}); + const app = createApp({ - db, + getDb, now, rateLimiter: createRateLimiter(), notifier, + isPaused, trustedProxies: config.trustedProxies, version: config.version, - onServerError: (err) => void system.report(`500 ${err instanceof Error ? err.message : ''}`.slice(0, 120), `500: ${shortError(err)}`), + onServerError: (err) => + void system.report(`500 ${err instanceof Error ? err.message : ''}`.slice(0, 120), `500: ${shortError(err)}`), }); const server = serve({ fetch: app.fetch, port: config.port }, (info) => { console.log(`api listening on :${info.port}`); outbox?.start(); bot?.start(); + scheduler.start(); void system.report('started', `api запущен, версия ${config.version}`); }); @@ -58,11 +87,12 @@ async function shutdown(signal: string) { console.error('не дождались остановки, выходим'); process.exit(1); }, 10_000).unref(); + scheduler.stop(); await bot?.stop(); await outbox?.stop(); // Сначала дожидаемся текущих запросов, потом закрываем базу — иначе запись оборвётся на полуслове. server.close(() => { - db.close(); + holder.db.close(); process.exit(0); }); } diff --git a/apps/api/src/telegram/backupCommands.test.ts b/apps/api/src/telegram/backupCommands.test.ts new file mode 100644 index 0000000..e39df9e --- /dev/null +++ b/apps/api/src/telegram/backupCommands.test.ts @@ -0,0 +1,218 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { validateLead } from '@kvarta/shared/lead'; +import { backupDirFor, createBackup, createBackupScheduler, listBackups } from '../backup.js'; +import { openDb, type DbHolder } from '../db/index.js'; +import { saveLead } from '../leads.js'; +import { validBody } from '../testing.js'; +import { createBackupCommands, CONFIRM_TTL_MS } from './backupCommands.js'; +import { createBot } from './bot.js'; +import type { TgUpdate } from './client.js'; +import { fakeTelegram } from './fake.js'; +import type { SystemReporter } from './system.js'; + +const ADMIN = 42; +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +// 18.10.2026 03:00 по Минску +const NIGHT = new Date('2026-10-18T00:00:00.000Z'); +let phone = 2000000; + +function setup() { + const root = mkdtempSync(join(tmpdir(), 'kvarta-bot-')); + dirs.push(root); + const dbPath = join(root, 'kvarta.sqlite'); + const holder: DbHolder = { db: openDb(dbPath), paused: false }; + const clock = { now: NIGHT }; + const uploads = new Map(); + const tg = fakeTelegram({ + getFile: (fileId: string) => ({ file_id: fileId, file_path: `documents/${fileId}` }), + downloadFile: ([path]: [string]) => uploads.get(path.replace('documents/', ''))!, + }); + const reports: string[] = []; + const system: SystemReporter = { report: async (_k, t) => void reports.push(t) }; + const backups = createBackupCommands({ client: tg.client, adminId: ADMIN, holder, dbPath, now: () => clock.now, system }); + const bot = createBot({ client: tg.client, telegram: { adminId: ADMIN }, system, backups }); + + const addLead = () => { + const r = validateLead(validBody({ phone: `+37529${phone++}` })); + if (!r.ok) throw new Error('invalid'); + saveLead(holder.db, r.data, { now: clock.now, userAgent: null }); + }; + const chat = { id: ADMIN, type: 'private' as const }; + const from = (id: number) => ({ id, is_bot: false, first_name: 'A' }); + const text = (t: string, fromId = ADMIN): TgUpdate => ({ + update_id: 1, + message: { message_id: 1, from: from(fromId), chat: { ...chat, id: fromId }, text: t }, + }); + const press = (data: string, fromId = ADMIN): TgUpdate => ({ + update_id: 1, + callback_query: { id: 'q', from: from(fromId), data, message: { message_id: 9, chat: { ...chat, id: fromId } } }, + }); + const lastText = () => tg.sent().at(-1)?.text as string; + const lastButtons = () => tg.sent().at(-1)?.reply_markup?.inline_keyboard.flat() as { text: string; callback_data: string }[]; + const edits = () => tg.sent('editMessageText').map((p) => p.text as string); + const leadIds = () => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id); + + return { tg, bot, holder, dbPath, clock, reports, uploads, addLead, text, press, lastText, lastButtons, edits, leadIds }; +} + +describe('бэкапы в личке администратора', () => { + it('/backups — кнопки с датой и числом заявок', async () => { + const s = setup(); + s.addLead(); + await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT); + s.addLead(); + await createBackup(s.holder.db, backupDirFor(s.dbPath), new Date(NIGHT.getTime() + 60_000), 'pre-restore'); + + await s.bot.handleUpdate(s.text('/backups')); + expect(s.lastButtons()).toEqual([ + { text: 'pre-restore · 18.10 03:01 · 2 заявки', callback_data: 'r:pre-restore-2026-10-18T0301.sqlite.gz' }, + { text: '18.10 03:00 · 1 заявка', callback_data: 'r:kvarta-2026-10-18T0300.sqlite.gz' }, + ]); + for (const b of s.lastButtons()) expect(Buffer.byteLength(b.callback_data)).toBeLessThanOrEqual(64); + }); + + it('/backup — копия сейчас, файлом в личку', async () => { + const s = setup(); + s.addLead(); + await s.bot.handleUpdate(s.text('/backup')); + expect(s.tg.sent('sendDocument')[0]).toMatchObject({ chat_id: ADMIN, file: { name: 'kvarta-2026-10-18T0300.sqlite.gz' } }); + expect(s.reports[0]).toMatch(/^Бэкап 18\.10 03:00: 1 заявка/); + }); + + it('кнопка копии → подтверждение с пропадающими заявками → восстановление', async () => { + const s = setup(); + s.addLead(); + await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT); + s.addLead(); + s.addLead(); + + await s.bot.handleUpdate(s.press('r:kvarta-2026-10-18T0300.sqlite.gz')); + expect(s.lastText()).toBe( + 'Восстановить базу из kvarta-2026-10-18T0300.sqlite.gz? Сейчас в базе 3 заявки, 2 из них новее этой копии и пропадут (#2, #3). Текущая база будет сохранена как pre-restore.', + ); + const [yes] = s.lastButtons(); + expect(yes).toMatchObject({ text: 'Восстановить' }); + expect(s.leadIds()).toEqual([1, 2, 3]); + + await s.bot.handleUpdate(s.press(yes!.callback_data)); + expect(s.leadIds()).toEqual([1]); + expect(s.lastText()).toMatch(/^База восстановлена из kvarta-2026-10-18T0300\.sqlite\.gz: 1 заявка\. Прежняя база сохранена как pre-restore-/); + expect(s.reports).toContain('База восстановлена из kvarta-2026-10-18T0300.sqlite.gz администратором'); + expect(listBackups(backupDirFor(s.dbPath)).some((b) => b.kind === 'pre-restore')).toBe(true); + + // Повторное нажатие той же кнопки ничего не делает. + await s.bot.handleUpdate(s.press(yes!.callback_data)); + expect(s.edits().at(-1)).toMatch(/устарело/); + }); + + it('отмена и истёкшее подтверждение не трогают базу', async () => { + const s = setup(); + s.addLead(); + await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT); + s.addLead(); + + await s.bot.handleUpdate(s.press('r:kvarta-2026-10-18T0300.sqlite.gz')); + await s.bot.handleUpdate(s.press(s.lastButtons()[1]!.callback_data)); + expect(s.edits().at(-1)).toBe('Восстановление отменено.'); + + await s.bot.handleUpdate(s.press('r:kvarta-2026-10-18T0300.sqlite.gz')); + const yes = s.lastButtons()[0]!.callback_data; + s.clock.now = new Date(NIGHT.getTime() + CONFIRM_TTL_MS + 1); + await s.bot.handleUpdate(s.press(yes)); + expect(s.edits().at(-1)).toMatch(/устарело/); + expect(s.leadIds()).toEqual([1, 2]); + }); + + it('присланный .sqlite.gz → подтверждение; битый файл — отказ', async () => { + const s = setup(); + s.addLead(); + const backup = await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT); + s.uploads.set('good', readFileSync(join(backupDirFor(s.dbPath), backup.file))); + s.uploads.set('bad', new Uint8Array([1, 2, 3])); + const doc = (fileId: string, name: string, size = 100): TgUpdate => ({ + update_id: 1, + message: { + message_id: 1, + from: { id: ADMIN, is_bot: false, first_name: 'A' }, + chat: { id: ADMIN, type: 'private' }, + document: { file_id: fileId, file_name: name, file_size: size }, + }, + }); + + await s.bot.handleUpdate(doc('good', 'kvarta-old.sqlite.gz')); + expect(s.lastText()).toMatch(/^Восстановить базу из kvarta-old\.sqlite\.gz\? Сейчас в базе 1 заявка, ни одна не пропадёт\./); + + await s.bot.handleUpdate(doc('bad', 'broken.sqlite.gz')); + expect(s.lastText()).toMatch(/^Из этой копии восстановить нельзя: Файл не распаковывается/); + + await s.bot.handleUpdate(doc('good', 'huge.sqlite.gz', 21 * 1024 * 1024)); + expect(s.lastText()).toMatch(/больше 20 МБ/); + await s.bot.handleUpdate(doc('good', 'notes.txt')); + expect(s.lastText()).toMatch(/\.sqlite\.gz/); + }); + + it('имя из callback_data не превращается в путь', async () => { + const s = setup(); + await s.bot.handleUpdate(s.press('r:../kvarta.sqlite')); + expect(s.lastText()).toMatch(/Этой копии уже нет/); + }); + + it('чужому — тишина; /backups в группе — тишина', async () => { + const s = setup(); + await s.bot.handleUpdate(s.text('/backups', 999)); + await s.bot.handleUpdate(s.press('r:x', 999)); + await s.bot.handleUpdate({ + update_id: 1, + message: { message_id: 1, from: { id: ADMIN, is_bot: false, first_name: 'A' }, chat: { id: -100, type: 'supergroup' }, text: '/backups' }, + }); + expect(s.tg.calls.filter((c) => c.method !== 'getMe')).toEqual([]); + }); +}); + +describe('планировщик бэкапов', () => { + it('при старте без сегодняшней копии — сразу, дальше в 03:00 по Минску', async () => { + vi.useFakeTimers({ now: new Date('2026-10-17T12:00:00Z') }); + try { + const runs: string[] = []; + const scheduler = createBackupScheduler({ + now: () => new Date(), + hasToday: () => runs.length > 0, + run: async () => void runs.push(new Date().toISOString()), + }); + scheduler.start(); + await vi.advanceTimersByTimeAsync(0); + expect(runs).toEqual(['2026-10-17T12:00:00.000Z']); + + await vi.advanceTimersByTimeAsync(12 * 60 * 60 * 1000); + expect(runs.at(-1)).toBe('2026-10-18T00:00:00.000Z'); + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + expect(runs.at(-1)).toBe('2026-10-19T00:00:00.000Z'); + scheduler.stop(); + await vi.advanceTimersByTimeAsync(48 * 60 * 60 * 1000); + expect(runs).toHaveLength(3); + } finally { + vi.useRealTimers(); + } + }); + + it('сегодняшняя копия есть — при старте не запускаем', async () => { + vi.useFakeTimers({ now: new Date('2026-10-17T12:00:00Z') }); + try { + let runs = 0; + const scheduler = createBackupScheduler({ now: () => new Date(), hasToday: () => true, run: async () => void runs++ }); + scheduler.start(); + await vi.advanceTimersByTimeAsync(1000); + expect(runs).toBe(0); + scheduler.stop(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/api/src/telegram/backupCommands.ts b/apps/api/src/telegram/backupCommands.ts new file mode 100644 index 0000000..68cd5c4 --- /dev/null +++ b/apps/api/src/telegram/backupCommands.ts @@ -0,0 +1,207 @@ +// Бэкапы в личке администратора: /backups, /backup, восстановление по кнопке или присланному файлу. +import { join } from 'node:path'; +import { + backupDirFor, + discardInspected, + inspectBackup, + leadsMissingFrom, + listBackups, + pluralLeads, + restoreFromInspected, + RestoreRejected, + runBackupAndReport, + shortLabel, + type InspectedBackup, +} from '../backup.js'; +import type { DbHolder } from '../db/index.js'; +import type { InlineKeyboard, TelegramClient, TgCallbackQuery, TgMessage } from './client.js'; +import { shortError, type SystemReporter } from './system.js'; + +export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; +export const CONFIRM_TTL_MS = 10 * 60 * 1000; +const LIST_LIMIT = 10; + +type Client = Pick< + TelegramClient, + 'sendMessage' | 'sendDocument' | 'answerCallbackQuery' | 'editMessageText' | 'getFile' | 'downloadFile' +>; + +type Pending = { inspected: InspectedBackup; label: string; expiresAt: number }; + +export type BackupCommands = ReturnType; + +export function createBackupCommands({ + client, + adminId, + holder, + dbPath, + now, + system, + waitIdle, +}: { + client: Client; + adminId: number; + holder: DbHolder; + dbPath: string; + now: () => Date; + system: SystemReporter; + waitIdle?: () => Promise; +}) { + const dir = backupDirFor(dbPath); + // Число заявок в копии ради подписи кнопки: файл копии не меняется, считаем один раз. + const leadsInFile = new Map(); + // В callback_data ≤ 64 байт: только короткий id, сама копия — здесь. + const pending = new Map(); + let seq = 0; + + const say = (text: string, reply_markup?: InlineKeyboard) => client.sendMessage({ chat_id: adminId, text, reply_markup }); + + function sweep() { + const t = now().getTime(); + for (const [id, p] of pending) { + if (p.expiresAt <= t) { + discardInspected(p.inspected); + pending.delete(id); + } + } + } + + async function countIn(file: string): Promise { + if (leadsInFile.has(file)) return leadsInFile.get(file); + try { + const inspected = await inspectBackup(join(dir, file), dir); + discardInspected(inspected); + leadsInFile.set(file, inspected.leads.length); + return inspected.leads.length; + } catch { + return undefined; + } + } + + async function listCommand() { + const backups = listBackups(dir).slice(0, LIST_LIMIT); + if (backups.length === 0) { + await say('Копий пока нет. /backup — сделать сейчас.'); + return; + } + const rows = []; + for (const b of backups) { + const leads = await countIn(b.file); + const text = [b.kind === 'pre-restore' ? 'pre-restore' : null, shortLabel(b.at), leads === undefined ? 'битая' : pluralLeads(leads)] + .filter(Boolean) + .join(' · '); + rows.push([{ text, callback_data: `r:${b.file}` }]); + } + await say('Последние копии базы. Нажмите на копию, чтобы восстановить из неё:', { inline_keyboard: rows }); + } + + async function backupCommand() { + const result = await runBackupAndReport({ holder, dir, now: now(), client, adminId, system }); + if (!result) await say('Не получилось сделать копию, подробности — в теме «Система».'); + } + + async function offerRestore(inspected: InspectedBackup, label: string) { + sweep(); + const missing = leadsMissingFrom(holder.db, inspected.leads); + const total = (holder.db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n; + const id = String(++seq); + pending.set(id, { inspected, label, expiresAt: now().getTime() + CONFIRM_TTL_MS }); + + const lost = + missing.length === 0 + ? 'ни одна не пропадёт' + : `${missing.length} из них новее этой копии и пропадут (${missing.map((n) => `#${n}`).join(', ')})`; + await say( + `Восстановить базу из ${label}? Сейчас в базе ${pluralLeads(total)}, ${lost}. Текущая база будет сохранена как pre-restore.`, + { + inline_keyboard: [ + [ + { text: 'Восстановить', callback_data: `y:${id}` }, + { text: 'Отмена', callback_data: `n:${id}` }, + ], + ], + }, + ); + } + + async function prepare(source: string | Uint8Array, label: string) { + try { + await offerRestore(await inspectBackup(source, dir), label); + } catch (err) { + if (err instanceof RestoreRejected) await say(`Из этой копии восстановить нельзя: ${err.message}`); + else throw err; + } + } + + async function confirm(id: string, query: TgCallbackQuery) { + sweep(); + const p = pending.get(id); + pending.delete(id); + const edit = (text: string) => + query.message + ? client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text }) + : say(text); + if (!p) { + await edit('Подтверждение устарело. Выберите копию заново: /backups'); + return; + } + await edit(`Восстанавливаю базу из ${p.label}…`); + try { + const result = await restoreFromInspected({ holder, dbPath, inspected: p.inspected, now: now(), waitIdle }); + await say( + `База восстановлена из ${p.label}: ${pluralLeads(result.leads)}. Прежняя база сохранена как ${result.preRestore.file}.`, + ); + await system.report('restore', `База восстановлена из ${p.label} администратором`); + } catch (err) { + await say(`Не удалось восстановить: ${err instanceof Error ? err.message : String(err)}. База осталась прежней.`); + await system.report('restore failed', `Восстановление из ${p.label} не удалось, база прежняя\n${shortError(err)}`); + } + } + + return { + /** true — команда наша и обработана. */ + async onCommand(command: string): Promise { + if (command === 'backups') await listCommand(); + else if (command === 'backup') await backupCommand(); + else return false; + return true; + }, + + async onDocument(msg: TgMessage) { + const doc = msg.document!; + const name = doc.file_name ?? 'файл'; + if (!name.endsWith('.sqlite.gz')) { + await say('Пришлите копию базы файлом .sqlite.gz.'); + return; + } + if ((doc.file_size ?? 0) > MAX_UPLOAD_BYTES) { + await say('Файл больше 20 МБ — бот такой скачать не может.'); + return; + } + const file = await client.getFile(doc.file_id); + if (!file.file_path) throw new Error('getFile не вернул file_path'); + await prepare(await client.downloadFile(file.file_path, MAX_UPLOAD_BYTES), name); + }, + + async onCallback(query: TgCallbackQuery) { + await client.answerCallbackQuery(query.id); + const data = query.data ?? ''; + const [action, arg = ''] = [data.slice(0, 1), data.slice(2)]; + if (action === 'r') { + // Имя проверяем по списку, а не склеиваем путь из callback_data: никакого ../ + const backup = listBackups(dir).find((b) => b.file === arg); + if (!backup) await say('Этой копии уже нет. /backups — свежий список.'); + else await prepare(join(dir, backup.file), backup.file); + } else if (action === 'y') { + await confirm(arg, query); + } else if (action === 'n') { + const p = pending.get(arg); + if (p) discardInspected(p.inspected); + pending.delete(arg); + if (query.message) { + await client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text: 'Восстановление отменено.' }); + } + } + }, + }; +} diff --git a/apps/api/src/telegram/bot.ts b/apps/api/src/telegram/bot.ts index cf96c72..1bbaa21 100644 --- a/apps/api/src/telegram/bot.ts +++ b/apps/api/src/telegram/bot.ts @@ -2,6 +2,7 @@ // (ADR 0002). Слушаемся только TELEGRAM_ADMIN_ID, остальным не отвечаем вовсе. import type { TelegramConfig } from '../config.js'; import { TelegramError, type TelegramClient, type TgMessage, type TgUpdate, type TgUser } from './client.js'; +import type { BackupCommands } from './backupCommands.js'; import { shortError, type SystemReporter } from './system.js'; export const LEADS_TOPIC = 'Kvarta Cafe: Заявки'; @@ -33,11 +34,13 @@ export function createBot({ client, telegram, system, + backups, retryPauseMs = RETRY_PAUSE_MS, }: { client: Client; telegram: TelegramConfig; system: SystemReporter; + backups?: Pick; retryPauseMs?: number; }): Bot { let me: TgUser | undefined; @@ -103,13 +106,27 @@ export function createBot({ await reply(msg, 'Темы созданы, настройки отправил администратору в личку.'); } + const isAdmin = (user?: TgUser) => telegram.adminId !== undefined && user?.id === telegram.adminId; + async function handleUpdate(update: TgUpdate) { + const query = update.callback_query; + if (query) { + if (isAdmin(query.from) && query.message?.chat.type === 'private') await backups?.onCallback(query); + return; + } const msg = update.message; - if (!msg?.from || telegram.adminId === undefined || msg.from.id !== telegram.adminId) return; + if (!msg || !isAdmin(msg.from)) return; + const isPrivate = msg.chat.type === 'private'; + if (isPrivate && msg.document) { + await backups?.onDocument(msg); + return; + } me ??= await client.getMe(); const cmd = parseCommand(msg.text, me?.username); if (!cmd) return; if (cmd.command === 'setup') await setup(msg, cmd.args[0] === 'force'); + // Бэкапы — только в личке: в группе их видели бы менеджеры. + else if (isPrivate) await backups?.onCommand(cmd.command); } const pause = (ms: number) => diff --git a/apps/api/src/telegram/outbox.test.ts b/apps/api/src/telegram/outbox.test.ts index 5f2931c..017f1c6 100644 --- a/apps/api/src/telegram/outbox.test.ts +++ b/apps/api/src/telegram/outbox.test.ts @@ -16,7 +16,7 @@ function lead(overrides: Parameters[0] = {}): ValidLead { return r.data; } -function setup({ failures = [] as unknown[], chatId = -100 as number | null } = {}) { +function setup({ failures = [] as unknown[], chatId = -100 as number | null, paused = { value: false } } = {}) { const db = openDb(':memory:'); const clock = { now: new Date('2026-10-17T12:00:00.000Z') }; const queue = [...failures]; @@ -29,7 +29,7 @@ function setup({ failures = [] as unknown[], chatId = -100 as number | null } = }); const reports: { kind: string; text: string }[] = []; const system: SystemReporter = { report: async (kind, text) => void reports.push({ kind, text }) }; - const outbox = createOutbox({ getDb: () => db, client: tg.client, chatId: chatId ?? undefined, threadId: 5, now: () => clock.now, system }); + const outbox = createOutbox({ getDb: () => db, client: tg.client, chatId: chatId ?? undefined, threadId: 5, now: () => clock.now, system, isPaused: () => paused.value }); const advance = (ms: number) => (clock.now = new Date(clock.now.getTime() + ms)); const save = (l: ValidLead) => saveLead(db, l, { now: clock.now, userAgent: null }); const row = (table: string, id = 1) => @@ -140,6 +140,17 @@ describe('outbox', () => { ]); }); + it('на паузе (восстановление базы) не отправляет', async () => { + const paused = { value: true }; + const { tg, outbox, save, row } = setup({ paused }); + save(lead()); + await outbox.tick(); + expect(tg.calls).toEqual([]); + paused.value = false; + await outbox.tick(); + expect(row('leads').tg_status).toBe('sent'); + }); + it('без TELEGRAM_CHAT_ID ничего не шлём, записи остаются pending', async () => { const { tg, outbox, save, row } = setup({ chatId: null }); save(lead()); diff --git a/apps/api/src/telegram/outbox.ts b/apps/api/src/telegram/outbox.ts index 05cbb2b..f54d692 100644 --- a/apps/api/src/telegram/outbox.ts +++ b/apps/api/src/telegram/outbox.ts @@ -29,6 +29,8 @@ export type Outbox = Notifier & { tick(): Promise; start(): void; stop(): Promise; + /** Дождаться текущей отправки: перед подменой базы. */ + idle(): Promise; }; /** Пауза перед попыткой n+1 после n неудач: 1, 2, 4… минуты. */ @@ -43,6 +45,7 @@ export function createOutbox({ threadId, now, system, + isPaused, intervalMs = TICK_MS, }: { getDb: () => Db; @@ -51,6 +54,7 @@ export function createOutbox({ threadId?: number; now: () => Date; system: SystemReporter; + isPaused?: () => boolean; intervalMs?: number; }): Outbox { const enabled = client !== undefined && chatId !== undefined; @@ -149,8 +153,10 @@ export function createOutbox({ async function run(): Promise { if (!enabled) return; - const db = getDb(); for (;;) { + // На время восстановления базу не трогаем; геттер — потому что после него база другая. + if (isPaused?.()) return; + const db = getDb(); const item = nextDue(db, now().toISOString()); if (!item) return; // После неудачи останавливаемся: остальное подождёт следующего тика, порядок не перемешается. @@ -174,6 +180,7 @@ export function createOutbox({ timer.unref(); void tick(); }, + idle: () => chain, async stop() { if (timer) clearInterval(timer); timer = undefined; diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts index 2743bda..7212b13 100644 --- a/apps/api/src/testing.ts +++ b/apps/api/src/testing.ts @@ -26,7 +26,7 @@ export function setup(overrides: Partial = {}) { const notifier: Notifier = { leadSaved: (e) => void events.push(e) }; // Лимит проверяется отдельно; в остальных тестах он бы мешал сериям запросов. const rateLimiter: RateLimiter = { hit: () => ({ ok: true }) }; - const app = createApp({ db, now: () => clock.now, rateLimiter, notifier, ...overrides }); + const app = createApp({ getDb: () => db, now: () => clock.now, rateLimiter, notifier, ...overrides }); /** socket — адрес, который увидел бы getConnInfo в node-server. */ const post = (body: unknown, headers: Record = {}, socket?: string) =>