Перевести проект на Telegram Business (без хранения сессии)
Смена архитектуры с MTProto-юзербота (GramJS + строка сессии) на официальное бизнес-подключение Telegram (Bot API). Проект больше НЕ хранит сессию — работает только через бота с включённым Business Mode, подключённого в Настройках → Telegram для бизнеса → Чат-боты. Это соответствует ToS. Изменения: - удалено: scripts/login.js, src/userbot/client.js, модуль onetime - src/userbot/ → src/business/: events.js подписывает business_message / edited / deleted / connection и нормализует сообщение; context.js даёт ctx.api = bot.api - core/media.js: скачивание через getFile + fetch вместо GramJS - core/db.js: составной ключ PRIMARY KEY (chat_id, msg_id) — в Bot API message_id уникален только внутри чата - bot/panel.js: барьер владельца пропускает бизнес-апдейты (у них ctx.from это собеседник); убран UI одноразовых - index.js: сборка только на grammY, явный allowed_updates с бизнес-типами - config.js: убраны API_ID/API_HASH/SESSION, проверки на старте почищены - package.json: убраны зависимости telegram и input, скрипт login - README и .env.example переписаны под бизнес-подключение Границы: onetime невозможен через Business API (сервер отбрасывает self-destruct до доставки боту), медиа >~20 МБ не выгружаются (лимит getFile), перехват — только личные 1-на-1 чаты. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+17
-7
@@ -7,7 +7,17 @@ import { logger } from '../core/logger.js';
|
||||
import { readMedia } from '../core/media.js';
|
||||
|
||||
const PAGE = 5;
|
||||
const KIND_TITLE = { deleted: '🗑 Удалённые', onetime: '👁 Одноразовые' };
|
||||
const KIND_TITLE = { deleted: '🗑 Удалённые' };
|
||||
|
||||
/** Бизнес-апдейт от подключённого аккаунта: у него ctx.from — собеседник, не владелец. */
|
||||
function isBusinessUpdate(ctx) {
|
||||
return Boolean(
|
||||
ctx.businessMessage ??
|
||||
ctx.editedBusinessMessage ??
|
||||
ctx.deletedBusinessMessages ??
|
||||
ctx.businessConnection,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Компаньон-бот — единственный интерфейс юзербота. Всё управление и все
|
||||
@@ -17,9 +27,11 @@ const KIND_TITLE = { deleted: '🗑 Удалённые', onetime: '👁 Одно
|
||||
export function createBot() {
|
||||
const bot = new Bot(config.botToken());
|
||||
|
||||
// Жёсткий барьер: чужие апдейты не обрабатываем вообще.
|
||||
// Жёсткий барьер: панельные апдейты обрабатываем только от владельца.
|
||||
// Бизнес-апдейты (business_message/deleted/…) пропускаем — у них ctx.from
|
||||
// это собеседник, а не владелец; их разбирает wireBusiness отдельно.
|
||||
bot.use(async (ctx, next) => {
|
||||
if (isOwner(ctx.from?.id)) return next();
|
||||
if (isBusinessUpdate(ctx) || isOwner(ctx.from?.id)) return next();
|
||||
logger.debug(`bot: игнорирую чужой апдейт от ${ctx.from?.id}`);
|
||||
});
|
||||
|
||||
@@ -45,9 +57,7 @@ export function createBot() {
|
||||
|
||||
function mainMenu() {
|
||||
return new InlineKeyboard()
|
||||
.text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0')
|
||||
.row()
|
||||
.text(`👁 Одноразовые (${countCaptures('onetime')})`, 'feed:onetime:0');
|
||||
.text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0');
|
||||
}
|
||||
|
||||
function feedKeyboard(kind, page, items, total) {
|
||||
@@ -140,7 +150,7 @@ async function edit(ctx, text, keyboard) {
|
||||
*/
|
||||
export async function notifyCapture(bot, item) {
|
||||
const kb = new InlineKeyboard().text('Открыть', `item:${item.id}`);
|
||||
const head = item.kind === 'onetime' ? '👁 Одноразовое медиа' : '🗑 Удалённое сообщение';
|
||||
const head = '🗑 Удалённое сообщение';
|
||||
const text = `${head} от ${item.sender || 'неизвестно'}\n${item.media_kind ? `[${item.media_kind}] ` : ''}${truncate(item.text ?? '', 60)}`;
|
||||
try {
|
||||
await bot.api.sendMessage(config.ownerId(), text, { reply_markup: kb });
|
||||
|
||||
@@ -4,16 +4,15 @@ import { notifyCapture } from '../bot/panel.js';
|
||||
/**
|
||||
* Контекст передаётся в каждый хук модуля. Это единственная точка, через которую
|
||||
* модуль что-то делает: сохраняет перехват, уведомляет владельца, качает медиа.
|
||||
* Модуль не знает ни про grammY, ни про схему БД — только про этот интерфейс.
|
||||
* Модуль не знает про схему БД — только про этот интерфейс.
|
||||
*/
|
||||
export function createContext({ client, bot, me, registry }) {
|
||||
export function createContext({ bot, registry }) {
|
||||
return {
|
||||
client, // MTProto-клиент (GramJS)
|
||||
me, // объект себя
|
||||
api: bot.api, // Bot API (grammY) — для скачивания медиа через getFile
|
||||
registry,
|
||||
|
||||
/**
|
||||
* Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted' | 'onetime'.
|
||||
* Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted'.
|
||||
* data: { chatId, senderId, sender, text, mediaKind, mediaPath }
|
||||
*/
|
||||
async capture(kind, data) {
|
||||
@@ -0,0 +1,70 @@
|
||||
import { isOwner } from '../config.js';
|
||||
import { detectMediaFromBusiness } from '../core/media.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { runHook } from '../core/registry.js';
|
||||
|
||||
/**
|
||||
* Подписывает бота на бизнес-апдейты Telegram и раздаёт их модулям через хуки.
|
||||
* Работает через официальное бизнес-подключение (Настройки → Telegram для
|
||||
* бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённого аккаунта.
|
||||
*
|
||||
* Событие удаления (deleted_business_messages) несёт только id сообщений и чат —
|
||||
* без содержимого. Поэтому модуль cache складывает каждое входящее заранее,
|
||||
* а antidelete достаёт его из кэша по (chatId, msgId).
|
||||
*/
|
||||
export function wireBusiness(bot, registry, ctx) {
|
||||
bot.on('business_message', (gctx) =>
|
||||
runHook(registry, 'onMessage', normalizeBusinessMessage(gctx), ctx),
|
||||
);
|
||||
|
||||
bot.on('edited_business_message', (gctx) =>
|
||||
runHook(registry, 'onEdited', normalizeBusinessMessage(gctx), ctx),
|
||||
);
|
||||
|
||||
bot.on('deleted_business_messages', (gctx) => {
|
||||
const d = gctx.deletedBusinessMessages;
|
||||
return runHook(
|
||||
registry,
|
||||
'onDeleted',
|
||||
{ chatId: d.chat?.id, msgIds: d.message_ids ?? [] },
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
bot.on('business_connection', (gctx) => {
|
||||
const c = gctx.businessConnection;
|
||||
if (c?.is_enabled) {
|
||||
logger.info(`Бизнес-подключение активно (id ${c.id})`);
|
||||
} else {
|
||||
logger.warn(`Бизнес-подключение отключено владельцем (id ${c?.id}). Перехват приостановлен.`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('бизнес-апдейты подписаны: message / edited / deleted / connection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит grammY-контекст бизнес-сообщения к плоскому виду, понятному модулям.
|
||||
* isPrivateIncoming — входящее в личном 1-на-1 чате, не от самого владельца.
|
||||
*/
|
||||
export function normalizeBusinessMessage(gctx) {
|
||||
const msg = gctx.businessMessage ?? gctx.editedBusinessMessage ?? gctx.msg;
|
||||
const from = msg?.from;
|
||||
const chat = msg?.chat;
|
||||
return {
|
||||
chatId: chat?.id,
|
||||
msgId: msg?.message_id,
|
||||
senderId: from?.id,
|
||||
sender: senderLabel(from),
|
||||
text: msg?.text ?? msg?.caption ?? '',
|
||||
media: detectMediaFromBusiness(msg),
|
||||
// Свои исходящие отсекаем через isOwner (как раньше через message.out).
|
||||
isPrivateIncoming: chat?.type === 'private' && !isOwner(from?.id),
|
||||
};
|
||||
}
|
||||
|
||||
function senderLabel(from) {
|
||||
if (!from) return '';
|
||||
const name = [from.first_name, from.last_name].filter(Boolean).join(' ');
|
||||
return from.username ? `${name} (@${from.username})`.trim() : name;
|
||||
}
|
||||
+6
-15
@@ -13,17 +13,13 @@ const num = (name) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Конфиг MTProto-юзербота. api_id/api_hash берутся на https://my.telegram.org,
|
||||
* строка сессии генерируется скриптом login (npm run login) и кладётся в .env.
|
||||
* Всё, что чувствительно (сессия, ключ), живёт только в .env и никуда не уходит.
|
||||
* Конфиг бота. Работает через официальное бизнес-подключение Telegram —
|
||||
* никакой строки сессии, только токен бота от @BotFather. Всё чувствительное
|
||||
* (токен, ключ шифрования) живёт только в .env и никуда не уходит.
|
||||
*/
|
||||
export const config = {
|
||||
// Доступ к MTProto.
|
||||
apiId: () => num('API_ID'),
|
||||
apiHash: () => req('API_HASH'),
|
||||
session: process.env.SESSION ?? '',
|
||||
|
||||
// Компаньон-бот (@BotFather) — единственный UI юзербота.
|
||||
// Бот-панель (@BotFather). К нему же владелец подключает бизнес-аккаунт
|
||||
// (Настройки → Telegram для бизнеса → Чат-боты). Единственный UI.
|
||||
botToken: () => req('BOT_TOKEN'),
|
||||
// Telegram id владельца: только он управляет ботом и получает перехваты.
|
||||
ownerId: () => num('OWNER_ID'),
|
||||
@@ -35,7 +31,7 @@ export const config = {
|
||||
retentionDays: Number(process.env.RETENTION_DAYS ?? 30),
|
||||
|
||||
// Качать ли медиа входящих сразу (нужно, чтобы восстанавливать удалённые
|
||||
// картинки/видео — при удалении ссылка на файл уже мертва). Ест диск.
|
||||
// картинки/видео — при удалении file_id уже не приходит). Ест диск.
|
||||
cacheMedia: process.env.CACHE_MEDIA !== '0',
|
||||
|
||||
dataDir: new URL('../data/', import.meta.url),
|
||||
@@ -48,17 +44,12 @@ export const mediaPath = () => fileURLToPath(config.mediaDir);
|
||||
|
||||
/** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */
|
||||
export function assertConfig() {
|
||||
config.apiId();
|
||||
config.apiHash();
|
||||
config.botToken();
|
||||
config.ownerId();
|
||||
const key = config.encryptionKey();
|
||||
if (Buffer.from(key, key.match(/^[0-9a-f]+$/i) ? 'hex' : 'utf8').length < 32) {
|
||||
throw new Error('ENCRYPTION_KEY должен быть не короче 32 байт (64 hex-символа).');
|
||||
}
|
||||
if (!config.session) {
|
||||
throw new Error('SESSION пуст. Выполните: npm run login — и вставьте строку в .env.');
|
||||
}
|
||||
}
|
||||
|
||||
export const isOwner = (id) => Number(id) === config.ownerId();
|
||||
|
||||
+10
-7
@@ -14,21 +14,22 @@ let db = null;
|
||||
export function openDb() {
|
||||
if (db) return db;
|
||||
mkdirSync(dataPath(), { recursive: true });
|
||||
db = new DatabaseSync(`${dataPath()}/userbot.db`);
|
||||
db = new DatabaseSync(`${dataPath()}/business.db`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
msg_id INTEGER PRIMARY KEY,
|
||||
msg_id INTEGER,
|
||||
chat_id TEXT,
|
||||
sender_id TEXT,
|
||||
sender TEXT,
|
||||
text_enc TEXT,
|
||||
media_kind TEXT,
|
||||
media_path TEXT,
|
||||
created_at INTEGER
|
||||
created_at INTEGER,
|
||||
PRIMARY KEY (chat_id, msg_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS captures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT, -- 'deleted' | 'onetime'
|
||||
kind TEXT, -- 'deleted'
|
||||
chat_id TEXT,
|
||||
sender_id TEXT,
|
||||
sender TEXT,
|
||||
@@ -65,12 +66,14 @@ export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind,
|
||||
);
|
||||
}
|
||||
|
||||
export function getCachedMessage(msgId) {
|
||||
const row = openDb().prepare('SELECT * FROM messages WHERE msg_id = ?').get(msgId);
|
||||
export function getCachedMessage(chatId, msgId) {
|
||||
const row = openDb()
|
||||
.prepare('SELECT * FROM messages WHERE chat_id = ? AND msg_id = ?')
|
||||
.get(String(chatId ?? ''), msgId);
|
||||
return row ? decodeRow(row) : null;
|
||||
}
|
||||
|
||||
/** Записываем факт перехвата (удаление или one-time) — это и есть лента в боте. */
|
||||
/** Записываем факт перехвата (удалённое сообщение) — это и есть лента в боте. */
|
||||
export function saveCapture(kind, data) {
|
||||
const info = openDb()
|
||||
.prepare(
|
||||
|
||||
+34
-35
@@ -6,51 +6,50 @@ import { config, mediaPath } from '../config.js';
|
||||
import { encrypt } from './crypto.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** Определяем тип медиа по объекту сообщения GramJS. */
|
||||
export function detectMedia(message) {
|
||||
if (!message?.media) return null;
|
||||
if (message.photo) return 'photo';
|
||||
if (message.video) return 'video';
|
||||
if (message.voice) return 'voice';
|
||||
if (message.videoNote) return 'video_note';
|
||||
if (message.gif) return 'gif';
|
||||
if (message.audio) return 'audio';
|
||||
if (message.sticker) return 'sticker';
|
||||
if (message.document) return 'document';
|
||||
return 'media';
|
||||
/**
|
||||
* Определяет тип медиа и file_id по объекту сообщения Bot API (grammY).
|
||||
* Возвращает { kind, fileId } либо null, если медиа нет.
|
||||
* У фото берём самый крупный размер (последний в массиве photo).
|
||||
*/
|
||||
export function detectMediaFromBusiness(msg) {
|
||||
if (!msg) return null;
|
||||
if (msg.photo?.length) return { kind: 'photo', fileId: msg.photo.at(-1).file_id };
|
||||
if (msg.video) return { kind: 'video', fileId: msg.video.file_id };
|
||||
if (msg.voice) return { kind: 'voice', fileId: msg.voice.file_id };
|
||||
if (msg.video_note) return { kind: 'video_note', fileId: msg.video_note.file_id };
|
||||
if (msg.animation) return { kind: 'gif', fileId: msg.animation.file_id };
|
||||
if (msg.audio) return { kind: 'audio', fileId: msg.audio.file_id };
|
||||
if (msg.sticker) return { kind: 'sticker', fileId: msg.sticker.file_id };
|
||||
if (msg.document) return { kind: 'document', fileId: msg.document.file_id };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Одноразовые медиа помечены ttlSeconds на самом медиа. Такие файлы Telegram
|
||||
* стирает у клиента после просмотра — их важно скачать в момент прихода.
|
||||
* Скачивает файл по file_id через Bot API, шифрует и кладёт на диск.
|
||||
* Возвращает имя файла (как хранится в БД) либо null.
|
||||
* getFile отдаёт файлы до ~20 МБ — на бо́льших вернётся ошибка, её ловим.
|
||||
* Качать нужно в момент прихода: при удалении file_id уже не приходит.
|
||||
*/
|
||||
export function isOneTime(message) {
|
||||
const media = message?.media;
|
||||
const ttl = media?.ttlSeconds ?? media?.photo?.ttlSeconds ?? media?.document?.ttlSeconds;
|
||||
return Boolean(ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает медиа сообщения, шифрует и кладёт на диск. Возвращает
|
||||
* { kind, path } либо null. Имя файла не раскрывает содержимого.
|
||||
*/
|
||||
export async function downloadEncrypted(client, message, tag = 'm') {
|
||||
const kind = detectMedia(message);
|
||||
if (!kind) return null;
|
||||
|
||||
export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') {
|
||||
try {
|
||||
const buffer = await client.downloadMedia(message, {});
|
||||
if (!buffer || !buffer.length) return { kind, path: null };
|
||||
const file = await api.getFile(fileId); // { file_path, ... } или бросит на >20 МБ
|
||||
if (!file?.file_path) return null;
|
||||
|
||||
const url = `https://api.telegram.org/file/bot${config.botToken()}/${file.file_path}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (!buffer.length) return null;
|
||||
|
||||
mkdirSync(mediaPath(), { recursive: true });
|
||||
const name = `${tag}_${message.id ?? 'x'}_${buffer.length}.enc`;
|
||||
const name = `${tag}_${msgId}_${buffer.length}.enc`;
|
||||
const full = fileURLToPath(new URL(name, config.mediaDir));
|
||||
await writeFile(full, encrypt(buffer));
|
||||
logger.debug(`media: сохранено ${kind} -> ${name} (${buffer.length} б)`);
|
||||
return { kind, path: name };
|
||||
logger.debug(`media: сохранено -> ${name} (${buffer.length} б)`);
|
||||
return name;
|
||||
} catch (err) {
|
||||
logger.warn(`media: не удалось скачать ${kind}:`, err.message);
|
||||
return { kind, path: null };
|
||||
logger.warn(`media: не удалось скачать файл (${tag}):`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-24
@@ -4,9 +4,18 @@ import { openDb, purgeOld } from './core/db.js';
|
||||
import { loadModules } from './core/loader.js';
|
||||
import { logger } from './core/logger.js';
|
||||
import { runHook } from './core/registry.js';
|
||||
import { createUserbot, startUserbot } from './userbot/client.js';
|
||||
import { createContext } from './userbot/context.js';
|
||||
import { wireEvents } from './userbot/events.js';
|
||||
import { createContext } from './business/context.js';
|
||||
import { wireBusiness } from './business/events.js';
|
||||
|
||||
// Бизнес-апдейты Telegram по умолчанию не приходят — их нужно запросить явно.
|
||||
const ALLOWED_UPDATES = [
|
||||
'message',
|
||||
'callback_query',
|
||||
'business_connection',
|
||||
'business_message',
|
||||
'edited_business_message',
|
||||
'deleted_business_messages',
|
||||
];
|
||||
|
||||
// 1. Конфиг и БД — падаем понятной ошибкой, если что-то не задано.
|
||||
try {
|
||||
@@ -17,32 +26,27 @@ try {
|
||||
}
|
||||
openDb();
|
||||
|
||||
// 2. Юзербот (MTProto): подключаемся уже готовой сессией.
|
||||
const client = createUserbot();
|
||||
let me;
|
||||
try {
|
||||
me = await startUserbot(client);
|
||||
} catch (err) {
|
||||
logger.error('Не удалось подключить юзербот. Проверьте SESSION/API_ID/API_HASH.', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Компаньон-бот (единственный UI) — запускаем в фоне.
|
||||
// 2. Бот (единственный UI и приёмник бизнес-апдейтов) — запускаем в фоне.
|
||||
const bot = createBot();
|
||||
bot.start({ onStart: ({ username }) => logger.info(`Панель поднята: @${username}`) }).catch((err) => {
|
||||
logger.error('Компаньон-бот не запустился (проверьте BOT_TOKEN):', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
bot
|
||||
.start({
|
||||
allowed_updates: ALLOWED_UPDATES,
|
||||
onStart: ({ username }) => logger.info(`Панель поднята: @${username}`),
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Бот не запустился (проверьте BOT_TOKEN):', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 4. Модули: грузим и раздаём им события юзербота.
|
||||
// 3. Модули: грузим и раздаём им бизнес-апдейты через хуки.
|
||||
const registry = await loadModules();
|
||||
const ctx = createContext({ client, bot, me, registry });
|
||||
const ctx = createContext({ bot, registry });
|
||||
await runHook(registry, 'setup', undefined, ctx);
|
||||
wireEvents(client, registry, ctx);
|
||||
wireBusiness(bot, registry, ctx);
|
||||
|
||||
logger.info(`Готово. Активных модулей: ${registry.list().map((m) => m.name).join(', ')}`);
|
||||
|
||||
// 5. Периодическая чистка по сроку хранения.
|
||||
// 4. Периодическая чистка по сроку хранения.
|
||||
if (config.retentionDays > 0) {
|
||||
const timer = setInterval(() => {
|
||||
const { messages, captures } = purgeOld();
|
||||
@@ -51,12 +55,11 @@ if (config.retentionDays > 0) {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
// 6. Аккуратная остановка.
|
||||
// 5. Аккуратная остановка.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => {
|
||||
logger.info('Останавливаюсь…');
|
||||
await bot.stop().catch(() => {});
|
||||
await client.disconnect().catch(() => {});
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,21 +2,20 @@ import { getCachedMessage } from '../core/db.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* Ловит удаления. MTProto в событии удаления присылает ТОЛЬКО id сообщений —
|
||||
* без содержимого и (в личных чатах) без чата. Поэтому восстанавливаем из кэша,
|
||||
* который наполняет модуль [cache]. Нашли в кэше — значит это было входящее
|
||||
* в личке: сохраняем как перехват и уведомляем владельца.
|
||||
* Ловит удаления. Telegram в событии deleted_business_messages присылает ТОЛЬКО
|
||||
* id сообщений и чат — без содержимого. Поэтому восстанавливаем из кэша, который
|
||||
* наполняет модуль [cache], по паре (chatId, msgId). Нашли — значит это было
|
||||
* входящее в личке: сохраняем как перехват и уведомляем владельца.
|
||||
*/
|
||||
export default {
|
||||
name: 'antidelete',
|
||||
description: 'Сохраняет удалённые собеседником сообщения и медиа',
|
||||
|
||||
async onDeleted(event, ctx) {
|
||||
const ids = event.deletedIds ?? [];
|
||||
if (!ids.length) return;
|
||||
async onDeleted({ chatId, msgIds }, ctx) {
|
||||
if (!msgIds?.length) return;
|
||||
|
||||
for (const id of ids) {
|
||||
const cached = getCachedMessage(id);
|
||||
for (const id of msgIds) {
|
||||
const cached = getCachedMessage(chatId, id);
|
||||
if (!cached) continue; // не наше/не кэшировали — пропускаем
|
||||
|
||||
await ctx.capture('deleted', {
|
||||
|
||||
+16
-29
@@ -1,50 +1,37 @@
|
||||
import { config } from '../config.js';
|
||||
import { cacheMessage } from '../core/db.js';
|
||||
import { downloadEncrypted, isOneTime } from '../core/media.js';
|
||||
import { isPrivateIncoming } from '../userbot/events.js';
|
||||
import { downloadEncrypted } from '../core/media.js';
|
||||
|
||||
/**
|
||||
* Фундамент для antidelete: кладёт каждое входящее из личных диалогов в кэш.
|
||||
* При удалении сообщение уже недоступно у Telegram — восстановить его можно
|
||||
* только из этого кэша. Поэтому текст сохраняем всегда, а медиа — сразу качаем
|
||||
* (если включён CACHE_MEDIA), потому что после удаления ссылка на файл мертва.
|
||||
* При удалении Telegram присылает только id (без содержимого) — восстановить
|
||||
* сообщение можно лишь из этого кэша. Поэтому текст сохраняем всегда, а медиа —
|
||||
* сразу качаем (если включён CACHE_MEDIA): при удалении file_id уже не приходит.
|
||||
*/
|
||||
export default {
|
||||
name: 'cache',
|
||||
description: 'Кэширует входящие личные сообщения — основа для antidelete',
|
||||
|
||||
async onMessage(message, ctx) {
|
||||
if (!isPrivateIncoming(message, ctx.me)) return;
|
||||
|
||||
// Одноразовые ведёт отдельный модуль onetime — тут их не трогаем,
|
||||
// чтобы не качать дважды.
|
||||
if (isOneTime(message)) return;
|
||||
async onMessage(msg, ctx) {
|
||||
if (!msg.isPrivateIncoming) 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'; // знаем, что медиа было, но файл не кэшируем
|
||||
if (msg.media && config.cacheMedia) {
|
||||
mediaKind = msg.media.kind;
|
||||
mediaPath = await downloadEncrypted(ctx.api, msg.media.fileId, 'cache', msg.msgId);
|
||||
} else if (msg.media) {
|
||||
mediaKind = msg.media.kind; // знаем, что медиа было, но файл не кэшируем
|
||||
}
|
||||
|
||||
const sender = await message.getSender().catch(() => null);
|
||||
cacheMessage({
|
||||
msgId: message.id,
|
||||
chatId: message.chatId,
|
||||
senderId: message.senderId,
|
||||
sender: senderLabel(sender),
|
||||
text: message.text ?? '',
|
||||
msgId: msg.msgId,
|
||||
chatId: msg.chatId,
|
||||
senderId: msg.senderId,
|
||||
sender: msg.sender,
|
||||
text: msg.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;
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { TelegramClient } from 'telegram';
|
||||
import { StringSession } from 'telegram/sessions/index.js';
|
||||
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* Создаёт (но не подключает) MTProto-клиент из строки сессии в .env.
|
||||
* Логин делается один раз скриптом scripts/login.js — здесь сессия уже готова.
|
||||
*/
|
||||
export function createUserbot(session = config.session) {
|
||||
const client = new TelegramClient(new StringSession(session), config.apiId(), config.apiHash(), {
|
||||
connectionRetries: 5,
|
||||
autoReconnect: true,
|
||||
// Юзербот молчит в чужих чатах — нам нужны только апдейты, не рассылки.
|
||||
floodSleepThreshold: 60,
|
||||
});
|
||||
client.setLogLevel('error');
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function startUserbot(client) {
|
||||
await client.connect();
|
||||
const me = await client.getMe();
|
||||
logger.info(`Юзербот подключён как ${me.username ? '@' + me.username : me.firstName} (id ${me.id})`);
|
||||
return me;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { NewMessage } from 'telegram/events/NewMessage.js';
|
||||
import { DeletedMessage } from 'telegram/events/DeletedMessage.js';
|
||||
import { EditedMessage } from 'telegram/events/EditedMessage.js';
|
||||
|
||||
import { logger } from '../core/logger.js';
|
||||
import { runHook } from '../core/registry.js';
|
||||
|
||||
/**
|
||||
* Подписывает клиент на апдметы и раздаёт их модулям через хуки.
|
||||
* Мы слушаем всё, но модули сами решают, что делать (обычно — только личку).
|
||||
*/
|
||||
export function wireEvents(client, registry, ctx) {
|
||||
client.addEventHandler(
|
||||
(event) => runHook(registry, 'onMessage', event.message, ctx),
|
||||
new NewMessage({}),
|
||||
);
|
||||
|
||||
client.addEventHandler(
|
||||
(event) => runHook(registry, 'onEdited', event.message, ctx),
|
||||
new EditedMessage({}),
|
||||
);
|
||||
|
||||
client.addEventHandler(
|
||||
(event) => runHook(registry, 'onDeleted', event, ctx),
|
||||
new DeletedMessage({}),
|
||||
);
|
||||
|
||||
logger.debug('события юзербота подписаны: new / edited / deleted');
|
||||
}
|
||||
|
||||
/** Личный диалог один-на-один (не группа, не канал, не свои Избранные). */
|
||||
export function isPrivateIncoming(message, me) {
|
||||
if (!message) return false;
|
||||
if (message.out) return false; // исходящие от себя не перехватываем
|
||||
const cls = message.peerId?.className;
|
||||
return cls === 'PeerUser' && String(message.senderId) !== String(me?.id ?? '');
|
||||
}
|
||||
Reference in New Issue
Block a user