import { mkdirSync } from 'node:fs'; import { DatabaseSync } from 'node:sqlite'; import { config, dataPath } from '../config.js'; import { encryptString, decryptString } from './crypto.js'; import { logger } from './logger.js'; let db = null; /** * Локальный индекс на node:sqlite. Тексты храним зашифрованными (AES-GCM), * бинарные медиа лежат отдельными файлами в data/media, здесь только путь. */ export function openDb() { if (db) return db; mkdirSync(dataPath(), { recursive: true }); db = new DatabaseSync(`${dataPath()}/userbot.db`); db.exec(` CREATE TABLE IF NOT EXISTS messages ( msg_id INTEGER PRIMARY KEY, chat_id TEXT, sender_id TEXT, sender TEXT, text_enc TEXT, media_kind TEXT, media_path TEXT, created_at INTEGER ); CREATE TABLE IF NOT EXISTS captures ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, -- 'deleted' | 'onetime' chat_id TEXT, sender_id TEXT, sender TEXT, text_enc TEXT, media_kind TEXT, media_path TEXT, captured_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_captures_time ON captures(captured_at DESC); `); logger.debug('sqlite: база готова'); return db; } const now = () => Math.floor(Date.now() / 1000); /** Кладём каждое входящее в кольцевой кэш — источник для восстановления удалённых. */ export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind, mediaPath }) { openDb() .prepare( `INSERT OR REPLACE INTO messages (msg_id, chat_id, sender_id, sender, text_enc, media_kind, media_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( msgId, String(chatId ?? ''), String(senderId ?? ''), sender ?? '', text ? encryptString(text) : null, mediaKind ?? null, mediaPath ?? null, now(), ); } export function getCachedMessage(msgId) { const row = openDb().prepare('SELECT * FROM messages WHERE msg_id = ?').get(msgId); return row ? decodeRow(row) : null; } /** Записываем факт перехвата (удаление или one-time) — это и есть лента в боте. */ export function saveCapture(kind, data) { const info = openDb() .prepare( `INSERT INTO captures (kind, chat_id, sender_id, sender, text_enc, media_kind, media_path, captured_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( kind, String(data.chatId ?? ''), String(data.senderId ?? ''), data.sender ?? '', data.text ? encryptString(data.text) : null, data.mediaKind ?? null, data.mediaPath ?? null, now(), ); return Number(info.lastInsertRowid); } export function listCaptures({ kind, limit = 10, offset = 0 } = {}) { const where = kind ? 'WHERE kind = ?' : ''; const args = kind ? [kind, limit, offset] : [limit, offset]; return openDb() .prepare(`SELECT * FROM captures ${where} ORDER BY captured_at DESC LIMIT ? OFFSET ?`) .all(...args) .map(decodeRow); } export function getCapture(id) { const row = openDb().prepare('SELECT * FROM captures WHERE id = ?').get(id); return row ? decodeRow(row) : null; } export function countCaptures(kind) { const where = kind ? 'WHERE kind = ?' : ''; const args = kind ? [kind] : []; return openDb().prepare(`SELECT COUNT(*) AS n FROM captures ${where}`).get(...args).n; } /** Чистка по retentionDays — вызывается по таймеру из index.js. */ export function purgeOld() { if (!config.retentionDays) return { messages: 0, captures: 0 }; const cutoff = now() - config.retentionDays * 86400; const m = openDb().prepare('DELETE FROM messages WHERE created_at < ?').run(cutoff); const c = openDb().prepare('DELETE FROM captures WHERE captured_at < ?').run(cutoff); return { messages: Number(m.changes), captures: Number(c.changes) }; } function decodeRow(row) { return { ...row, text: row.text_enc ? safeDecrypt(row.text_enc) : null, }; } function safeDecrypt(value) { try { return decryptString(value); } catch (err) { logger.warn('sqlite: не удалось расшифровать текст записи', err.message); return '[не удалось расшифровать]'; } }