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:
@@ -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
@@ -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 — бессрочно)
|
||||
|
||||
@@ -5,3 +5,5 @@ data/
|
||||
|
||||
# Сторонний код (изучался как референс, в проект не входит)
|
||||
Hcrgram/
|
||||
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -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/` перед первым запуском.
|
||||
|
||||
+29
-33
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+60
-20
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -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();
|
||||
|
||||
+98
-29
@@ -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. */
|
||||
|
||||
+12
-2
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user