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 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 22:16:44 +03:00
parent 8bd13b4dd5
commit 0e19c84a6d
14 changed files with 385 additions and 169 deletions
-50
View File
@@ -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)"
]
}
}
+6 -3
View File
@@ -1,14 +1,17 @@
# === Бот (единственный интерфейс + приёмник бизнес-апдейтов) === # === Бот (единственный интерфейс + приёмник бизнес-апдейтов) ===
# Токен от @BotFather. Обязательно включите боту режим бизнеса: # Токен от @BotFather. Обязательно включите боту режим бизнеса:
# @BotFather → /mybots → выбрать бота → Bot Settings → Business Mode → Enable. # @BotFather → /mybots → выбрать бота → Bot Settings → Business Mode → Enable.
# Затем в приложении: Настройки → Telegram для бизнеса → Чат-боты → добавить бота. # Бот доступен всем: любой может подключить его к СВОЕМУ бизнес-аккаунту
# Сессия НЕ нужна — работаем только через официальное бизнес-подключение. # (Настройки → Telegram для бизнеса → Чат-боты). Сессия НЕ нужна.
BOT_TOKEN= BOT_TOKEN=
# Ваш Telegram id (узнать: напишите @userinfobot). Только вы управляете ботом. # (Необязательно) Telegram id «оператора» бота — только для служебных логов.
# Доступа к чужим данным НЕ даёт: каждый видит лишь свои перехваты (строгая
# изоляция по владельцу). Можно оставить пустым.
OWNER_ID= OWNER_ID=
# === Локальное хранилище === # === Локальное хранилище ===
# Ключ шифрования: 64 hex-символа ИЛИ длинная парольная фраза (≥32 байт). # Ключ шифрования: 64 hex-символа ИЛИ длинная парольная фраза (≥32 байт).
# Одним ключом шифруются данные ВСЕХ подключившихся — храните его в секрете.
# Сгенерировать hex: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Сгенерировать hex: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY= ENCRYPTION_KEY=
# Сколько дней хранить перехваченное (0 — бессрочно) # Сколько дней хранить перехваченное (0 — бессрочно)
+2
View File
@@ -5,3 +5,5 @@ data/
# Сторонний код (изучался как референс, в проект не входит) # Сторонний код (изучался как референс, в проект не входит)
Hcrgram/ Hcrgram/
.claude/settings.local.json
+24 -19
View File
@@ -1,14 +1,14 @@
# telegrambusiness # telegrambusiness
Личный Telegram-бот на Node.js, работающий через **официальное бизнес-подключение** Telegram (Business API). Бот подключается к вашему аккаунту как чат-бот для бизнеса и сохраняет то, что собеседник удалил: Telegram-бот на Node.js, работающий через **официальное бизнес-подключение** Telegram (Business API). Бот **доступен всем**: любой человек подключает его к **своему** бизнес-аккаунту как чат-бота и получает сохранение того, что собеседник удалил:
- **antidelete** — сообщения (и медиа), которые собеседник удалил в личке. - **antidelete** — сообщения (и медиа), которые собеседник удалил в личке.
Всё управление и все перехваты приходят в этого же бота — в личку владельца. Наружу бот ничего не пишет, слеш-команд в чатах нет. Бот многопользовательский. При подключении он присылает владельцу **красочное приветствие** с описанием функций, а все перехваты приходят этому же владельцу в личку. Данные строго изолированы: **каждый видит только свои** перехваты. Наружу бот ничего не пишет, слеш-команд в чужих чатах нет.
> ✅ **Без сессии, по правилам Telegram.** Проект **не хранит строку сессии** и не использует MTProto-юзербот. Работает исключительно через штатную функцию «Telegram для бизнеса» — обычный бот от [@BotFather](https://t.me/BotFather) с включённым Business Mode. Это соответствует ToS. > ✅ **Без сессии, по правилам Telegram.** Проект **не хранит строку сессии** и не использует MTProto-юзербот. Работает исключительно через штатную функцию «Telegram для бизнеса» — обычный бот от [@BotFather](https://t.me/BotFather) с включённым Business Mode. Это соответствует ToS.
> ⚠️ **Дисклеймер.** Инструмент для личного использования на **своём** аккаунте и своей переписке. Не применяйте против других людей. > ⚠️ **Дисклеймер.** Инструмент сохраняет входящие в личке того, кто подключил бота к своему аккаунту. Оператор бота, у которого лежат `data/` и `ENCRYPTION_KEY`, технически имеет доступ к расшифровке. Разворачивайте и используйте ответственно, уважая приватность собеседников.
## Как устроено (важно понимать до запуска) ## Как устроено (важно понимать до запуска)
@@ -36,13 +36,12 @@ cp .env.example .env
Заполните `.env`: Заполните `.env`:
1. **BOT_TOKEN** — новый бот у [@BotFather](https://t.me/BotFather). 1. **BOT_TOKEN** — новый бот у [@BotFather](https://t.me/BotFather).
2. **OWNER_ID** — ваш id (напишите [@userinfobot](https://t.me/userinfobot)). 2. **ENCRYPTION_KEY** — сгенерируйте: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
3. **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**. 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 npm run dev
``` ```
Откройте своего бота, нажмите **Start** — появится панель. Когда собеседник удалит сообщение, бот пришлёт уведомление с кнопкой «Открыть». Дальше это делает **любой пользователь** для своего аккаунта: **Настройки → Telegram для бизнеса → Чат-боты** → добавить бота и разрешить управление сообщениями. Сразу после подключения бот пришлёт в личку приветствие. Когда собеседник удалит сообщение, придёт уведомление с кнопкой «Открыть». Команда **/start** в любой момент откроет приветствие и личную ленту перехватов.
`npm run dev` — с автоперезапуском, `npm start` — обычный запуск. `npm run dev` — с автоперезапуском, `npm start` — обычный запуск.
@@ -68,6 +67,8 @@ export default {
if (!msg.isPrivateIncoming) return; if (!msg.isPrivateIncoming) return;
if (/срочно/i.test(msg.text ?? '')) { if (/срочно/i.test(msg.text ?? '')) {
await ctx.capture('deleted', { // переиспользуем ленту перехватов await ctx.capture('deleted', { // переиспользуем ленту перехватов
ownerId: msg.ownerId, // чья это лента (изоляция)
ownerChatId: msg.ownerChatId, // куда уведомлять
chatId: msg.chatId, chatId: msg.chatId,
senderId: msg.senderId, senderId: msg.senderId,
sender: msg.sender, 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)` | Один раз при старте | | `setup(_, ctx)` | Один раз при старте |
| `onMessage(msg, ctx)` | Новое бизнес-сообщение (`business_message`) | | `onMessage(msg, ctx)` | Новое бизнес-сообщение (`business_message`) |
| `onEdited(msg, ctx)` | Правка (`edited_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 registry.js реестр модулей, runHook
logger.js logger.js
business/ business/
events.js подписка business_message/edited/deleted → модули events.js подписка business_message/edited/deleted/connection → модули
connections.js резолвер владельца по business_connection_id
context.js ctx.capture — мост модуль↔БД↔бот context.js ctx.capture — мост модуль↔БД↔бот
modules/ modules/
cache.js кэширует входящие (основа antidelete) cache.js кэширует входящие (основа antidelete)
antidelete.js удаление → достать из кэша → перехват antidelete.js удаление → достать из кэша → перехват
bot/ 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)). - **Многопользовательский режим.** Бота подключают к себе разные люди; данные строго изолированы по владельцу (`owner_id`), в панели каждый видит только своё.
- Id сообщения в Bot API уникален **только внутри чата**, поэтому в БД составной ключ `(chat_id, msg_id)`. - У бизнес-сообщений **нет флага «исходящее»**, поэтому своё сообщение владельца отсекаем сравнением `from.id` с владельцем подключения (`business_connection_id` → владелец, см. [connections.js](src/business/connections.js)). Если владельца распознать не удалось, сообщение не кэшируется.
- Если вы отключите бизнес-подключение в настройках Telegram, перехват приостановится (бот залогирует это по апдейту `business_connection`). - Перехватываются только **входящие в личных диалогах** (см. `isPrivateIncoming` в [events.js](src/business/events.js)).
- Id сообщения в Bot API уникален **только внутри чата**, поэтому в БД составной ключ `(conn_id, chat_id, msg_id)`.
- Если владелец отключит бизнес-подключение в настройках Telegram, перехват для него приостановится (бот залогирует это по апдейту `business_connection`).
- `CACHE_MEDIA=1` качает медиа каждого входящего, чтобы восстанавливать удалённые картинки. Это ест диск — при `=0` останется только текст и пометка о типе медиа. - `CACHE_MEDIA=1` качает медиа каждого входящего, чтобы восстанавливать удалённые картинки. Это ест диск — при `=0` останется только текст и пометка о типе медиа.
- Ключ шифрования — в `.env`. Потеряете ключ — расшифровать хранилище нельзя. - Ключ шифрования — в `.env`, **один на все подключения**. Потеряете ключ — расшифровать хранилище нельзя.
- Хранилище локальное; `data/` в `.gitignore`. - Хранилище локальное; `data/` в `.gitignore`. Схема БД сменилась на мультитенантную — при обновлении со старой версии удалите прежнюю `data/` перед первым запуском.
+29 -33
View File
@@ -1,51 +1,42 @@
import { Bot, InlineKeyboard, InputFile } from 'grammy'; import { Bot, InlineKeyboard, InputFile } from 'grammy';
import { config, isOwner } from '../config.js'; import { config } from '../config.js';
import { decrypt } from '../core/crypto.js'; import { decrypt } from '../core/crypto.js';
import { countCaptures, getCapture, listCaptures } from '../core/db.js'; import { countCaptures, getCapture, listCaptures } from '../core/db.js';
import { logger } from '../core/logger.js'; import { logger } from '../core/logger.js';
import { readMedia } from '../core/media.js'; import { readMedia } from '../core/media.js';
import { welcomeText, WELCOME_PARSE_MODE } from './welcome.js';
const PAGE = 5; const PAGE = 5;
const KIND_TITLE = { deleted: '🗑 Удалённые' }; const KIND_TITLE = { deleted: '🗑 Удалённые' };
/** Бизнес-апдейт от подключённого аккаунта: у него ctx.from — собеседник, не владелец. */
function isBusinessUpdate(ctx) {
return Boolean(
ctx.businessMessage ??
ctx.editedBusinessMessage ??
ctx.deletedBusinessMessages ??
ctx.businessConnection,
);
}
/** /**
* Компаньон-бот — единственный интерфейс юзербота. Всё управление и все * Бот — интерфейс для всех. Любой может открыть его, нажать Start и получить
* перехваты живут здесь, в личке владельца. Никаких слеш-команд снаружи: * приветствие и СВОЮ ленту перехватов. Строгая изоляция: перехваты фильтруются
* бот отвечает только владельцу (OWNER_ID), любому другому — молчит. * по id того, кто обратился (ctx.from.id) — чужого не видно.
*
* Бизнес-апд엘ты (business_message/deleted/connection) сюда не попадают —
* их обрабатывает wireBusiness через bot.on(...). Панель живёт на обычных
* message/callback_query, поэтому барьер доступа не нужен.
*/ */
export function createBot() { export function createBot() {
const bot = new Bot(config.botToken()); const bot = new Bot(config.botToken());
// Жёсткий барьер: панельные апдейты обрабатываем только от владельца. bot.command('start', async (ctx) => {
// Бизнес-апдейты (business_message/deleted/…) пропускаем — у них ctx.from const text = welcomeText({ name: ctx.from?.first_name });
// это собеседник, а не владелец; их разбирает wireBusiness отдельно. await ctx.reply(text, { parse_mode: WELCOME_PARSE_MODE, reply_markup: mainMenu(ctx.from.id) });
bot.use(async (ctx, next) => {
if (isBusinessUpdate(ctx) || 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(ctx.from.id)));
bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель юзербота.', mainMenu()));
// Списки перехватов с пагинацией. // Списки перехватов с пагинацией (только свои).
bot.callbackQuery(/^feed:(\w+):(\d+)$/, async (ctx) => { bot.callbackQuery(/^feed:(\w+):(\d+)$/, async (ctx) => {
const [, kind, pageRaw] = ctx.match; const [, kind, pageRaw] = ctx.match;
await renderFeed(ctx, kind, Number(pageRaw)); await renderFeed(ctx, kind, Number(pageRaw));
await ctx.answerCallbackQuery(); await ctx.answerCallbackQuery();
}); });
// Открыть одну запись — с медиа, если оно есть. // Открыть одну запись — с медиа, если оно есть (только свою).
bot.callbackQuery(/^item:(\d+)$/, async (ctx) => { bot.callbackQuery(/^item:(\d+)$/, async (ctx) => {
await renderItem(ctx, Number(ctx.match[1])); await renderItem(ctx, Number(ctx.match[1]));
await ctx.answerCallbackQuery(); await ctx.answerCallbackQuery();
@@ -55,9 +46,8 @@ export function createBot() {
return bot; return bot;
} }
function mainMenu() { function mainMenu(ownerId) {
return new InlineKeyboard() return new InlineKeyboard().text(`🗑 Удалённые (${countCaptures(ownerId, 'deleted')})`, 'feed:deleted:0');
.text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0');
} }
function feedKeyboard(kind, page, items, total) { function feedKeyboard(kind, page, items, total) {
@@ -74,8 +64,9 @@ function feedKeyboard(kind, page, items, total) {
} }
async function renderFeed(ctx, kind, page) { async function renderFeed(ctx, kind, page) {
const total = countCaptures(kind); const ownerId = ctx.from.id;
const items = listCaptures({ kind, limit: PAGE, offset: page * PAGE }); const total = countCaptures(ownerId, kind);
const items = listCaptures({ ownerId, kind, limit: PAGE, offset: page * PAGE });
const title = KIND_TITLE[kind] ?? kind; const title = KIND_TITLE[kind] ?? kind;
const body = items.length const body = items.length
? `${title} — стр. ${page + 1}/${Math.max(1, Math.ceil(total / PAGE))}` ? `${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) { async function renderItem(ctx, id) {
const item = getCapture(id); const item = getCapture(id, ctx.from.id);
if (!item) return edit(ctx, 'Запись не найдена (возможно, удалена по сроку хранения).', backTo('home')); if (!item) return edit(ctx, 'Запись не найдена (возможно, удалена по сроку хранения).', backTo('home'));
const caption = formatCapture(item); 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) { 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 kb = new InlineKeyboard().text('Открыть', `item:${item.id}`);
const head = '🗑 Удалённое сообщение'; const head = '🗑 Удалённое сообщение';
const text = `${head} от ${item.sender || 'неизвестно'}\n${item.media_kind ? `[${item.media_kind}] ` : ''}${truncate(item.text ?? '', 60)}`; const text = `${head} от ${item.sender || 'неизвестно'}\n${item.media_kind ? `[${item.media_kind}] ` : ''}${truncate(item.text ?? '', 60)}`;
try { try {
await bot.api.sendMessage(config.ownerId(), text, { reply_markup: kb }); await bot.api.sendMessage(item.ownerChatId, text, { reply_markup: kb });
} catch (err) { } catch (err) {
logger.warn('bot: не удалось уведомить владельца', err.message); logger.warn('bot: не удалось уведомить владельца', err.message);
} }
+63
View File
@@ -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');
}
+67
View File
@@ -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;
}
+2 -1
View File
@@ -13,7 +13,8 @@ export function createContext({ bot, registry }) {
/** /**
* Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted'. * Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted'.
* data: { chatId, senderId, sender, text, mediaKind, mediaPath } * data: { ownerId, ownerChatId, chatId, senderId, sender, text, mediaKind, mediaPath }
* ownerId — чей это перехват (изоляция ленты), ownerChatId — куда уведомлять.
*/ */
async capture(kind, data) { async capture(kind, data) {
const id = saveCapture(kind, data); const id = saveCapture(kind, data);
+60 -20
View File
@@ -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 { detectMediaFromBusiness } from '../core/media.js';
import { logger } from '../core/logger.js'; import { logger } from '../core/logger.js';
import { runHook } from '../core/registry.js'; import { runHook } from '../core/registry.js';
import { rememberConnection, resolveOwner } from './connections.js';
/** /**
* Подписывает бота на бизнес-апдейты Telegram и раздаёт их модулям через хуки. * Подписывает бота на бизнес-апдейты Telegram и раздаёт их модулям через хуки.
* Работает через официальное бизнес-подключение (Настройки → Telegram для * Работает через официальное бизнес-подключение (Настройки → Telegram для
* бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённого аккаунта. * бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённых аккаунтов.
*
* Мультитенант: бота подключают разные владельцы. У бизнес-сообщений НЕТ флага
* «исходящее», поэтому по business_connection_id восстанавливаем владельца и
* сравниваем with from.id — так отсекаем собственные сообщения владельца. Владелец
* нужен и для адресации: уведомления/приветствие шлём в его личку (owner_chat_id).
* *
* Событие удаления (deleted_business_messages) несёт только id сообщений и чат — * Событие удаления (deleted_business_messages) несёт только id сообщений и чат —
* без содержимого. Поэтому модуль cache складывает каждое входящее заранее, * без содержимого. Поэтому модуль cache складывает каждое входящее заранее,
* а antidelete достаёт его из кэша по (chatId, msgId). * а antidelete достаёт его из кэша по (connId, chatId, msgId).
*/ */
export function wireBusiness(bot, registry, ctx) { export function wireBusiness(bot, registry, ctx) {
bot.on('business_message', (gctx) => bot.on('business_message', async (gctx) => {
runHook(registry, 'onMessage', normalizeBusinessMessage(gctx), ctx), const msg = await normalizeBusinessMessage(gctx);
); return runHook(registry, 'onMessage', msg, ctx);
});
bot.on('edited_business_message', (gctx) => bot.on('edited_business_message', async (gctx) => {
runHook(registry, 'onEdited', normalizeBusinessMessage(gctx), ctx), 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 d = gctx.deletedBusinessMessages;
const connId = d.business_connection_id != null ? String(d.business_connection_id) : '';
const owner = await resolveOwner(connId, gctx.api);
return runHook( return runHook(
registry, registry,
'onDeleted', '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, ctx,
); );
}); });
bot.on('business_connection', (gctx) => { // Подключение/отключение бота владельцем. Здесь же — красочное приветствие.
bot.on('business_connection', async (gctx) => {
const c = gctx.businessConnection; const c = gctx.businessConnection;
if (c?.is_enabled) { if (!c) return;
logger.info(`Бизнес-подключение активно (id ${c.id})`); const record = rememberConnection(c);
if (c.is_enabled) {
logger.info(`Бизнес-подключение активно (владелец ${record.ownerId || '?'}, id ${c.id})`);
await greetOwner(gctx.api, record);
} else { } else {
logger.warn(`Бизнес-подключение отключено владельцем (id ${c?.id}). Перехват приостановлен.`); logger.warn(`Бизнес-подключение отключено (id ${c.id}). Перехват для владельца приостановлен.`);
} }
}); });
logger.debug('бизнес-апдейты подписаны: message / edited / deleted / connection'); 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-контекст бизнес-сообщения к плоскому виду, понятному модулям. * Приводит grammY-контекст бизнес-сообщения к плоскому виду, понятному модулям,
* isPrivateIncoming — входящее в личном 1-на-1 чате, не от самого владельца. * и подмешивает владельца подключения (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 msg = gctx.businessMessage ?? gctx.editedBusinessMessage ?? gctx.msg;
const from = msg?.from; const from = msg?.from;
const chat = msg?.chat; 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 { return {
connId,
ownerId,
ownerChatId: owner?.ownerChatId ?? '',
chatId: chat?.id, chatId: chat?.id,
msgId: msg?.message_id, msgId: msg?.message_id,
senderId: from?.id, senderId: from?.id,
sender: senderLabel(from), sender: senderLabel(from),
text: msg?.text ?? msg?.caption ?? '', text: msg?.text ?? msg?.caption ?? '',
media: detectMediaFromBusiness(msg), media: detectMediaFromBusiness(msg),
// Свои исходящие отсекаем через isOwner (как раньше через message.out). isPrivateIncoming: chat?.type === 'private' && ownerId !== '' && String(from?.id) !== ownerId,
isPrivateIncoming: chat?.type === 'private' && !isOwner(from?.id),
}; };
} }
+9 -7
View File
@@ -16,13 +16,18 @@ const num = (name) => {
* Конфиг бота. Работает через официальное бизнес-подключение Telegram — * Конфиг бота. Работает через официальное бизнес-подключение Telegram —
* никакой строки сессии, только токен бота от @BotFather. Всё чувствительное * никакой строки сессии, только токен бота от @BotFather. Всё чувствительное
* (токен, ключ шифрования) живёт только в .env и никуда не уходит. * (токен, ключ шифрования) живёт только в .env и никуда не уходит.
*
* Мультитенант: любой может подключить бота к своему бизнес-аккаунту. Доступ
* к перехватам изолирован по владельцу (см. db.js), поэтому OWNER_ID больше
* ничего не открывает и не обязателен.
*/ */
export const config = { export const config = {
// Бот-панель (@BotFather). К нему же владелец подключает бизнес-аккаунт // Бот (@BotFather, Business Mode). К нему пользователи подключают свои
// (Настройки → Telegram для бизнеса → Чат-боты). Единственный UI. // бизнес-аккаунты (Настройки → Telegram для бизнеса → Чат-боты).
botToken: () => req('BOT_TOKEN'), botToken: () => req('BOT_TOKEN'),
// Telegram id владельца: только он управляет ботом и получает перехваты. // Необязательный id «оператора» бота — только для служебных логов, доступ
ownerId: () => num('OWNER_ID'), // к чужим данным он НЕ даёт (строгая изоляция). null, если не задан.
ownerId: () => (process.env.OWNER_ID ? num('OWNER_ID') : null),
// Ключ шифрования локального хранилища (32+ байта, hex или ascii). // Ключ шифрования локального хранилища (32+ байта, hex или ascii).
encryptionKey: () => req('ENCRYPTION_KEY'), encryptionKey: () => req('ENCRYPTION_KEY'),
@@ -45,11 +50,8 @@ export const mediaPath = () => fileURLToPath(config.mediaDir);
/** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */ /** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */
export function assertConfig() { export function assertConfig() {
config.botToken(); config.botToken();
config.ownerId();
const key = config.encryptionKey(); const key = config.encryptionKey();
if (Buffer.from(key, key.match(/^[0-9a-f]+$/i) ? 'hex' : 'utf8').length < 32) { if (Buffer.from(key, key.match(/^[0-9a-f]+$/i) ? 'hex' : 'utf8').length < 32) {
throw new Error('ENCRYPTION_KEY должен быть не короче 32 байт (64 hex-символа).'); throw new Error('ENCRYPTION_KEY должен быть не короче 32 байт (64 hex-символа).');
} }
} }
export const isOwner = (id) => Number(id) === config.ownerId();
+98 -29
View File
@@ -10,13 +10,27 @@ let db = null;
/** /**
* Локальный индекс на node:sqlite. Тексты храним зашифрованными (AES-GCM), * Локальный индекс на node:sqlite. Тексты храним зашифрованными (AES-GCM),
* бинарные медиа лежат отдельными файлами в data/media, здесь только путь. * бинарные медиа лежат отдельными файлами в data/media, здесь только путь.
*
* Мультитенант: бот подключают к своим бизнес-аккаунтам разные люди. Каждое
* подключение — строка в connections (conn_id → владелец). Кэш входящих и
* перехваты привязаны к владельцу, поэтому в панели каждый видит только своё.
*/ */
export function openDb() { export function openDb() {
if (db) return db; if (db) return db;
mkdirSync(dataPath(), { recursive: true }); mkdirSync(dataPath(), { recursive: true });
db = new DatabaseSync(`${dataPath()}/business.db`); db = new DatabaseSync(`${dataPath()}/business.db`);
db.exec(` 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 ( CREATE TABLE IF NOT EXISTS messages (
conn_id TEXT,
msg_id INTEGER, msg_id INTEGER,
chat_id TEXT, chat_id TEXT,
sender_id TEXT, sender_id TEXT,
@@ -25,20 +39,21 @@ export function openDb() {
media_kind TEXT, media_kind TEXT,
media_path TEXT, media_path TEXT,
created_at INTEGER, created_at INTEGER,
PRIMARY KEY (chat_id, msg_id) PRIMARY KEY (conn_id, chat_id, msg_id)
); );
CREATE TABLE IF NOT EXISTS captures ( CREATE TABLE IF NOT EXISTS captures (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT, -- 'deleted' owner_id TEXT,
chat_id TEXT, kind TEXT, -- 'deleted'
sender_id TEXT, chat_id TEXT,
sender TEXT, sender_id TEXT,
text_enc TEXT, sender TEXT,
media_kind TEXT, text_enc TEXT,
media_path TEXT, media_kind TEXT,
media_path TEXT,
captured_at INTEGER 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: база готова'); logger.debug('sqlite: база готова');
return db; return db;
@@ -46,15 +61,53 @@ export function openDb() {
const now = () => Math.floor(Date.now() / 1000); 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() openDb()
.prepare( .prepare(
`INSERT OR REPLACE INTO messages `INSERT OR REPLACE INTO messages
(msg_id, chat_id, sender_id, sender, text_enc, media_kind, media_path, created_at) (conn_id, msg_id, chat_id, sender_id, sender, text_enc, media_kind, media_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
String(connId ?? ''),
msgId, msgId,
String(chatId ?? ''), String(chatId ?? ''),
String(senderId ?? ''), 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() const row = openDb()
.prepare('SELECT * FROM messages WHERE chat_id = ? AND msg_id = ?') .prepare('SELECT * FROM messages WHERE conn_id = ? AND chat_id = ? AND msg_id = ?')
.get(String(chatId ?? ''), msgId); .get(String(connId ?? ''), String(chatId ?? ''), msgId);
return row ? decodeRow(row) : null; return row ? decodeRow(row) : null;
} }
@@ -78,10 +131,11 @@ export function saveCapture(kind, data) {
const info = openDb() const info = openDb()
.prepare( .prepare(
`INSERT INTO captures `INSERT INTO captures
(kind, chat_id, sender_id, sender, text_enc, media_kind, media_path, captured_at) (owner_id, kind, chat_id, sender_id, sender, text_enc, media_kind, media_path, captured_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
String(data.ownerId ?? ''),
kind, kind,
String(data.chatId ?? ''), String(data.chatId ?? ''),
String(data.senderId ?? ''), String(data.senderId ?? ''),
@@ -94,24 +148,39 @@ export function saveCapture(kind, data) {
return Number(info.lastInsertRowid); return Number(info.lastInsertRowid);
} }
export function listCaptures({ kind, limit = 10, offset = 0 } = {}) { /** Лента перехватов конкретного владельца (строгая изоляция между пользователями). */
const where = kind ? 'WHERE kind = ?' : ''; export function listCaptures({ ownerId, kind, limit = 10, offset = 0 } = {}) {
const args = kind ? [kind, limit, offset] : [limit, offset]; const where = ['owner_id = ?'];
const args = [String(ownerId ?? '')];
if (kind) {
where.push('kind = ?');
args.push(kind);
}
args.push(limit, offset);
return openDb() 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) .all(...args)
.map(decodeRow); .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; return row ? decodeRow(row) : null;
} }
export function countCaptures(kind) { export function countCaptures(ownerId, kind) {
const where = kind ? 'WHERE kind = ?' : ''; const where = ['owner_id = ?'];
const args = kind ? [kind] : []; const args = [String(ownerId ?? '')];
return openDb().prepare(`SELECT COUNT(*) AS n FROM captures ${where}`).get(...args).n; 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. */ /** Чистка по retentionDays — вызывается по таймеру из index.js. */
+12 -2
View File
@@ -29,8 +29,13 @@ export function detectMediaFromBusiness(msg) {
* Возвращает имя файла (как хранится в БД) либо null. * Возвращает имя файла (как хранится в БД) либо null.
* getFile отдаёт файлы до ~20 МБ — на бо́льших вернётся ошибка, её ловим. * getFile отдаёт файлы до ~20 МБ — на бо́льших вернётся ошибка, её ловим.
* Качать нужно в момент прихода: при удалении file_id уже не приходит. * Качать нужно в момент прихода: при удалении 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 { try {
const file = await api.getFile(fileId); // { file_path, ... } или бросит на >20 МБ const file = await api.getFile(fileId); // { file_path, ... } или бросит на >20 МБ
if (!file?.file_path) return null; 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; if (!buffer.length) return null;
mkdirSync(mediaPath(), { recursive: true }); 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)); const full = fileURLToPath(new URL(name, config.mediaDir));
await writeFile(full, encrypt(buffer)); await writeFile(full, encrypt(buffer));
logger.debug(`media: сохранено -> ${name} (${buffer.length} б)`); 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) { export function readMedia(name) {
return readFile(fileURLToPath(new URL(name, config.mediaDir))); return readFile(fileURLToPath(new URL(name, config.mediaDir)));
+6 -4
View File
@@ -4,21 +4,23 @@ import { logger } from '../core/logger.js';
/** /**
* Ловит удаления. Telegram в событии deleted_business_messages присылает ТОЛЬКО * Ловит удаления. Telegram в событии deleted_business_messages присылает ТОЛЬКО
* id сообщений и чат — без содержимого. Поэтому восстанавливаем из кэша, который * id сообщений и чат — без содержимого. Поэтому восстанавливаем из кэша, который
* наполняет модуль [cache], по паре (chatId, msgId). Нашли — значит это было * наполняет модуль [cache], по тройке (connId, chatId, msgId). Нашли — значит это
* входящее в личке: сохраняем как перехват и уведомляем владельца. * было входящее в личке: сохраняем как перехват владельца и уведомляем его.
*/ */
export default { export default {
name: 'antidelete', name: 'antidelete',
description: 'Сохраняет удалённые собеседником сообщения и медиа', description: 'Сохраняет удалённые собеседником сообщения и медиа',
async onDeleted({ chatId, msgIds }, ctx) { async onDeleted({ connId, ownerId, ownerChatId, chatId, msgIds }, ctx) {
if (!msgIds?.length) return; if (!msgIds?.length) return;
for (const id of msgIds) { for (const id of msgIds) {
const cached = getCachedMessage(chatId, id); const cached = getCachedMessage(connId, chatId, id);
if (!cached) continue; // не наше/не кэшировали — пропускаем if (!cached) continue; // не наше/не кэшировали — пропускаем
await ctx.capture('deleted', { await ctx.capture('deleted', {
ownerId,
ownerChatId,
chatId: cached.chat_id, chatId: cached.chat_id,
senderId: cached.sender_id, senderId: cached.sender_id,
sender: cached.sender, sender: cached.sender,
+7 -1
View File
@@ -19,12 +19,18 @@ export default {
let mediaPath = null; let mediaPath = null;
if (msg.media && config.cacheMedia) { if (msg.media && config.cacheMedia) {
mediaKind = msg.media.kind; 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) { } else if (msg.media) {
mediaKind = msg.media.kind; // знаем, что медиа было, но файл не кэшируем mediaKind = msg.media.kind; // знаем, что медиа было, но файл не кэшируем
} }
cacheMessage({ cacheMessage({
connId: msg.connId,
msgId: msg.msgId, msgId: msg.msgId,
chatId: msg.chatId, chatId: msg.chatId,
senderId: msg.senderId, senderId: msg.senderId,