Перевести проект на 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:
2026-08-09 21:00:22 +03:00
parent 0d1af5b50c
commit 8bd13b4dd5
18 changed files with 265 additions and 1174 deletions
+17 -7
View File
@@ -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) {
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
}
+8 -9
View File
@@ -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
View File
@@ -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;
}
-37
View File
@@ -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;
}
-27
View File
@@ -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;
}
-37
View File
@@ -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 ?? '');
}