Переписать проект в личный 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
-63
View File
@@ -1,63 +0,0 @@
import { Bot, GrammyError, HttpError } from 'grammy';
import { assertConfig, config } from './config.js';
import { createDispatcher } from './core/dispatcher.js';
import { loadCommands } from './core/loader.js';
import { logger } from './core/logger.js';
import { connections, handovers } from './core/store.js';
import { registerConnectionHandlers } from './handlers/connection.js';
import { fallback } from './handlers/fallback.js';
import { businessContext } from './middlewares/business.js';
import { handover } from './middlewares/handover.js';
/** Без явного allowed_updates Telegram не пришлёт business-апдейты. */
export const ALLOWED_UPDATES = [
'message',
'edited_message',
'callback_query',
'business_connection',
'business_message',
'edited_business_message',
'deleted_business_messages',
];
export async function createBot() {
assertConfig();
await Promise.all([connections.load(), handovers.load()]);
const registry = await loadCommands();
const bot = new Bot(config.token);
bot.catch(({ error, ctx }) => {
const where = `update ${ctx?.update?.update_id}`;
if (error instanceof GrammyError) {
logger.error(`${where}: Telegram отклонил запрос — ${error.description}`);
} else if (error instanceof HttpError) {
logger.error(`${where}: сеть недоступна`, error);
} else {
logger.error(`${where}: необработанная ошибка`, error);
}
});
bot.use(businessContext());
// Подключение/отключение бота к бизнес-аккаунту — до пайплайна сообщений.
registerConnectionHandlers(bot);
bot.on(['message', 'business_message'], handover(), createDispatcher(registry), fallback());
return { bot, registry };
}
export async function syncCommands(bot, registry) {
const commands = registry.forTelegram();
await bot.api.setMyCommands(commands);
logger.info(`Меню команд обновлено: ${commands.map((c) => `/${c.command}`).join(' ') || '—'}`);
}
export async function shutdown(bot) {
logger.info('Останавливаюсь…');
await bot.stop();
await Promise.all([connections.flush(), handovers.flush()]);
logger.info('Состояние сохранено.');
}
+150
View File
@@ -0,0 +1,150 @@
import { Bot, InlineKeyboard, InputFile } from 'grammy';
import { config, isOwner } from '../config.js';
import { decrypt } from '../core/crypto.js';
import { countCaptures, getCapture, listCaptures } from '../core/db.js';
import { logger } from '../core/logger.js';
import { readMedia } from '../core/media.js';
const PAGE = 5;
const KIND_TITLE = { deleted: '🗑 Удалённые', onetime: '👁 Одноразовые' };
/**
* Компаньон-бот — единственный интерфейс юзербота. Всё управление и все
* перехваты живут здесь, в личке владельца. Никаких слеш-команд снаружи:
* бот отвечает только владельцу (OWNER_ID), любому другому — молчит.
*/
export function createBot() {
const bot = new Bot(config.botToken());
// Жёсткий барьер: чужие апдейты не обрабатываем вообще.
bot.use(async (ctx, next) => {
if (isOwner(ctx.from?.id)) return next();
logger.debug(`bot: игнорирую чужой апдейт от ${ctx.from?.id}`);
});
bot.command('start', (ctx) => ctx.reply('Панель юзербота.', { reply_markup: mainMenu() }));
bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель юзербота.', mainMenu()));
// Списки перехватов с пагинацией.
bot.callbackQuery(/^feed:(\w+):(\d+)$/, async (ctx) => {
const [, kind, pageRaw] = ctx.match;
await renderFeed(ctx, kind, Number(pageRaw));
await ctx.answerCallbackQuery();
});
// Открыть одну запись — с медиа, если оно есть.
bot.callbackQuery(/^item:(\d+)$/, async (ctx) => {
await renderItem(ctx, Number(ctx.match[1]));
await ctx.answerCallbackQuery();
});
bot.catch((err) => logger.error('bot: ошибка обработки', err.error ?? err));
return bot;
}
function mainMenu() {
return new InlineKeyboard()
.text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0')
.row()
.text(`👁 Одноразовые (${countCaptures('onetime')})`, 'feed:onetime:0');
}
function feedKeyboard(kind, page, items, total) {
const kb = new InlineKeyboard();
for (const it of items) {
kb.text(`${previewLine(it)}`, `item:${it.id}`).row();
}
const nav = [];
if (page > 0) nav.push(['◀', `feed:${kind}:${page - 1}`]);
if ((page + 1) * PAGE < total) nav.push(['▶', `feed:${kind}:${page + 1}`]);
nav.forEach(([label, data]) => kb.text(label, data));
kb.row().text('🏠 В меню', 'home');
return kb;
}
async function renderFeed(ctx, kind, page) {
const total = countCaptures(kind);
const items = listCaptures({ kind, limit: PAGE, offset: page * PAGE });
const title = KIND_TITLE[kind] ?? kind;
const body = items.length
? `${title} — стр. ${page + 1}/${Math.max(1, Math.ceil(total / PAGE))}`
: `${title}: пусто.`;
await edit(ctx, body, feedKeyboard(kind, page, items, total));
}
async function renderItem(ctx, id) {
const item = getCapture(id);
if (!item) return edit(ctx, 'Запись не найдена (возможно, удалена по сроку хранения).', backTo('home'));
const caption = formatCapture(item);
const back = backTo(`feed:${item.kind}:0`);
if (item.media_path) {
try {
const buffer = decrypt(await readMedia(item.media_path));
await ctx.replyWithDocument(new InputFile(buffer, fileName(item)), { caption, reply_markup: back });
return;
} catch (err) {
logger.warn('bot: не удалось отдать медиа', err.message);
return ctx.reply(`${caption}\n\n⚠️ Медиа не удалось расшифровать.`, { reply_markup: back });
}
}
await edit(ctx, caption, back);
}
// ——— helpers ———
const backTo = (data) => new InlineKeyboard().text('◀ Назад', data);
function previewLine(item) {
const who = item.sender || 'неизвестно';
const what = item.media_kind ? `[${item.media_kind}]` : truncate(item.text ?? '', 30) || '—';
return `${who}: ${what}`;
}
function formatCapture(item) {
const when = new Date(item.captured_at * 1000).toLocaleString('ru-RU');
const lines = [
`${KIND_TITLE[item.kind] ?? item.kind}`,
`От: ${item.sender || 'неизвестно'}`,
`Когда: ${when}`,
];
if (item.media_kind) lines.push(`Медиа: ${item.media_kind}`);
if (item.text) lines.push('', item.text);
return lines.join('\n');
}
function fileName(item) {
const ext = { photo: 'jpg', video: 'mp4', voice: 'ogg', gif: 'mp4', audio: 'mp3' }[item.media_kind] ?? 'bin';
return `${item.kind}_${item.id}.${ext}`;
}
const truncate = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
async function edit(ctx, text, keyboard) {
try {
if (ctx.callbackQuery) {
await ctx.editMessageText(text, { reply_markup: keyboard });
} else {
await ctx.reply(text, { reply_markup: keyboard });
}
} catch (err) {
if (!String(err.description ?? err).includes('message is not modified')) throw err;
}
}
/**
* Присылает владельцу уведомление о свежем перехвате. Дёргается из модулей
* через контекст (ctx.notify) — модуль не знает деталей бота.
*/
export async function notifyCapture(bot, item) {
const kb = new InlineKeyboard().text('Открыть', `item:${item.id}`);
const head = item.kind === 'onetime' ? '👁 Одноразовое медиа' : '🗑 Удалённое сообщение';
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 });
} catch (err) {
logger.warn('bot: не удалось уведомить владельца', err.message);
}
}
-26
View File
@@ -1,26 +0,0 @@
/**
* Шаблон команды. Файлы, чьё имя начинается с `_`, загрузчик пропускает,
* поэтому этот файл можно скопировать и переименовать — например, в price.js.
*
* Поля:
* command — имя без слэша, обязательное
* aliases — дополнительные триггеры (можно кириллицей)
* description — строка для /help и setMyCommands
* scope — 'all' | 'private' (только личка с ботом) | 'business' (только бизнес-чаты)
* adminOnly — доступно владельцу бизнес-аккаунта и ID из ADMIN_IDS
* hidden — не показывать в /help и не отправлять в setMyCommands
* handler — (ctx, args, { registry, rest }) => Promise<void>
*/
export default {
command: 'template',
aliases: [],
description: 'Пример команды',
scope: 'all',
adminOnly: false,
hidden: true,
async handler(ctx, args, { rest }) {
// args — массив слов после команды, rest — та же строка целиком.
await ctx.reply(args.length ? `Аргументы: ${args.join(', ')}` : `Команда без аргументов (rest: "${rest}")`);
},
};
-28
View File
@@ -1,28 +0,0 @@
import { config } from '../../config.js';
export default {
command: 'pause',
aliases: ['пауза'],
description: 'Остановить автоответы в этом чате: /pause [минуты | off]',
scope: 'business',
adminOnly: true,
async handler(ctx, args) {
const [arg] = args;
if (arg === 'off' || arg === 'выкл') {
ctx.business.resume();
await ctx.reply('Автоответы снова включены в этом чате.');
return;
}
const minutes = arg ? Number(arg) : config.handoverPauseMs / 60_000;
if (!Number.isFinite(minutes) || minutes <= 0) {
await ctx.reply('Нужно число минут или "off". Например: /pause 60');
return;
}
ctx.business.pause(minutes * 60_000);
await ctx.reply(`Молчу в этом чате ${minutes} мин. Вернуть раньше — /pause off`);
},
};
-40
View File
@@ -1,40 +0,0 @@
import { connections } from '../../core/store.js';
const RIGHTS_TO_SHOW = [
['can_reply', 'отвечать'],
['can_read_messages', 'читать переписку'],
['can_delete_outgoing_messages', 'удалять свои сообщения'],
['can_edit_name', 'менять имя'],
['can_view_gifts_and_stars', 'видеть подарки и звёзды'],
['can_manage_stories', 'управлять историями'],
];
export default {
command: 'status',
description: 'Состояние бизнес-подключений',
scope: 'all',
adminOnly: true,
async handler(ctx) {
const all = connections.entries();
if (!all.length) {
await ctx.reply(
'Активных подключений нет.\n\nВключите Business Mode у бота в @BotFather, затем добавьте бота в Settings → Telegram Business → Chatbots.',
);
return;
}
const blocks = all.map(([id, record]) => {
const rights = RIGHTS_TO_SHOW.filter(([key]) => record.rights?.[key]).map(([, label]) => label);
return [
`<b>Подключение</b> <code>${id}</code>`,
`владелец: <code>${record.ownerId}</code>${record.ownerUsername ? ` (@${record.ownerUsername})` : ''}`,
`активно: ${record.isEnabled ? 'да' : 'нет'}`,
`права: ${rights.length ? rights.join(', ') : 'не переданы'}`,
].join('\n');
});
await ctx.reply(blocks.join('\n\n'), { parse_mode: 'HTML' });
},
};
-23
View File
@@ -1,23 +0,0 @@
import { isOperator } from '../middlewares/business.js';
export default {
command: 'help',
aliases: ['помощь', 'справка'],
description: 'Список команд',
scope: 'all',
async handler(ctx, args, { registry }) {
const operator = isOperator(ctx);
// Админские команды показываем только тем, кто может их вызвать.
const available = registry.visible().filter((cmd) => operator || !cmd.adminOnly);
const lines = available.map((cmd) => {
const aliases = cmd.aliases.length ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
const mark = cmd.adminOnly ? ' 🔒' : '';
return `/${cmd.command}${aliases}${cmd.description}${mark}`;
});
await ctx.reply(lines.length ? `Команды:\n${lines.join('\n')}` : 'Команд пока нет.');
},
};
-23
View File
@@ -1,23 +0,0 @@
export default {
command: 'id',
aliases: ['whoami'],
description: 'Показать ID пользователя и чата',
scope: 'all',
async handler(ctx) {
const lines = [
`Ваш ID: <code>${ctx.from?.id}</code>`,
`Чат: <code>${ctx.chat?.id}</code> (${ctx.chat?.type})`,
];
if (ctx.business) {
lines.push(
`Подключение: <code>${ctx.business.connectionId}</code>`,
`Владелец: <code>${ctx.business.ownerId ?? 'неизвестен'}</code>`,
`Вы владелец: ${ctx.business.isOwner ? 'да' : 'нет'}`,
);
}
await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
},
};
-17
View File
@@ -1,17 +0,0 @@
export default {
command: 'ping',
description: 'Проверка живости и задержки апдейта',
scope: 'all',
async handler(ctx) {
// Telegram отдаёт date в секундах, поэтому точность лага — около секунды.
const sentAt = (ctx.msg?.date ?? 0) * 1000;
const lagMs = sentAt ? Date.now() - sentAt : null;
const lines = ['pong'];
if (lagMs !== null) lines.push(`задержка апдейта: ~${lagMs} мс`);
lines.push(`uptime: ${Math.round(process.uptime())} с`);
await ctx.reply(lines.join('\n'));
},
};
-28
View File
@@ -1,28 +0,0 @@
export default {
command: 'start',
aliases: ['старт'],
description: 'Приветствие и краткая справка',
scope: 'all',
async handler(ctx, args, { registry }) {
const name = ctx.from?.first_name ?? 'там';
// В бизнес-чате бот пишет от имени владельца, поэтому текст другой:
// клиент не должен видеть, что общается с ботом-помощником.
if (ctx.business) {
await ctx.reply(
`Здравствуйте, ${name}! Спасибо за обращение — уже смотрю ваш вопрос и скоро вернусь с ответом.`,
);
return;
}
const commands = registry
.visible()
.map((cmd) => `/${cmd.command}${cmd.description}`)
.join('\n');
await ctx.reply(
`Привет, ${name}. Я бот-помощник для Telegram Business.\n\nДоступные команды:\n${commands}`,
);
},
};
+56 -18
View File
@@ -1,26 +1,64 @@
const parseIds = (raw) =>
(raw ?? '')
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map(Number)
.filter((id) => Number.isInteger(id));
import { fileURLToPath } from 'node:url';
export const config = {
token: process.env.BOT_TOKEN ?? '',
adminIds: parseIds(process.env.ADMIN_IDS),
handoverPauseMs: Number(process.env.HANDOVER_PAUSE_MINUTES ?? 30) * 60_000,
logLevel: process.env.LOG_LEVEL ?? 'info',
dataDir: new URL('../data/', import.meta.url),
const req = (name) => {
const value = process.env[name];
if (!value) throw new Error(`${name} не задан в .env (см. .env.example).`);
return value;
};
const num = (name) => {
const value = Number(process.env[name]);
if (!Number.isInteger(value)) throw new Error(`${name} должен быть целым числом.`);
return value;
};
/**
* Конфиг MTProto-юзербота. api_id/api_hash берутся на https://my.telegram.org,
* строка сессии генерируется скриптом login (npm run login) и кладётся в .env.
* Всё, что чувствительно (сессия, ключ), живёт только в .env и никуда не уходит.
*/
export const config = {
// Доступ к MTProto.
apiId: () => num('API_ID'),
apiHash: () => req('API_HASH'),
session: process.env.SESSION ?? '',
// Компаньон-бот (@BotFather) — единственный UI юзербота.
botToken: () => req('BOT_TOKEN'),
// Telegram id владельца: только он управляет ботом и получает перехваты.
ownerId: () => num('OWNER_ID'),
// Ключ шифрования локального хранилища (32+ байта, hex или ascii).
encryptionKey: () => req('ENCRYPTION_KEY'),
// Сколько дней держать перехваченное, прежде чем чистить (0 — бессрочно).
retentionDays: Number(process.env.RETENTION_DAYS ?? 30),
// Качать ли медиа входящих сразу (нужно, чтобы восстанавливать удалённые
// картинки/видео — при удалении ссылка на файл уже мертва). Ест диск.
cacheMedia: process.env.CACHE_MEDIA !== '0',
dataDir: new URL('../data/', import.meta.url),
mediaDir: new URL('../data/media/', import.meta.url),
logLevel: process.env.LOG_LEVEL ?? 'info',
};
export const dataPath = () => fileURLToPath(config.dataDir);
export const mediaPath = () => fileURLToPath(config.mediaDir);
/** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */
export function assertConfig() {
if (!config.token) {
throw new Error('BOT_TOKEN не задан. Скопируйте .env.example в .env и вставьте токен от @BotFather.');
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 (!Number.isFinite(config.handoverPauseMs) || config.handoverPauseMs < 0) {
throw new Error('HANDOVER_PAUSE_MINUTES должен быть неотрицательным числом.');
if (!config.session) {
throw new Error('SESSION пуст. Выполните: npm run login — и вставьте строку в .env.');
}
}
export const isAdmin = (userId) => config.adminIds.includes(userId);
export const isOwner = (id) => Number(id) === config.ownerId();
+47
View File
@@ -0,0 +1,47 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
import { config } from '../config.js';
const ALGORITHM = 'aes-256-gcm';
const IV_LEN = 12;
const TAG_LEN = 16;
let keyCache = null;
/**
* Ключ из ENCRYPTION_KEY. Принимаем как 64-символьный hex (готовые 32 байта),
* так и произвольную парольную фразу — её растягиваем scrypt до 32 байт.
*/
function getKey() {
if (keyCache) return keyCache;
const raw = config.encryptionKey();
if (/^[0-9a-f]{64}$/i.test(raw)) {
keyCache = Buffer.from(raw, 'hex');
} else {
keyCache = scryptSync(raw, 'telegrambusiness/v1', 32);
}
return keyCache;
}
/**
* Шифрует буфер. Формат на выходе: [iv(12)][tag(16)][ciphertext].
* Так весь артефакт самодостаточен — расшифровка не требует внешних метаданных.
*/
export function encrypt(plaintext) {
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const data = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), data]);
}
export function decrypt(payload) {
const iv = payload.subarray(0, IV_LEN);
const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN);
const data = payload.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]);
}
export const encryptString = (text) => encrypt(Buffer.from(text, 'utf8')).toString('base64');
export const decryptString = (b64) => decrypt(Buffer.from(b64, 'base64')).toString('utf8');
+137
View File
@@ -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 '[не удалось расшифровать]';
}
}
-68
View File
@@ -1,68 +0,0 @@
import { isAdmin } from '../config.js';
import { logger } from './logger.js';
/**
* Разбирает команду из ctx.msg — то есть и из `message`, и из `business_message`.
* Родной bot.command() в grammY смотрит только на message/channel_post,
* поэтому в бизнес-чатах он молчит.
*/
export function parseCommand(ctx) {
const msg = ctx.msg;
const text = msg?.text ?? msg?.caption;
if (!text || !text.startsWith('/')) return null;
const entities = msg.entities ?? msg.caption_entities ?? [];
const entity = entities.find((e) => e.type === 'bot_command' && e.offset === 0);
// Латинские команды Telegram размечает сам. Для алиасов вроде "/старт"
// разметки не будет, поэтому режем текст руками.
const rawLength = entity?.length ?? (text.split(/\s/, 1)[0]?.length ?? 0);
const raw = text.slice(1, rawLength);
if (!raw) return null;
const [name, target] = raw.split('@');
if (target && target.toLowerCase() !== ctx.me?.username?.toLowerCase()) return null;
const rest = text.slice(rawLength).trim();
return {
name: name.toLowerCase(),
rest,
args: rest.length ? rest.split(/\s+/) : [],
};
}
const inScope = (scope, ctx) => {
if (scope === 'all') return true;
if (scope === 'business') return Boolean(ctx.businessConnectionId);
return !ctx.businessConnectionId && ctx.chat?.type === 'private';
};
/**
* @param {import('./registry.js').CommandRegistry} registry
*/
export function createDispatcher(registry) {
return async (ctx, next) => {
// На правках сообщений команды не выполняем, иначе один /start сработает дважды.
if (ctx.editedMessage || ctx.editedBusinessMessage) return next();
const parsed = parseCommand(ctx);
if (!parsed) return next();
const command = registry.resolve(parsed.name);
if (!command) return next();
if (!inScope(command.scope, ctx)) {
logger.debug(`/${parsed.name}: вне scope "${command.scope}", пропускаем`);
return next();
}
const allowed = !command.adminOnly || isAdmin(ctx.from?.id) || ctx.business?.isOwner === true;
if (!allowed) {
logger.warn(`/${parsed.name}: отказано пользователю ${ctx.from?.id}`);
return;
}
logger.info(`/${command.command} от ${ctx.from?.id}${ctx.businessConnectionId ? ' (business)' : ''}`);
await command.handler(ctx, parsed.args, { registry, rest: parsed.rest });
};
}
+11 -11
View File
@@ -3,30 +3,30 @@ import { join, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { logger } from './logger.js';
import { CommandRegistry } from './registry.js';
import { ModuleRegistry } from './registry.js';
const COMMANDS_DIR = fileURLToPath(new URL('../commands/', import.meta.url));
const MODULES_DIR = fileURLToPath(new URL('../modules/', import.meta.url));
/**
* Забирает все *.js из src/commands (включая подпапки) и складывает в реестр.
* Забирает все *.js из src/modules (включая подпапки) в реестр.
* Файлы, начинающиеся с `_`, игнорируются — удобно для черновиков и хелперов.
*/
export async function loadCommands(dir = COMMANDS_DIR) {
const registry = new CommandRegistry();
export async function loadModules(dir = MODULES_DIR) {
const registry = new ModuleRegistry();
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.js') && !entry.name.startsWith('_'))
.map((entry) => join(entry.parentPath ?? entry.path, entry.name))
.filter((e) => e.isFile() && e.name.endsWith('.js') && !e.name.startsWith('_'))
.map((e) => join(e.parentPath ?? e.path, e.name))
.sort();
for (const file of files) {
const source = relative(dir, file).split(sep).join('/');
const module = await import(pathToFileURL(file).href);
registry.register(module.default, source);
logger.debug(`команда загружена: ${source}`);
const mod = await import(pathToFileURL(file).href);
registry.register(mod.default, source);
logger.debug(`модуль загружен: ${source}`);
}
logger.info(`Загружено команд: ${registry.list().length}`);
logger.info(`Загружено модулей: ${registry.list().length}`);
return registry;
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { config } from '../config.js';
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
const threshold = LEVELS[config.logLevel] ?? LEVELS.info;
const threshold = () => LEVELS[config.logLevel] ?? LEVELS.info;
const write = (level, stream, args) => {
if (LEVELS[level] < threshold) return;
if (LEVELS[level] < threshold()) return;
stream(`[${new Date().toISOString()}] ${level.toUpperCase()}`, ...args);
};
+60
View File
@@ -0,0 +1,60 @@
import { mkdirSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
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';
}
/**
* Одноразовые медиа помечены ttlSeconds на самом медиа. Такие файлы Telegram
* стирает у клиента после просмотра — их важно скачать в момент прихода.
*/
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;
try {
const buffer = await client.downloadMedia(message, {});
if (!buffer || !buffer.length) return { kind, path: null };
mkdirSync(mediaPath(), { recursive: true });
const name = `${tag}_${message.id ?? 'x'}_${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 };
} catch (err) {
logger.warn(`media: не удалось скачать ${kind}:`, err.message);
return { kind, path: null };
}
}
/** Читает зашифрованный медиа-файл с диска по имени (как хранится в БД). */
export function readMedia(name) {
return readFile(fileURLToPath(new URL(name, config.mediaDir)));
}
+66 -63
View File
@@ -1,84 +1,87 @@
export const SCOPES = ['all', 'private', 'business'];
import { logger } from './logger.js';
/** Telegram принимает в setMyCommands только [a-z0-9_]{1,32} */
const TELEGRAM_COMMAND = /^[a-z0-9_]{1,32}$/;
export class CommandRegistry {
/** @type {Map<string, object>} триггер (имя или алиас) -> команда */
#triggers = new Map();
/** @type {object[]} */
#commands = [];
/**
* Реестр модулей юзербота. Модуль — это файл в src/modules, который экспортирует
* по умолчанию объект вида:
*
* export default {
* name: 'antidelete',
* description: 'Ловит удалённые сообщения',
* setup(ctx) { ... }, // однократная инициализация
* onMessage(message, ctx) { ... }, // новое входящее (личные чаты)
* onDeleted(event, ctx) { ... }, // событие удаления
* onEdited(message, ctx) { ... }, // правка сообщения
* panel: { title, id, render, ... }, // раздел в компаньон-боте (опц.)
* }
*
* Обработчики опциональны — модуль объявляет только то, что ему нужно.
*/
export class ModuleRegistry {
#modules = [];
#byName = new Map();
register(definition, source = '<inline>') {
const command = normalize(definition, source);
for (const trigger of [command.command, ...command.aliases]) {
const taken = this.#triggers.get(trigger);
if (taken) {
throw new Error(
`Конфликт триггеров: "${trigger}" из ${source} уже занят командой "${taken.command}" (${taken.source}).`,
);
}
this.#triggers.set(trigger, command);
const module = normalize(definition, source);
if (this.#byName.has(module.name)) {
throw new Error(`Модуль "${module.name}" уже зарегистрирован (${source}).`);
}
this.#commands.push(command);
return command;
this.#byName.set(module.name, module);
this.#modules.push(module);
return module;
}
resolve(trigger) {
return this.#triggers.get(trigger.toLowerCase());
}
/** Все команды в порядке регистрации. */
list() {
return [...this.#commands];
return [...this.#modules];
}
/** Что показываем в /help. */
visible() {
return this.#commands.filter((cmd) => !cmd.hidden);
get(name) {
return this.#byName.get(name);
}
/** Что уходит в setMyCommands: без скрытых, админских и нелатинских алиасов. */
forTelegram() {
return this.#commands
.filter((cmd) => !cmd.hidden && !cmd.adminOnly && TELEGRAM_COMMAND.test(cmd.command))
.map((cmd) => ({ command: cmd.command, description: cmd.description }));
/** Модули, у которых объявлен нужный хук — чтобы не дёргать пустышки. */
withHook(hook) {
return this.#modules.filter((m) => typeof m[hook] === 'function');
}
panels() {
return this.#modules.filter((m) => m.panel).map((m) => ({ ...m.panel, module: m.name }));
}
}
function normalize(definition, source) {
if (!definition || typeof definition !== 'object') {
throw new Error(`${source}: файл команды должен экспортировать объект по умолчанию.`);
function normalize(def, source) {
if (!def || typeof def !== 'object') {
throw new Error(`${source}: модуль должен экспортировать объект по умолчанию.`);
}
const { command, aliases = [], description = '', scope = 'all', adminOnly = false, hidden = false, handler } = definition;
if (typeof command !== 'string' || !command.trim()) {
throw new Error(`${source}: не задано поле "command".`);
if (typeof def.name !== 'string' || !def.name.trim()) {
throw new Error(`${source}: у модуля не задано поле "name".`);
}
if (command.startsWith('/')) {
throw new Error(`${source}: имя команды указывается без слэша — "${command.slice(1)}", а не "${command}".`);
for (const hook of ['setup', 'onMessage', 'onDeleted', 'onEdited']) {
if (def[hook] !== undefined && typeof def[hook] !== 'function') {
throw new Error(`${source}: "${hook}" должен быть функцией.`);
}
}
if (typeof handler !== 'function') {
throw new Error(`${source}: поле "handler" должно быть функцией.`);
}
if (!SCOPES.includes(scope)) {
throw new Error(`${source}: неизвестный scope "${scope}". Допустимо: ${SCOPES.join(', ')}.`);
}
if (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== 'string')) {
throw new Error(`${source}: "aliases" должен быть массивом строк.`);
}
return {
command: command.toLowerCase(),
aliases: aliases.map((alias) => alias.toLowerCase()),
description,
scope,
adminOnly: Boolean(adminOnly),
hidden: Boolean(hidden),
handler,
name: def.name,
description: def.description ?? '',
setup: def.setup,
onMessage: def.onMessage,
onDeleted: def.onDeleted,
onEdited: def.onEdited,
panel: def.panel,
source,
};
}
/**
* Прогоняет хук по всем модулям, где он объявлен. Ошибка одного модуля
* не роняет остальные — логируем и идём дальше.
*/
export async function runHook(registry, hook, payload, ctx) {
for (const module of registry.withHook(hook)) {
try {
await module[hook](payload, ctx);
} catch (err) {
logger.error(`Модуль "${module.name}", хук ${hook}:`, err);
}
}
}
-83
View File
@@ -1,83 +0,0 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { config } from '../config.js';
import { logger } from './logger.js';
/**
* Простейшее JSON-хранилище: держим состояние в памяти, на диск пишем
* дебаунсом через временный файл. Для продакшена меняется на Redis/Postgres,
* интерфейс (get/set/delete) при этом остаётся тем же.
*/
export class JsonStore {
#path;
#data = new Map();
#flushTimer = null;
#writing = Promise.resolve();
constructor(fileName) {
this.#path = fileURLToPath(new URL(fileName, config.dataDir));
}
async load() {
try {
const raw = await readFile(this.#path, 'utf8');
this.#data = new Map(Object.entries(JSON.parse(raw)));
logger.debug(`store ${this.#path}: загружено записей — ${this.#data.size}`);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
logger.debug(`store ${this.#path}: файла нет, начинаем с пустого состояния`);
}
return this;
}
get(key) {
return this.#data.get(String(key));
}
has(key) {
return this.#data.has(String(key));
}
entries() {
return [...this.#data.entries()];
}
set(key, value) {
this.#data.set(String(key), value);
this.#scheduleFlush();
return value;
}
delete(key) {
const existed = this.#data.delete(String(key));
if (existed) this.#scheduleFlush();
return existed;
}
#scheduleFlush() {
if (this.#flushTimer) return;
this.#flushTimer = setTimeout(() => {
this.#flushTimer = null;
this.flush().catch((err) => logger.error('store: не удалось записать файл', err));
}, 300);
this.#flushTimer.unref?.();
}
async flush() {
this.#writing = this.#writing.then(async () => {
const payload = JSON.stringify(Object.fromEntries(this.#data), null, 2);
const tmp = `${this.#path}.tmp`;
await mkdir(fileURLToPath(config.dataDir), { recursive: true });
await writeFile(tmp, payload, 'utf8');
await rename(tmp, this.#path);
});
return this.#writing;
}
}
/** business_connection_id -> { ownerId, userChatId, isEnabled, rights, updatedAt } */
export const connections = new JsonStore('connections.json');
/** `${connectionId}:${chatId}` -> timestamp, до которого автоответы выключены */
export const handovers = new JsonStore('handovers.json');
-33
View File
@@ -1,33 +0,0 @@
import { logger } from '../core/logger.js';
import { connections } from '../core/store.js';
import { saveConnection } from '../middlewares/business.js';
/**
* business_connection приходит, когда владелец подключает бота, меняет права
* или отключает его. Здесь единственное место, где мы узнаём connection.id, —
* без него нельзя отправить ни одного сообщения, поэтому пишем его в хранилище.
*/
export function registerConnectionHandlers(bot) {
bot.on('business_connection', async (ctx) => {
const connection = ctx.businessConnection;
if (connection.is_enabled) {
saveConnection(connection);
logger.info(`Бот подключён к аккаунту ${connection.user.id} (${connection.id})`);
// user_chat_id — личный чат с владельцем, туда пишем без business_connection_id.
await ctx.api.sendMessage(
connection.user_chat_id,
'Бот подключён к вашему бизнес-аккаунту. Проверить права — /status, остановить автоответы в конкретном чате — /pause.',
).catch((err) => logger.warn('Не удалось написать владельцу', err));
return;
}
connections.delete(connection.id);
logger.info(`Подключение ${connection.id} отключено владельцем ${connection.user.id}`);
});
bot.on('deleted_business_messages', (ctx) => {
logger.debug(`Удалено сообщений: ${ctx.deletedBusinessMessages.message_ids.length}`);
});
}
-32
View File
@@ -1,32 +0,0 @@
import { logger } from '../core/logger.js';
/**
* Всё, что не команда. Сюда же вешается автоответчик, FAQ или переход в LLM.
* Вызывается последним в цепочке, поэтому next() уже не нужен.
*/
export function fallback() {
return async (ctx) => {
const text = ctx.msg?.text ?? ctx.msg?.caption ?? '';
if (ctx.business) {
logger.debug(`Бизнес-чат ${ctx.chat?.id}: сообщение без команды`);
// Отметку «прочитано» ставим только если владелец выдал право читать чат.
if (ctx.business.rights?.can_read_messages && ctx.msgId) {
await ctx.api
.readBusinessMessage(ctx.business.connectionId, ctx.chat.id, ctx.msgId)
.catch((err) => logger.debug('readBusinessMessage не сработал', err.description ?? err));
}
await ctx.reply('Принял ваше сообщение, скоро ответим.');
return;
}
if (!text.startsWith('/')) {
await ctx.reply('Не понял. Посмотрите /help — там список команд.');
return;
}
await ctx.reply(`Команда ${text.split(/\s/)[0]} не найдена. Список — /help`);
};
}
+56 -28
View File
@@ -1,34 +1,62 @@
import { GrammyError } from 'grammy';
import { ALLOWED_UPDATES, createBot, shutdown, syncCommands } from './bot.js';
import { assertConfig, config } from './config.js';
import { createBot } from './bot/panel.js';
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';
const { bot, registry } = await createBot();
// 1. Конфиг и БД — падаем понятной ошибкой, если что-то не задано.
try {
assertConfig();
} catch (err) {
logger.error(err.message);
process.exit(1);
}
openDb();
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
shutdown(bot).then(
() => process.exit(0),
(err) => {
logger.error('Ошибка при остановке', err);
process.exit(1);
},
);
});
// 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);
}
try {
await bot.start({
allowed_updates: ALLOWED_UPDATES,
// Первый апдейт приходит уже после getMe, поэтому меню обновляем здесь.
onStart: async ({ username }) => {
await syncCommands(bot, registry).catch((err) => logger.warn('setMyCommands не прошёл', err));
logger.info(`@${username} запущен. Апдейты: ${ALLOWED_UPDATES.join(', ')}`);
},
// 3. Компаньон-бот (единственный UI) — запускаем в фоне.
const bot = createBot();
bot.start({ onStart: ({ username }) => logger.info(`Панель поднята: @${username}`) }).catch((err) => {
logger.error('Компаньон-бот не запустился (проверьте BOT_TOKEN):', err.message);
process.exit(1);
});
// 4. Модули: грузим и раздаём им события юзербота.
const registry = await loadModules();
const ctx = createContext({ client, bot, me, registry });
await runHook(registry, 'setup', undefined, ctx);
wireEvents(client, registry, ctx);
logger.info(`Готово. Активных модулей: ${registry.list().map((m) => m.name).join(', ')}`);
// 5. Периодическая чистка по сроку хранения.
if (config.retentionDays > 0) {
const timer = setInterval(() => {
const { messages, captures } = purgeOld();
if (messages || captures) logger.info(`Чистка: удалено сообщений ${messages}, перехватов ${captures}`);
}, 6 * 3600 * 1000);
timer.unref();
}
// 6. Аккуратная остановка.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
logger.info('Останавливаюсь…');
await bot.stop().catch(() => {});
await client.disconnect().catch(() => {});
process.exit(0);
});
} catch (err) {
// bot.catch() ловит ошибки апдейтов, но не падения на старте.
if (err instanceof GrammyError && err.error_code === 401) {
logger.error('Telegram не принял токен (401). Проверьте BOT_TOKEN в .env.');
} else {
logger.error('Не удалось запустить б
}
-76
View File
@@ -1,76 +0,0 @@
import { isAdmin } from '../config.js';
import { logger } from '../core/logger.js';
import { connections, handovers } from '../core/store.js';
export const pauseKey = (connectionId, chatId) => `${connectionId}:${chatId}`;
export const isPaused = (connectionId, chatId) => {
const until = handovers.get(pauseKey(connectionId, chatId));
if (!until) return false;
if (until > Date.now()) return true;
handovers.delete(pauseKey(connectionId, chatId));
return false;
};
export function saveConnection(connection) {
return connections.set(connection.id, {
ownerId: connection.user.id,
ownerUsername: connection.user.username ?? null,
userChatId: connection.user_chat_id,
isEnabled: connection.is_enabled ?? true,
rights: connection.rights ?? null,
updatedAt: Date.now(),
});
}
/**
* Кладёт в ctx.business всё, что нужно обработчикам: кто владелец аккаунта,
* пишет ли он сам прямо сейчас, какие права выданы боту.
*
* Данные о подключении приходят один раз в апдейте business_connection,
* поэтому храним их и при промахе кэша дотягиваем через getBusinessConnection.
*/
export function businessContext() {
return async (ctx, next) => {
const connectionId = ctx.businessConnectionId;
if (!connectionId) {
ctx.business = null;
return next();
}
let record = connections.get(connectionId);
if (!record) {
try {
record = saveConnection(await ctx.api.getBusinessConnection(connectionId));
logger.info(`Подключение ${connectionId} восстановлено через getBusinessConnection`);
} catch (err) {
logger.error(`Не удалось получить подключение ${connectionId}`, err);
}
}
const chatId = ctx.chat?.id;
ctx.business = {
connectionId,
ownerId: record?.ownerId ?? null,
userChatId: record?.userChatId ?? null,
rights: record?.rights ?? null,
isEnabled: record?.isEnabled ?? true,
canReply: record?.rights?.can_reply ?? true,
isOwner: Boolean(record?.ownerId && ctx.from?.id === record.ownerId),
isPaused: chatId != null ? isPaused(connectionId, chatId) : false,
pause(ms) {
if (chatId == null) return;
handovers.set(pauseKey(connectionId, chatId), Date.now() + ms);
},
resume() {
if (chatId == null) return;
handovers.delete(pauseKey(connectionId, chatId));
},
};
return next();
};
}
/** Владелец аккаунта или админ из ADMIN_IDS. */
export const isOperator = (ctx) => ctx.business?.isOwner === true || isAdmin(ctx.from?.id);
-39
View File
@@ -1,39 +0,0 @@
import { config } from '../config.js';
import { parseCommand } from '../core/dispatcher.js';
import { logger } from '../core/logger.js';
/**
* Передача диалога человеку.
*
* В бизнес-чате апдейт business_message приходит и на сообщения клиента,
* и на собственные сообщения владельца. Если владелец ответил вручную —
* бот замолкает в этом чате на HANDOVER_PAUSE_MINUTES, чтобы клиент
* не получал два ответа сразу. Команды владельца при этом продолжают работать.
*/
export function handover() {
return async (ctx, next) => {
const business = ctx.business;
if (!business) return next();
const isCommand = Boolean(parseCommand(ctx));
if (business.isOwner) {
if (isCommand) return next();
business.pause(config.handoverPauseMs);
logger.info(`Владелец отвечает сам в чате ${ctx.chat?.id} — пауза на ${config.handoverPauseMs / 60_000} мин`);
return;
}
if (business.isPaused) {
logger.debug(`Чат ${ctx.chat?.id} на паузе — молчим`);
return;
}
if (!business.canReply) {
logger.warn(`Нет права can_reply в подключении ${business.connectionId}`);
return;
}
return next();
};
}
+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;
}
+27
View File
@@ -0,0 +1,27 @@
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;
}
+25
View File
@@ -0,0 +1,25 @@
import { saveCapture } from '../core/db.js';
import { notifyCapture } from '../bot/panel.js';
/**
* Контекст передаётся в каждый хук модуля. Это единственная точка, через которую
* модуль что-то делает: сохраняет перехват, уведомляет владельца, качает медиа.
* Модуль не знает ни про grammY, ни про схему БД — только про этот интерфейс.
*/
export function createContext({ client, bot, me, registry }) {
return {
client, // MTProto-клиент (GramJS)
me, // объект себя
registry,
/**
* Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted' | 'onetime'.
* data: { chatId, senderId, sender, text, mediaKind, mediaPath }
*/
async capture(kind, data) {
const id = saveCapture(kind, data);
await notifyCapture(bot, { id, kind, ...data });
return id;
},
};
}
+37
View File
@@ -0,0 +1,37 @@
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 ?? '');
}