From 0e19c84a6d0428fb50ae39c6f7942b607adb5443 Mon Sep 17 00:00:00 2001 From: ares Date: Sun, 9 Aug 2026 22:16:44 +0300 Subject: [PATCH] Make bot multi-tenant with colorful welcome and per-owner isolation The bot is now public: anyone connects it to their own Telegram Business account and gets antidelete for their own private chats. On connection (business_connection enabled) and on /start it sends a colorful welcome describing features, how to connect, and limitations. - connections.js: resolve a connection's owner from business_connection_id (memo -> DB -> getBusinessConnection). Business messages have no outgoing flag, so the owner's own messages are filtered by comparing from.id; if the owner can't be resolved the message is not cached. - Strict per-owner isolation: captures scoped by owner_id; the panel shows each user only their own feed; notifications go to the owner's chat. - db.js: multi-tenant schema (connections table; messages keyed by (conn_id, chat_id, msg_id); captures/counts scoped by owner_id). - media.js: key cached-media filenames by (connId, chatId, msgId) to prevent one tenant overwriting another's encrypted media. - panel.js: drop the owner-only barrier; /start sends welcome + own feed. - config.js: OWNER_ID is now optional (service logs only, grants no access). - Docs: README/.env.example rewritten for the multi-tenant model and the shared-key privacy caveat. - Stop tracking .claude/settings.local.json; restore the Hcrgram/ ignore. Co-Authored-By: Claude Opus 5 --- .claude/settings.local.json | 50 -------------- .env.example | 9 ++- .gitignore | 2 + README.md | 43 ++++++------ src/bot/panel.js | 62 ++++++++---------- src/bot/welcome.js | 63 ++++++++++++++++++ src/business/connections.js | 67 +++++++++++++++++++ src/business/context.js | 3 +- src/business/events.js | 80 +++++++++++++++++------ src/config.js | 16 +++-- src/core/db.js | 127 ++++++++++++++++++++++++++++-------- src/core/media.js | 14 +++- src/modules/antidelete.js | 10 +-- src/modules/cache.js | 8 ++- 14 files changed, 385 insertions(+), 169 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 src/bot/welcome.js create mode 100644 src/business/connections.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 80ec1f8..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npm test *)", - "Bash(BOT_TOKEN=\"123456:AAFAKE_TOKEN_FOR_SMOKE_TEST\" LOG_LEVEL=debug timeout 25 node src/index.js)", - "Bash(timeout 20 node src/index.js)", - "Bash(BOT_TOKEN=\"123456:AAFAKE\" timeout 25 node src/index.js)", - "Bash(npm run *)", - "Bash(node -e ' *)", - "WebSearch", - "Bash(npm view *)", - "Bash(npm i *)", - "Bash(node -e \"console.log\\('telegram', require\\('telegram/package.json'\\).version\\)\")", - "Bash(node --input-type=module -e ' *)", - "Bash(node -e \"console.log\\(require\\('crypto'\\).randomBytes\\(32\\).toString\\('hex'\\)\\)\")", - "Bash(ENCRYPTION_KEY=__CMDSUB_OUTPUT__ RETENTION_DAYS=30 node --input-type=module -e ' *)", - "Bash(rmdir test *)", - "Bash(echo \"=== removed test/ \\(exit $?\\) ===\")", - "Bash(git commit *)", - "WebFetch(domain:core.telegram.org)", - "WebFetch(domain:grammy.dev)", - "WebFetch(domain:github.com)", - "WebFetch(domain:docs.aiogram.dev)", - "WebFetch(domain:telegram.org)", - "Bash(gh api *)", - "Bash(cd /tmp)", - "Bash(curl -sL \"https://raw.githubusercontent.com/tdlib/telegram-bot-api/master/telegram-bot-api/Client.cpp\" -o client.cpp)", - "Bash(awk 'NR<=18629 && /^[a-zA-Z].*Client::.*\\\\\\(/ {line=NR; text=$0} END{print line\": \"text}' client.cpp)", - "Bash(awk 'NR<=19167 && /^\\(void|bool|td|const|int|auto\\).*Client::.*\\\\\\(/ {line=NR; text=$0} END{print line\": \"text}' client.cpp)", - "Bash(awk 'NR<=19212 && /^\\(void|bool|td|const|int|auto\\).*Client::.*\\\\\\(/ {line=NR; text=$0} END{print line\": \"text}' client.cpp)", - "Bash(git add *)", - "WebFetch(domain:docs.python-telegram-bot.org)", - "Bash(curl -s \"https://core.telegram.org/bots/api-changelog\" -o /tmp/changelog.html)", - "Read(//tmp/**)", - "Bash(curl -s \"https://core.telegram.org/bots/api\" -o /tmp/api.html)", - "Bash(python -c ' *)", - "Bash(curl -s \"https://core.telegram.org/bots/api\" -o api.html)", - "Bash(curl -s \"https://core.telegram.org/bots/api-changelog\" -o changelog.html)", - "Bash(git rm *)", - "Bash(git mv *)", - "Bash(mkdir -p src/business)", - "Bash(rm -f api.html changelog.html)", - "Bash(rmdir src/userbot)", - "Bash(npm install *)", - "Bash(node _offline_test.mjs)", - "Bash(rm -f _offline_test.mjs)", - "Bash(rm -rf data)" - ] - } -} diff --git a/.env.example b/.env.example index 23ebab3..5313b4c 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,17 @@ # === Бот (единственный интерфейс + приёмник бизнес-апдейтов) === # Токен от @BotFather. Обязательно включите боту режим бизнеса: # @BotFather → /mybots → выбрать бота → Bot Settings → Business Mode → Enable. -# Затем в приложении: Настройки → Telegram для бизнеса → Чат-боты → добавить бота. -# Сессия НЕ нужна — работаем только через официальное бизнес-подключение. +# Бот доступен всем: любой может подключить его к СВОЕМУ бизнес-аккаунту +# (Настройки → Telegram для бизнеса → Чат-боты). Сессия НЕ нужна. BOT_TOKEN= -# Ваш Telegram id (узнать: напишите @userinfobot). Только вы управляете ботом. +# (Необязательно) Telegram id «оператора» бота — только для служебных логов. +# Доступа к чужим данным НЕ даёт: каждый видит лишь свои перехваты (строгая +# изоляция по владельцу). Можно оставить пустым. OWNER_ID= # === Локальное хранилище === # Ключ шифрования: 64 hex-символа ИЛИ длинная парольная фраза (≥32 байт). +# Одним ключом шифруются данные ВСЕХ подключившихся — храните его в секрете. # Сгенерировать hex: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ENCRYPTION_KEY= # Сколько дней хранить перехваченное (0 — бессрочно) diff --git a/.gitignore b/.gitignore index 0bc22d2..72c5fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ data/ # Сторонний код (изучался как референс, в проект не входит) Hcrgram/ + +.claude/settings.local.json diff --git a/README.md b/README.md index 121c35e..1c4d58e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # telegrambusiness -Личный Telegram-бот на Node.js, работающий через **официальное бизнес-подключение** Telegram (Business API). Бот подключается к вашему аккаунту как чат-бот для бизнеса и сохраняет то, что собеседник удалил: +Telegram-бот на Node.js, работающий через **официальное бизнес-подключение** Telegram (Business API). Бот **доступен всем**: любой человек подключает его к **своему** бизнес-аккаунту как чат-бота и получает сохранение того, что собеседник удалил: - **antidelete** — сообщения (и медиа), которые собеседник удалил в личке. -Всё управление и все перехваты приходят в этого же бота — в личку владельца. Наружу бот ничего не пишет, слеш-команд в чатах нет. +Бот многопользовательский. При подключении он присылает владельцу **красочное приветствие** с описанием функций, а все перехваты приходят этому же владельцу в личку. Данные строго изолированы: **каждый видит только свои** перехваты. Наружу бот ничего не пишет, слеш-команд в чужих чатах нет. > ✅ **Без сессии, по правилам Telegram.** Проект **не хранит строку сессии** и не использует MTProto-юзербот. Работает исключительно через штатную функцию «Telegram для бизнеса» — обычный бот от [@BotFather](https://t.me/BotFather) с включённым Business Mode. Это соответствует ToS. -> ⚠️ **Дисклеймер.** Инструмент для личного использования на **своём** аккаунте и своей переписке. Не применяйте против других людей. +> ⚠️ **Дисклеймер.** Инструмент сохраняет входящие в личке того, кто подключил бота к своему аккаунту. Оператор бота, у которого лежат `data/` и `ENCRYPTION_KEY`, технически имеет доступ к расшифровке. Разворачивайте и используйте ответственно, уважая приватность собеседников. ## Как устроено (важно понимать до запуска) @@ -36,13 +36,12 @@ cp .env.example .env Заполните `.env`: 1. **BOT_TOKEN** — новый бот у [@BotFather](https://t.me/BotFather). -2. **OWNER_ID** — ваш id (напишите [@userinfobot](https://t.me/userinfobot)). -3. **ENCRYPTION_KEY** — сгенерируйте: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. +2. **ENCRYPTION_KEY** — сгенерируйте: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. +3. **OWNER_ID** — необязательно, только для служебных логов оператора. Доступа к чужим данным не даёт (изоляция по владельцу), можно не задавать. -Включите боту режим бизнеса и подключите его к аккаунту: +Включите боту режим бизнеса — этого достаточно, дальше его подключит каждый пользователь сам: 1. [@BotFather](https://t.me/BotFather) → `/mybots` → выберите бота → **Bot Settings** → **Business Mode** → **Enable**. -2. В приложении Telegram: **Настройки → Telegram для бизнеса → Чат-боты** → добавьте своего бота. Дайте ему право читать/управлять сообщениями. Запуск: @@ -50,7 +49,7 @@ cp .env.example .env npm run dev ``` -Откройте своего бота, нажмите **Start** — появится панель. Когда собеседник удалит сообщение, бот пришлёт уведомление с кнопкой «Открыть». +Дальше это делает **любой пользователь** для своего аккаунта: **Настройки → Telegram для бизнеса → Чат-боты** → добавить бота и разрешить управление сообщениями. Сразу после подключения бот пришлёт в личку приветствие. Когда собеседник удалит сообщение, придёт уведомление с кнопкой «Открыть». Команда **/start** в любой момент откроет приветствие и личную ленту перехватов. `npm run dev` — с автоперезапуском, `npm start` — обычный запуск. @@ -68,6 +67,8 @@ export default { if (!msg.isPrivateIncoming) return; if (/срочно/i.test(msg.text ?? '')) { await ctx.capture('deleted', { // переиспользуем ленту перехватов + ownerId: msg.ownerId, // чья это лента (изоляция) + ownerChatId: msg.ownerChatId, // куда уведомлять chatId: msg.chatId, senderId: msg.senderId, sender: msg.sender, @@ -78,16 +79,16 @@ export default { }; ``` -Хук получает **нормализованное** сообщение: `{ chatId, msgId, senderId, sender, text, media, isPrivateIncoming }`. +Хук получает **нормализованное** сообщение с уже разрешённым владельцем подключения: `{ connId, ownerId, ownerChatId, chatId, msgId, senderId, sender, text, media, isPrivateIncoming }`. | Хук | Когда вызывается | | --- | --- | | `setup(_, ctx)` | Один раз при старте | | `onMessage(msg, ctx)` | Новое бизнес-сообщение (`business_message`) | | `onEdited(msg, ctx)` | Правка (`edited_business_message`) | -| `onDeleted(event, ctx)` | Удаление; `event.chatId`, `event.msgIds` | +| `onDeleted(event, ctx)` | Удаление; `event.connId`, `event.ownerId`, `event.ownerChatId`, `event.chatId`, `event.msgIds` | -`ctx` даёт: `api` (grammY `bot.api`), `registry` и `capture(kind, data)` — сохранить перехват и уведомить владельца. Ошибка в одном модуле не роняет остальные ([registry.js](src/core/registry.js) — `runHook`). +`ctx` даёт: `api` (grammY `bot.api`), `registry` и `capture(kind, data)` — сохранить перехват под `data.ownerId` и уведомить владельца в `data.ownerChatId`. Ошибка в одном модуле не роняет остальные ([registry.js](src/core/registry.js) — `runHook`). ## Устройство @@ -103,22 +104,26 @@ src/ registry.js реестр модулей, runHook logger.js business/ - events.js подписка business_message/edited/deleted → модули + events.js подписка business_message/edited/deleted/connection → модули + connections.js резолвер владельца по business_connection_id context.js ctx.capture — мост модуль↔БД↔бот modules/ cache.js кэширует входящие (основа antidelete) antidelete.js удаление → достать из кэша → перехват bot/ - panel.js бот: инлайн-панель, лента, выдача медиа + panel.js бот: приветствие, инлайн-панель, лента, выдача медиа + welcome.js текст красочного приветствия (/start и подключение) ``` -Поток: входящее (`business_message`) → `cache` кэширует текст и качает медиа → при удалении (`deleted_business_messages`) `antidelete` поднимает из кэша → `ctx.capture` пишет в БД и шлёт уведомление → панель показывает и отдаёт расшифрованный файл. +Поток: подключение (`business_connection`) → запоминаем владельца и шлём приветствие → входящее (`business_message`) → `cache` кэширует текст и качает медиа под этим подключением → при удалении (`deleted_business_messages`) `antidelete` поднимает из кэша по `(connId, chatId, msgId)` → `ctx.capture` пишет в БД под `ownerId` и шлёт уведомление в `ownerChatId` → панель показывает владельцу только его записи и отдаёт расшифрованный файл. ## Что стоит учесть -- Перехватываются только **входящие в личных диалогах**. Свои сообщения — нет (см. `isPrivateIncoming` в [events.js](src/business/events.js)). -- Id сообщения в Bot API уникален **только внутри чата**, поэтому в БД составной ключ `(chat_id, msg_id)`. -- Если вы отключите бизнес-подключение в настройках Telegram, перехват приостановится (бот залогирует это по апдейту `business_connection`). +- **Многопользовательский режим.** Бота подключают к себе разные люди; данные строго изолированы по владельцу (`owner_id`), в панели каждый видит только своё. +- У бизнес-сообщений **нет флага «исходящее»**, поэтому своё сообщение владельца отсекаем сравнением `from.id` с владельцем подключения (`business_connection_id` → владелец, см. [connections.js](src/business/connections.js)). Если владельца распознать не удалось, сообщение не кэшируется. +- Перехватываются только **входящие в личных диалогах** (см. `isPrivateIncoming` в [events.js](src/business/events.js)). +- Id сообщения в Bot API уникален **только внутри чата**, поэтому в БД составной ключ `(conn_id, chat_id, msg_id)`. +- Если владелец отключит бизнес-подключение в настройках Telegram, перехват для него приостановится (бот залогирует это по апдейту `business_connection`). - `CACHE_MEDIA=1` качает медиа каждого входящего, чтобы восстанавливать удалённые картинки. Это ест диск — при `=0` останется только текст и пометка о типе медиа. -- Ключ шифрования — в `.env`. Потеряете ключ — расшифровать хранилище нельзя. -- Хранилище локальное; `data/` в `.gitignore`. +- Ключ шифрования — в `.env`, **один на все подключения**. Потеряете ключ — расшифровать хранилище нельзя. +- Хранилище локальное; `data/` в `.gitignore`. Схема БД сменилась на мультитенантную — при обновлении со старой версии удалите прежнюю `data/` перед первым запуском. diff --git a/src/bot/panel.js b/src/bot/panel.js index b75e634..a1f6773 100644 --- a/src/bot/panel.js +++ b/src/bot/panel.js @@ -1,51 +1,42 @@ import { Bot, InlineKeyboard, InputFile } from 'grammy'; -import { config, isOwner } from '../config.js'; +import { config } 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'; +import { welcomeText, WELCOME_PARSE_MODE } from './welcome.js'; const PAGE = 5; const KIND_TITLE = { deleted: '🗑 Удалённые' }; -/** Бизнес-апдейт от подключённого аккаунта: у него ctx.from — собеседник, не владелец. */ -function isBusinessUpdate(ctx) { - return Boolean( - ctx.businessMessage ?? - ctx.editedBusinessMessage ?? - ctx.deletedBusinessMessages ?? - ctx.businessConnection, - ); -} - /** - * Компаньон-бот — единственный интерфейс юзербота. Всё управление и все - * перехваты живут здесь, в личке владельца. Никаких слеш-команд снаружи: - * бот отвечает только владельцу (OWNER_ID), любому другому — молчит. + * Бот — интерфейс для всех. Любой может открыть его, нажать Start и получить + * приветствие и СВОЮ ленту перехватов. Строгая изоляция: перехваты фильтруются + * по id того, кто обратился (ctx.from.id) — чужого не видно. + * + * Бизнес-апд엘ты (business_message/deleted/connection) сюда не попадают — + * их обрабатывает wireBusiness через bot.on(...). Панель живёт на обычных + * message/callback_query, поэтому барьер доступа не нужен. */ export function createBot() { const bot = new Bot(config.botToken()); - // Жёсткий барьер: панельные апдейты обрабатываем только от владельца. - // Бизнес-апдейты (business_message/deleted/…) пропускаем — у них ctx.from - // это собеседник, а не владелец; их разбирает wireBusiness отдельно. - bot.use(async (ctx, next) => { - if (isBusinessUpdate(ctx) || isOwner(ctx.from?.id)) return next(); - logger.debug(`bot: игнорирую чужой апдейт от ${ctx.from?.id}`); + bot.command('start', async (ctx) => { + const text = welcomeText({ name: ctx.from?.first_name }); + await ctx.reply(text, { parse_mode: WELCOME_PARSE_MODE, reply_markup: mainMenu(ctx.from.id) }); }); - bot.command('start', (ctx) => ctx.reply('Панель юзербота.', { reply_markup: mainMenu() })); - bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель юзербота.', mainMenu())); + bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель — ваши перехваты.', mainMenu(ctx.from.id))); - // Списки перехватов с пагинацией. + // Списки перехватов с пагинацией (только свои). 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(); @@ -55,9 +46,8 @@ export function createBot() { return bot; } -function mainMenu() { - return new InlineKeyboard() - .text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0'); +function mainMenu(ownerId) { + return new InlineKeyboard().text(`🗑 Удалённые (${countCaptures(ownerId, 'deleted')})`, 'feed:deleted:0'); } function feedKeyboard(kind, page, items, total) { @@ -74,8 +64,9 @@ function feedKeyboard(kind, page, items, total) { } async function renderFeed(ctx, kind, page) { - const total = countCaptures(kind); - const items = listCaptures({ kind, limit: PAGE, offset: page * PAGE }); + const ownerId = ctx.from.id; + const total = countCaptures(ownerId, kind); + const items = listCaptures({ ownerId, 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))}` @@ -84,7 +75,7 @@ async function renderFeed(ctx, kind, page) { } async function renderItem(ctx, id) { - const item = getCapture(id); + const item = getCapture(id, ctx.from.id); if (!item) return edit(ctx, 'Запись не найдена (возможно, удалена по сроку хранения).', backTo('home')); const caption = formatCapture(item); @@ -145,15 +136,20 @@ async function edit(ctx, text, keyboard) { } /** - * Присылает владельцу уведомление о свежем перехвате. Дёргается из модулей - * через контекст (ctx.notify) — модуль не знает деталей бота. + * Уведомляет ВЛАДЕЛЬЦА подключения о свежем перехвате — в его личку с ботом. + * item.ownerChatId — куда слать (owner_chat_id из бизнес-подключения). + * Дёргается из модулей через контекст (ctx.capture → notifyCapture). */ export async function notifyCapture(bot, item) { + if (!item.ownerChatId) { + logger.warn(`bot: некому отправить уведомление о перехвате ${item.id} (нет ownerChatId)`); + return; + } const kb = new InlineKeyboard().text('Открыть', `item:${item.id}`); 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 }); + await bot.api.sendMessage(item.ownerChatId, text, { reply_markup: kb }); } catch (err) { logger.warn('bot: не удалось уведомить владельца', err.message); } diff --git a/src/bot/welcome.js b/src/bot/welcome.js new file mode 100644 index 0000000..f78b2a0 --- /dev/null +++ b/src/bot/welcome.js @@ -0,0 +1,63 @@ +/** + * Красочное приветствие. Показывается в двух местах: на /start в личке бота + * и когда человек подключает бота к своему бизнес-аккаунту (business_connection). + * Формат — Markdown (parse_mode: 'Markdown'). + * + * Разделы (по выбору владельца проекта): что умеет, как подключить, ограничения. + * Ни от кого не зависит и ничего не хранит — просто текст. + */ +export const WELCOME_PARSE_MODE = 'Markdown'; + +const FEATURES = [ + '🗑 *Antidelete* — сохраняю сообщения и медиа, которые собеседник удалил у вас в личке.', + '🔔 *Уведомления* — как только что-то удалено, пришлю карточку с кнопкой «Открыть».', + '🗂 *Личная лента* — все ваши перехваты в одном месте, с постраничным просмотром.', + '🔐 *Локально и зашифровано* — данные лежат только на сервере бота (AES-256), у каждого — свои.', +]; + +const HOW_TO = [ + '1️⃣ В *@BotFather* у этого бота включите *Business Mode*.', + '2️⃣ В Telegram: *Настройки → Telegram для бизнеса → Чат-боты*.', + '3️⃣ Добавьте меня и разрешите управлять сообщениями.', + '4️⃣ Готово — я начну беречь ваши личные переписки. Возвращайтесь сюда за лентой.', +]; + +const LIMITS = [ + '• Одноразовые медиа («просмотр один раз») перехватить нельзя — Telegram не отдаёт их ботам.', + '• Файлы больше ~20 МБ не сохраняются целиком (ограничение Bot API).', + '• Работаю только в личных 1-на-1 чатах, не в группах и каналах.', +]; + +/** + * @param {object} [opts] + * @param {string} [opts.name] — имя, чтобы поздороваться лично. + * @param {boolean} [opts.connected] — true, если это отклик на подключение бизнеса. + */ +export function welcomeText({ name, connected = false } = {}) { + const hi = name ? `Привет, *${escapeMd(name)}*! ` : 'Привет! '; + const head = connected + ? `${hi}✅ Бот подключён к вашему бизнес-аккаунту — antidelete включён.` + : `${hi}Я бот-хранитель личных переписок для Telegram Business.`; + + return [ + head, + '', + '*Что я умею*', + ...FEATURES, + '', + '*Как подключить*', + ...HOW_TO, + '', + '*Важно знать*', + ...LIMITS, + '', + connected + ? '📂 Открыть свои перехваты можно кнопкой ниже или командой /start.' + : '📂 Ниже — ваша лента перехватов.', + ].join('\n'); +} + +/** Экранируем символы, ломающие Markdown-разметку в подставляемом имени. */ +function escapeMd(s) { + return String(s).replace(/([_*`\[\]])/g, '\\$1'); +} diff --git a/src/business/connections.js b/src/business/connections.js new file mode 100644 index 0000000..fa26cfb --- /dev/null +++ b/src/business/connections.js @@ -0,0 +1,67 @@ +import { getConnection, upsertConnection } from '../core/db.js'; +import { logger } from '../core/logger.js'; + +/** + * Резолвер владельца по business_connection_id. + * + * Зачем: у бизнес-сообщений НЕТ флага «исходящее». Единственный надёжный способ + * отличить сообщение владельца от сообщения собеседника — сравнить from.id с id + * владельца подключения. А уведомления об удалении надо слать в личку владельца + * (owner_chat_id). И то, и другое берём из подключения. + * + * Порядок поиска: память → БД → getBusinessConnection (и результат кэшируем). + * getBusinessConnection нужен после рестарта, если событие connection пришло до + * запуска и в памяти пусто, но в БД запись есть — тогда чаще хватит БД. + */ +const memo = new Map(); // connId -> { ownerId, ownerChatId, ownerName, ownerUsername, isEnabled } + +/** Кладёт/обновляет подключение и в память, и в БД. Возвращает нормализованную запись. */ +export function rememberConnection(conn) { + const record = { + connId: String(conn.id), + ownerId: conn.user?.id != null ? String(conn.user.id) : '', + ownerChatId: conn.user_chat_id != null ? String(conn.user_chat_id) : '', + ownerName: [conn.user?.first_name, conn.user?.last_name].filter(Boolean).join(' '), + ownerUsername: conn.user?.username ?? '', + isEnabled: Boolean(conn.is_enabled), + }; + memo.set(record.connId, record); + upsertConnection(record); + return record; +} + +/** + * Возвращает { ownerId, ownerChatId, ... } по connId или null. + * api — bot.api (для getBusinessConnection); можно не передавать, тогда только кэш/БД. + */ +export async function resolveOwner(connId, api) { + if (connId == null) return null; + const key = String(connId); + + const cached = memo.get(key); + if (cached) return cached; + + const row = getConnection(key); + if (row) { + const record = { + connId: key, + ownerId: row.owner_id ?? '', + ownerChatId: row.owner_chat_id ?? '', + ownerName: row.owner_name ?? '', + ownerUsername: row.owner_username ?? '', + isEnabled: Boolean(row.is_enabled), + }; + memo.set(key, record); + return record; + } + + if (api?.getBusinessConnection) { + try { + const conn = await api.getBusinessConnection(key); + if (conn?.id) return rememberConnection(conn); + } catch (err) { + logger.warn(`connections: getBusinessConnection(${key}) не удался:`, err.message); + } + } + return null; +} diff --git a/src/business/context.js b/src/business/context.js index 0b5a6c2..e789af8 100644 --- a/src/business/context.js +++ b/src/business/context.js @@ -13,7 +13,8 @@ export function createContext({ bot, registry }) { /** * Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted'. - * data: { chatId, senderId, sender, text, mediaKind, mediaPath } + * data: { ownerId, ownerChatId, chatId, senderId, sender, text, mediaKind, mediaPath } + * ownerId — чей это перехват (изоляция ленты), ownerChatId — куда уведомлять. */ async capture(kind, data) { const id = saveCapture(kind, data); diff --git a/src/business/events.js b/src/business/events.js index e807c9b..0f85992 100644 --- a/src/business/events.js +++ b/src/business/events.js @@ -1,65 +1,105 @@ -import { isOwner } from '../config.js'; +import { welcomeText, WELCOME_PARSE_MODE } from '../bot/welcome.js'; import { detectMediaFromBusiness } from '../core/media.js'; import { logger } from '../core/logger.js'; import { runHook } from '../core/registry.js'; +import { rememberConnection, resolveOwner } from './connections.js'; /** * Подписывает бота на бизнес-апдейты Telegram и раздаёт их модулям через хуки. * Работает через официальное бизнес-подключение (Настройки → Telegram для - * бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённого аккаунта. + * бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённых аккаунтов. + * + * Мультитенант: бота подключают разные владельцы. У бизнес-сообщений НЕТ флага + * «исходящее», поэтому по business_connection_id восстанавливаем владельца и + * сравниваем with from.id — так отсекаем собственные сообщения владельца. Владелец + * нужен и для адресации: уведомления/приветствие шлём в его личку (owner_chat_id). * * Событие удаления (deleted_business_messages) несёт только id сообщений и чат — * без содержимого. Поэтому модуль cache складывает каждое входящее заранее, - * а antidelete достаёт его из кэша по (chatId, msgId). + * а antidelete достаёт его из кэша по (connId, chatId, msgId). */ export function wireBusiness(bot, registry, ctx) { - bot.on('business_message', (gctx) => - runHook(registry, 'onMessage', normalizeBusinessMessage(gctx), ctx), - ); + bot.on('business_message', async (gctx) => { + const msg = await normalizeBusinessMessage(gctx); + return runHook(registry, 'onMessage', msg, ctx); + }); - bot.on('edited_business_message', (gctx) => - runHook(registry, 'onEdited', normalizeBusinessMessage(gctx), ctx), - ); + bot.on('edited_business_message', async (gctx) => { + const msg = await normalizeBusinessMessage(gctx); + return runHook(registry, 'onEdited', msg, ctx); + }); - bot.on('deleted_business_messages', (gctx) => { + bot.on('deleted_business_messages', async (gctx) => { const d = gctx.deletedBusinessMessages; + const connId = d.business_connection_id != null ? String(d.business_connection_id) : ''; + const owner = await resolveOwner(connId, gctx.api); return runHook( registry, 'onDeleted', - { chatId: d.chat?.id, msgIds: d.message_ids ?? [] }, + { + connId, + ownerId: owner?.ownerId ?? '', + ownerChatId: owner?.ownerChatId ?? '', + chatId: d.chat?.id, + msgIds: d.message_ids ?? [], + }, ctx, ); }); - bot.on('business_connection', (gctx) => { + // Подключение/отключение бота владельцем. Здесь же — красочное приветствие. + bot.on('business_connection', async (gctx) => { const c = gctx.businessConnection; - if (c?.is_enabled) { - logger.info(`Бизнес-подключение активно (id ${c.id})`); + if (!c) return; + const record = rememberConnection(c); + if (c.is_enabled) { + logger.info(`Бизнес-подключение активно (владелец ${record.ownerId || '?'}, id ${c.id})`); + await greetOwner(gctx.api, record); } else { - logger.warn(`Бизнес-подключение отключено владельцем (id ${c?.id}). Перехват приостановлен.`); + logger.warn(`Бизнес-подключение отключено (id ${c.id}). Перехват для владельца приостановлен.`); } }); logger.debug('бизнес-апдейты подписаны: message / edited / deleted / connection'); } +/** Шлёт приветствие владельцу в его личку с ботом сразу после подключения. */ +async function greetOwner(api, record) { + if (!record.ownerChatId) return; + try { + await api.sendMessage(record.ownerChatId, welcomeText({ name: record.ownerName, connected: true }), { + parse_mode: WELCOME_PARSE_MODE, + }); + } catch (err) { + logger.warn('business: не удалось отправить приветствие владельцу', err.message); + } +} + /** - * Приводит grammY-контекст бизнес-сообщения к плоскому виду, понятному модулям. - * isPrivateIncoming — входящее в личном 1-на-1 чате, не от самого владельца. + * Приводит grammY-контекст бизнес-сообщения к плоскому виду, понятному модулям, + * и подмешивает владельца подключения (connId/ownerId/ownerChatId). + * isPrivateIncoming — входящее в личном 1-на-1 чате, НЕ от самого владельца. + * Если владельца распознать не удалось (ownerId пуст) — считаем сообщение + * неопределённым и НЕ кэшируем: лучше пропустить, чем сохранить своё исходящее. */ -export function normalizeBusinessMessage(gctx) { +export async function normalizeBusinessMessage(gctx) { const msg = gctx.businessMessage ?? gctx.editedBusinessMessage ?? gctx.msg; const from = msg?.from; const chat = msg?.chat; + const connId = msg?.business_connection_id != null ? String(msg.business_connection_id) : ''; + const owner = await resolveOwner(connId, gctx.api); + const ownerId = owner?.ownerId ?? ''; return { + connId, + ownerId, + ownerChatId: owner?.ownerChatId ?? '', 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), + isPrivateIncoming: chat?.type === 'private' && ownerId !== '' && String(from?.id) !== ownerId, }; } diff --git a/src/config.js b/src/config.js index 2158a1d..715993c 100644 --- a/src/config.js +++ b/src/config.js @@ -16,13 +16,18 @@ const num = (name) => { * Конфиг бота. Работает через официальное бизнес-подключение Telegram — * никакой строки сессии, только токен бота от @BotFather. Всё чувствительное * (токен, ключ шифрования) живёт только в .env и никуда не уходит. + * + * Мультитенант: любой может подключить бота к своему бизнес-аккаунту. Доступ + * к перехватам изолирован по владельцу (см. db.js), поэтому OWNER_ID больше + * ничего не открывает и не обязателен. */ export const config = { - // Бот-панель (@BotFather). К нему же владелец подключает бизнес-аккаунт - // (Настройки → Telegram для бизнеса → Чат-боты). Единственный UI. + // Бот (@BotFather, Business Mode). К нему пользователи подключают свои + // бизнес-аккаунты (Настройки → Telegram для бизнеса → Чат-боты). botToken: () => req('BOT_TOKEN'), - // Telegram id владельца: только он управляет ботом и получает перехваты. - ownerId: () => num('OWNER_ID'), + // Необязательный id «оператора» бота — только для служебных логов, доступ + // к чужим данным он НЕ даёт (строгая изоляция). null, если не задан. + ownerId: () => (process.env.OWNER_ID ? num('OWNER_ID') : null), // Ключ шифрования локального хранилища (32+ байта, hex или ascii). encryptionKey: () => req('ENCRYPTION_KEY'), @@ -45,11 +50,8 @@ export const mediaPath = () => fileURLToPath(config.mediaDir); /** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */ export function assertConfig() { 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-символа).'); } } - -export const isOwner = (id) => Number(id) === config.ownerId(); diff --git a/src/core/db.js b/src/core/db.js index 25a1fd5..335015d 100644 --- a/src/core/db.js +++ b/src/core/db.js @@ -10,13 +10,27 @@ let db = null; /** * Локальный индекс на node:sqlite. Тексты храним зашифрованными (AES-GCM), * бинарные медиа лежат отдельными файлами в data/media, здесь только путь. + * + * Мультитенант: бот подключают к своим бизнес-аккаунтам разные люди. Каждое + * подключение — строка в connections (conn_id → владелец). Кэш входящих и + * перехваты привязаны к владельцу, поэтому в панели каждый видит только своё. */ export function openDb() { if (db) return db; mkdirSync(dataPath(), { recursive: true }); db = new DatabaseSync(`${dataPath()}/business.db`); db.exec(` + CREATE TABLE IF NOT EXISTS connections ( + conn_id TEXT PRIMARY KEY, + owner_id TEXT, + owner_chat_id TEXT, + owner_name TEXT, + owner_username TEXT, + is_enabled INTEGER, + updated_at INTEGER + ); CREATE TABLE IF NOT EXISTS messages ( + conn_id TEXT, msg_id INTEGER, chat_id TEXT, sender_id TEXT, @@ -25,20 +39,21 @@ export function openDb() { media_kind TEXT, media_path TEXT, created_at INTEGER, - PRIMARY KEY (chat_id, msg_id) + PRIMARY KEY (conn_id, chat_id, msg_id) ); CREATE TABLE IF NOT EXISTS captures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT, -- 'deleted' - chat_id TEXT, - sender_id TEXT, - sender TEXT, - text_enc TEXT, - media_kind TEXT, - media_path TEXT, + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id TEXT, + kind TEXT, -- 'deleted' + 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); + CREATE INDEX IF NOT EXISTS idx_captures_owner_time ON captures(owner_id, captured_at DESC); `); logger.debug('sqlite: база готова'); return db; @@ -46,15 +61,53 @@ export function openDb() { const now = () => Math.floor(Date.now() / 1000); -/** Кладём каждое входящее в кольцевой кэш — источник для восстановления удалённых. */ -export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind, mediaPath }) { +/** + * Сохраняет/обновляет бизнес-подключение. Ключ — conn_id (business_connection_id), + * по нему потом восстанавливаем владельца (для адресации уведомлений и отсева + * исходящих владельца). Зовётся из события business_connection. + */ +export function upsertConnection({ connId, ownerId, ownerChatId, ownerName, ownerUsername, isEnabled }) { + openDb() + .prepare( + `INSERT INTO connections + (conn_id, owner_id, owner_chat_id, owner_name, owner_username, is_enabled, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(conn_id) DO UPDATE SET + owner_id=excluded.owner_id, + owner_chat_id=excluded.owner_chat_id, + owner_name=excluded.owner_name, + owner_username=excluded.owner_username, + is_enabled=excluded.is_enabled, + updated_at=excluded.updated_at`, + ) + .run( + String(connId), + String(ownerId ?? ''), + String(ownerChatId ?? ''), + ownerName ?? '', + ownerUsername ?? '', + isEnabled ? 1 : 0, + now(), + ); +} + +/** Возвращает подключение по conn_id (или null). owner_id/owner_chat_id — строки. */ +export function getConnection(connId) { + return ( + openDb().prepare('SELECT * FROM connections WHERE conn_id = ?').get(String(connId ?? '')) ?? null + ); +} + +/** Кладём каждое входящее в кэш — источник для восстановления удалённых. */ +export function cacheMessage({ connId, 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 (?, ?, ?, ?, ?, ?, ?, ?)`, + (conn_id, msg_id, chat_id, sender_id, sender, text_enc, media_kind, media_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( + String(connId ?? ''), msgId, String(chatId ?? ''), String(senderId ?? ''), @@ -66,10 +119,10 @@ export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind, ); } -export function getCachedMessage(chatId, msgId) { +export function getCachedMessage(connId, chatId, msgId) { const row = openDb() - .prepare('SELECT * FROM messages WHERE chat_id = ? AND msg_id = ?') - .get(String(chatId ?? ''), msgId); + .prepare('SELECT * FROM messages WHERE conn_id = ? AND chat_id = ? AND msg_id = ?') + .get(String(connId ?? ''), String(chatId ?? ''), msgId); return row ? decodeRow(row) : null; } @@ -78,10 +131,11 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?)`, + (owner_id, kind, chat_id, sender_id, sender, text_enc, media_kind, media_path, captured_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( + String(data.ownerId ?? ''), kind, String(data.chatId ?? ''), String(data.senderId ?? ''), @@ -94,24 +148,39 @@ export function saveCapture(kind, data) { 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]; +/** Лента перехватов конкретного владельца (строгая изоляция между пользователями). */ +export function listCaptures({ ownerId, kind, limit = 10, offset = 0 } = {}) { + const where = ['owner_id = ?']; + const args = [String(ownerId ?? '')]; + if (kind) { + where.push('kind = ?'); + args.push(kind); + } + args.push(limit, offset); return openDb() - .prepare(`SELECT * FROM captures ${where} ORDER BY captured_at DESC LIMIT ? OFFSET ?`) + .prepare( + `SELECT * FROM captures WHERE ${where.join(' AND ')} 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); +/** Одна запись — только если принадлежит запросившему владельцу. */ +export function getCapture(id, ownerId) { + const row = openDb() + .prepare('SELECT * FROM captures WHERE id = ? AND owner_id = ?') + .get(id, String(ownerId ?? '')); 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; +export function countCaptures(ownerId, kind) { + const where = ['owner_id = ?']; + const args = [String(ownerId ?? '')]; + if (kind) { + where.push('kind = ?'); + args.push(kind); + } + return openDb().prepare(`SELECT COUNT(*) AS n FROM captures WHERE ${where.join(' AND ')}`).get(...args).n; } /** Чистка по retentionDays — вызывается по таймеру из index.js. */ diff --git a/src/core/media.js b/src/core/media.js index 1f453e1..27700c7 100644 --- a/src/core/media.js +++ b/src/core/media.js @@ -29,8 +29,13 @@ export function detectMediaFromBusiness(msg) { * Возвращает имя файла (как хранится в БД) либо null. * getFile отдаёт файлы до ~20 МБ — на бо́льших вернётся ошибка, её ловим. * Качать нужно в момент прихода: при удалении file_id уже не приходит. + * + * key = { connId, chatId, msgId, tag } — уникализирует имя файла между + * подключениями и чатами (мультитенант): msg_id уникален только внутри чата, + * поэтому без connId/chatId два владельца могли бы затереть медиа друг друга. */ -export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') { +export async function downloadEncrypted(api, fileId, key = {}) { + const { connId = '', chatId = '', msgId = 'x', tag = 'm' } = key; try { const file = await api.getFile(fileId); // { file_path, ... } или бросит на >20 МБ if (!file?.file_path) return null; @@ -42,7 +47,7 @@ export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') { if (!buffer.length) return null; mkdirSync(mediaPath(), { recursive: true }); - const name = `${tag}_${msgId}_${buffer.length}.enc`; + const name = `${tag}_${seg(connId)}_${seg(chatId)}_${seg(msgId)}_${buffer.length}.enc`; const full = fileURLToPath(new URL(name, config.mediaDir)); await writeFile(full, encrypt(buffer)); logger.debug(`media: сохранено -> ${name} (${buffer.length} б)`); @@ -53,6 +58,11 @@ export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') { } } +/** Делает сегмент имени файла безопасным: минус → 'm', прочее не-словесное убираем. */ +function seg(v) { + return String(v).replace(/-/g, 'm').replace(/[^A-Za-z0-9]+/g, '') || 'x'; +} + /** Читает зашифрованный медиа-файл с диска по имени (как хранится в БД). */ export function readMedia(name) { return readFile(fileURLToPath(new URL(name, config.mediaDir))); diff --git a/src/modules/antidelete.js b/src/modules/antidelete.js index 4751501..c412846 100644 --- a/src/modules/antidelete.js +++ b/src/modules/antidelete.js @@ -4,21 +4,23 @@ import { logger } from '../core/logger.js'; /** * Ловит удаления. Telegram в событии deleted_business_messages присылает ТОЛЬКО * id сообщений и чат — без содержимого. Поэтому восстанавливаем из кэша, который - * наполняет модуль [cache], по паре (chatId, msgId). Нашли — значит это было - * входящее в личке: сохраняем как перехват и уведомляем владельца. + * наполняет модуль [cache], по тройке (connId, chatId, msgId). Нашли — значит это + * было входящее в личке: сохраняем как перехват владельца и уведомляем его. */ export default { name: 'antidelete', description: 'Сохраняет удалённые собеседником сообщения и медиа', - async onDeleted({ chatId, msgIds }, ctx) { + async onDeleted({ connId, ownerId, ownerChatId, chatId, msgIds }, ctx) { if (!msgIds?.length) return; for (const id of msgIds) { - const cached = getCachedMessage(chatId, id); + const cached = getCachedMessage(connId, chatId, id); if (!cached) continue; // не наше/не кэшировали — пропускаем await ctx.capture('deleted', { + ownerId, + ownerChatId, chatId: cached.chat_id, senderId: cached.sender_id, sender: cached.sender, diff --git a/src/modules/cache.js b/src/modules/cache.js index 4c5b00d..52cde15 100644 --- a/src/modules/cache.js +++ b/src/modules/cache.js @@ -19,12 +19,18 @@ export default { let mediaPath = null; if (msg.media && config.cacheMedia) { mediaKind = msg.media.kind; - mediaPath = await downloadEncrypted(ctx.api, msg.media.fileId, 'cache', msg.msgId); + mediaPath = await downloadEncrypted(ctx.api, msg.media.fileId, { + connId: msg.connId, + chatId: msg.chatId, + msgId: msg.msgId, + tag: 'cache', + }); } else if (msg.media) { mediaKind = msg.media.kind; // знаем, что медиа было, но файл не кэшируем } cacheMessage({ + connId: msg.connId, msgId: msg.msgId, chatId: msg.chatId, senderId: msg.senderId,