Files
arestools/src/core/db.js
T
ros 15a03a77fd Переписать проект в личный MTProto-юзербот
Смена архитектуры с бизнес-бота на Bot API (команды со слешем) на
личный юзербот на GramJS/MTProto с управлением через компаньон-бота.

Возможности:
- antidelete — кэш входящих в личке, восстановление удалённых «у всех»
- onetime — перехват «просмотр один раз» фото/видео до истечения ссылки
- локальное хранилище node:sqlite + шифрование AES-256-GCM, чистка по сроку
- компаньон-бот (grammY): инлайн-панель, лента с пагинацией, выдача медиа,
  доступ только владельцу по OWNER_ID

Устройство:
- core/ — crypto, db, media, loader, registry, logger
- userbot/ — MTProto-клиент из строки сессии, подписка на события
- modules/ — фичи как модули с хуками setup/onMessage/onEdited/onDeleted
- bot/ — компаньон-бот (единственный UI)
- scripts/login.js — генерация строки сессии

Удалён старый код Bot API (commands/, handlers/, middlewares/, dispatcher,
store). Обновлены README и .env.example. Hcrgram/ (сторонний референс)
добавлен в .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 19:16:52 +03:00

138 lines
4.4 KiB
JavaScript

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 '[не удалось расшифровать]';
}
}