15a03a77fd
Смена архитектуры с бизнес-бота на 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>
138 lines
4.4 KiB
JavaScript
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 '[не удалось расшифровать]';
|
|
}
|
|
}
|