Переписать проект в личный 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:
2026-08-09 19:16:52 +03:00
parent e92e0df644
commit 15a03a77fd
40 changed files with 1845 additions and 922 deletions
+33
View File
@@ -0,0 +1,33 @@
import { getCachedMessage } from '../core/db.js';
import { logger } from '../core/logger.js';
/**
* Ловит удаления. MTProto в событии удаления присылает ТОЛЬКО id сообщений —
* без содержимого и (в личных чатах) без чата. Поэтому восстанавливаем из кэша,
* который наполняет модуль [cache]. Нашли в кэше — значит это было входящее
* в личке: сохраняем как перехват и уведомляем владельца.
*/
export default {
name: 'antidelete',
description: 'Сохраняет удалённые собеседником сообщения и медиа',
async onDeleted(event, ctx) {
const ids = event.deletedIds ?? [];
if (!ids.length) return;
for (const id of ids) {
const cached = getCachedMessage(id);
if (!cached) continue; // не наше/не кэшировали — пропускаем
await ctx.capture('deleted', {
chatId: cached.chat_id,
senderId: cached.sender_id,
sender: cached.sender,
text: cached.text,
mediaKind: cached.media_kind,
mediaPath: cached.media_path,
});
logger.info(`antidelete: восстановлено удалённое сообщение ${id} от ${cached.sender || '?'}`);
}
},
};
+50
View File
@@ -0,0 +1,50 @@
import { config } from '../config.js';
import { cacheMessage } from '../core/db.js';
import { downloadEncrypted, isOneTime } from '../core/media.js';
import { isPrivateIncoming } from '../userbot/events.js';
/**
* Фундамент для antidelete: кладёт каждое входящее из личных диалогов в кэш.
* При удалении сообщение уже недоступно у Telegram — восстановить его можно
* только из этого кэша. Поэтому текст сохраняем всегда, а медиа — сразу качаем
* (если включён CACHE_MEDIA), потому что после удаления ссылка на файл мертва.
*/
export default {
name: 'cache',
description: 'Кэширует входящие личные сообщения — основа для antidelete',
async onMessage(message, ctx) {
if (!isPrivateIncoming(message, ctx.me)) return;
// Одноразовые ведёт отдельный модуль onetime — тут их не трогаем,
// чтобы не качать дважды.
if (isOneTime(message)) return;
let mediaKind = null;
let mediaPath = null;
if (message.media && config.cacheMedia) {
const saved = await downloadEncrypted(ctx.client, message, 'cache');
mediaKind = saved?.kind ?? null;
mediaPath = saved?.path ?? null;
} else if (message.media) {
mediaKind = 'media'; // знаем, что медиа было, но файл не кэшируем
}
const sender = await message.getSender().catch(() => null);
cacheMessage({
msgId: message.id,
chatId: message.chatId,
senderId: message.senderId,
sender: senderLabel(sender),
text: message.text ?? '',
mediaKind,
mediaPath,
});
},
};
function senderLabel(sender) {
if (!sender) return '';
const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ');
return sender.username ? `${name} (@${sender.username})`.trim() : name;
}
+37
View File
@@ -0,0 +1,37 @@
import { downloadEncrypted, isOneTime } from '../core/media.js';
import { logger } from '../core/logger.js';
import { isPrivateIncoming } from '../userbot/events.js';
/**
* Одноразовые фото/видео (ttl-медиа) Telegram стирает сразу после просмотра.
* Ловим их в момент прихода: качаем файл, шифруем, сохраняем как перехват.
* Работает только по входящим в личке — свои и групповые не трогаем.
*/
export default {
name: 'onetime',
description: 'Сохраняет одноразовые фото и видео',
async onMessage(message, ctx) {
if (!isPrivateIncoming(message, ctx.me)) return;
if (!isOneTime(message)) return;
const saved = await downloadEncrypted(ctx.client, message, 'onetime');
const sender = await message.getSender().catch(() => null);
await ctx.capture('onetime', {
chatId: message.chatId,
senderId: message.senderId,
sender: senderLabel(sender),
text: message.text ?? '',
mediaKind: saved?.kind ?? 'media',
mediaPath: saved?.path ?? null,
});
logger.info(`onetime: сохранено одноразовое ${saved?.kind ?? 'медиа'} от ${senderLabel(sender) || '?'}`);
},
};
function senderLabel(sender) {
if (!sender) return '';
const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ');
return sender.username ? `${name} (@${sender.username})`.trim() : name;
}