feat(api): ежедневный бэкап, копия в личку, восстановление через бота

backup.ts: db.backup() → gzip в backups/ рядом с базой, имя по времени
Минска (Intl), 14 ежедневных и pre-restore на 30 дней; планировщик
в 03:00 по Минску и при старте, если сегодня копии нет; файл админу,
итог в «Систему». Восстановление: gunzip → integrity_check → версия
схемы → пауза приёма (503) → pre-restore → атомарная подмена файла →
миграции; при сбое возвращаем прежнюю базу. createApp и очередь берут
базу через геттер, чтобы увидеть подменённую.
Бот в личке: /backups с кнопками, /backup, присланный .sqlite.gz,
подтверждение со списком пропадающих заявок, живёт 10 минут.
This commit is contained in:
2026-10-17 16:07:26 +03:00
parent 667c6a6e6d
commit 249b4bb627
11 changed files with 1103 additions and 15 deletions
+3 -2
View File
@@ -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);
+223
View File
@@ -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);
});
});
+371
View File
@@ -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<BackupResult> {
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<InspectedBackup> {
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<void>;
}): Promise<RestoreResult> {
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<void>;
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<TelegramClient, 'sendDocument'>;
adminId?: number;
system: SystemReporter;
}): Promise<BackupResult | undefined> {
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;
}
+3
View File
@@ -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:` — для тестов. */
+38 -8
View File
@@ -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);
});
}
@@ -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<string, Uint8Array>();
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();
}
});
});
+207
View File
@@ -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<typeof createBackupCommands>;
export function createBackupCommands({
client,
adminId,
holder,
dbPath,
now,
system,
waitIdle,
}: {
client: Client;
adminId: number;
holder: DbHolder;
dbPath: string;
now: () => Date;
system: SystemReporter;
waitIdle?: () => Promise<void>;
}) {
const dir = backupDirFor(dbPath);
// Число заявок в копии ради подписи кнопки: файл копии не меняется, считаем один раз.
const leadsInFile = new Map<string, number>();
// В callback_data ≤ 64 байт: только короткий id, сама копия — здесь.
const pending = new Map<string, Pending>();
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<number | undefined> {
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<boolean> {
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: 'Восстановление отменено.' });
}
}
},
};
}
+18 -1
View File
@@ -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<BackupCommands, 'onCommand' | 'onDocument' | 'onCallback'>;
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) =>
+13 -2
View File
@@ -16,7 +16,7 @@ function lead(overrides: Parameters<typeof validBody>[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());
+8 -1
View File
@@ -29,6 +29,8 @@ export type Outbox = Notifier & {
tick(): Promise<void>;
start(): void;
stop(): Promise<void>;
/** Дождаться текущей отправки: перед подменой базы. */
idle(): Promise<void>;
};
/** Пауза перед попыткой 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<void> {
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;
+1 -1
View File
@@ -26,7 +26,7 @@ export function setup(overrides: Partial<AppDeps> = {}) {
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<string, string> = {}, socket?: string) =>