Переписать проект в личный 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>
This commit is contained in:
+137
@@ -0,0 +1,137 @@
|
||||
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 '[не удалось расшифровать]';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user