Compare commits

...

3 Commits

Author SHA1 Message Date
ros 0e19c84a6d 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>
2026-08-09 22:16:44 +03:00
ros 8bd13b4dd5 Перевести проект на Telegram Business (без хранения сессии)
Смена архитектуры с MTProto-юзербота (GramJS + строка сессии) на
официальное бизнес-подключение Telegram (Bot API). Проект больше НЕ
хранит сессию — работает только через бота с включённым Business Mode,
подключённого в Настройках → Telegram для бизнеса → Чат-боты. Это
соответствует ToS.

Изменения:
- удалено: scripts/login.js, src/userbot/client.js, модуль onetime
- src/userbot/ → src/business/: events.js подписывает business_message /
  edited / deleted / connection и нормализует сообщение; context.js даёт
  ctx.api = bot.api
- core/media.js: скачивание через getFile + fetch вместо GramJS
- core/db.js: составной ключ PRIMARY KEY (chat_id, msg_id) — в Bot API
  message_id уникален только внутри чата
- bot/panel.js: барьер владельца пропускает бизнес-апдейты (у них ctx.from
  это собеседник); убран UI одноразовых
- index.js: сборка только на grammY, явный allowed_updates с бизнес-типами
- config.js: убраны API_ID/API_HASH/SESSION, проверки на старте почищены
- package.json: убраны зависимости telegram и input, скрипт login
- README и .env.example переписаны под бизнес-подключение

Границы: onetime невозможен через Business API (сервер отбрасывает
self-destruct до доставки боту), медиа >~20 МБ не выгружаются (лимит
getFile), перехват — только личные 1-на-1 чаты.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:00:22 +03:00
ros 0d1af5b50c chore: обновить allowlist разрешений Claude Code
Добавлены разрешения на git commit, WebFetch к докам (core.telegram.org,
grammy.dev, github.com, telegram.org) и вспомогательные команды.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 19:35:08 +03:00
21 changed files with 562 additions and 1245 deletions
-21
View File
@@ -1,21 +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 $?\\) ===\")"
]
}
}
+9 -10
View File
@@ -1,18 +1,17 @@
# === MTProto (юзербот) === # === Бот (единственный интерфейс + приёмник бизнес-апдейтов) ===
# Получить на https://my.telegram.org → API development tools # Токен от @BotFather. Обязательно включите боту режим бизнеса:
API_ID= # @BotFather → /mybots → выбрать бота → Bot Settings → Business Mode → Enable.
API_HASH= # Бот доступен всем: любой может подключить его к СВОЕМУ бизнес-аккаунту
# Строка сессии. Сгенерировать: npm run login — и вставить сюда. # (Настройки → Telegram для бизнеса → Чат-боты). Сессия НЕ нужна.
SESSION=
# === Компаньон-бот (единственный интерфейс) ===
# Токен от @BotFather
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
+49 -42
View File
@@ -1,23 +1,29 @@
# telegrambusiness # telegrambusiness
Личный Telegram-**юзербот** на Node.js + MTProto (GramJS). Работает от вашего аккаунта и сохраняет то, что Bot API недоступно: Telegram-бот на Node.js, работающий через **официальное бизнес-подключение** Telegram (Business API). Бот **доступен всем**: любой человек подключает его к **своему** бизнес-аккаунту как чат-бота и получает сохранение того, что собеседник удалил:
- **antidelete** — сообщения, которые собеседник удалил в личке; - **antidelete** — сообщения (и медиа), которые собеседник удалил в личке.
- **onetime** — одноразовые («просмотр один раз») фото и видео.
Всё управление и все перехваты приходят в **компаньон-бота** — отдельного бота от [@BotFather](https://t.me/BotFather), в личку владельца. Наружу юзербот ничего не пишет, слеш-команд в чатах нет. Бот многопользовательский. При подключении он присылает владельцу **красочное приветствие** с описанием функций, а все перехваты приходят этому же владельцу в личку. Данные строго изолированы: **каждый видит только свои** перехваты. Наружу бот ничего не пишет, слеш-команд в чужих чатах нет.
> ⚠️ **Дисклеймер.** Инструмент для личного использования на **своём** аккаунте и своей переписке. Юзерботы нарушают ToS Telegram и аккаунт могут заблокировать — держите его на отдельном номере, не на основном. Одноразовые медиа отправляются с расчётом на удаление; сохраняя их, вы берёте ответственность на себя. > ✅ **Без сессии, по правилам Telegram.** Проект **не хранит строку сессии** и не использует MTProto-юзербот. Работает исключительно через штатную функцию «Telegram для бизнеса» — обычный бот от [@BotFather](https://t.me/BotFather) с включённым Business Mode. Это соответствует ToS.
> ⚠️ **Дисклеймер.** Инструмент сохраняет входящие в личке того, кто подключил бота к своему аккаунту. Оператор бота, у которого лежат `data/` и `ENCRYPTION_KEY`, технически имеет доступ к расшифровке. Разворачивайте и используйте ответственно, уважая приватность собеседников.
## Как устроено (важно понимать до запуска) ## Как устроено (важно понимать до запуска)
MTProto в событии удаления присылает **только id сообщений** — без текста и без медиа. Поэтому «сохранить удалённое» реактивно невозможно: юзербот **заранее кэширует каждое входящее** в личке, а при удалении достаёт его из своего кэша. То же с медиа — файл качается **в момент прихода**, после удаления ссылка мертва. Telegram в бизнес-событии удаления (`deleted_business_messages`) присылает **только id сообщений** — без текста и без медиа. Поэтому «сохранить удалённое» реактивно невозможно: бот **заранее кэширует каждое входящее** в личке, а при удалении достаёт его из своего кэша. То же с медиа — файл качается **в момент прихода**, после удаления ссылка мертва.
Отсюда следствия: Отсюда следствия:
- тексты и метаданные пишутся в локальную БД (`node:sqlite`), **зашифрованы** (AES-256-GCM); - тексты и метаданные пишутся в локальную БД (`node:sqlite`), **зашифрованы** (AES-256-GCM);
- медиа лежат отдельными **зашифрованными** файлами в `data/media/`; - медиа лежат отдельными **зашифрованными** файлами в `data/media/`;
- всё чистится по `RETENTION_DAYS`. - всё чистится по `RETENTION_DAYS`.
### Чего этот подход НЕ умеет
- **Одноразовые медиа** («просмотр один раз», самоуничтожающиеся) перехватить **невозможно**: сервер Bot API отбрасывает такие сообщения ещё до доставки боту. Это ограничение платформы, обойти его без юзербота нельзя — а от юзербота мы отказались сознательно.
- **Медиа больше ~20 МБ** не скачиваются: у Bot API (`getFile`) лимит на размер файла. Такое медиа сохранится только как пометка о типе.
## Установка ## Установка
Нужен Node.js ≥ 22.5 (для встроенного `node:sqlite`). Нужен Node.js ≥ 22.5 (для встроенного `node:sqlite`).
@@ -29,24 +35,21 @@ cp .env.example .env
Заполните `.env`: Заполните `.env`:
1. **API_ID / API_HASH** — [my.telegram.org](https://my.telegram.org) → API development tools. 1. **BOT_TOKEN** — новый бот у [@BotFather](https://t.me/BotFather).
2. **BOT_TOKEN** — новый бот у [@BotFather](https://t.me/BotFather) (это и есть панель). 2. **ENCRYPTION_KEY** — сгенерируйте: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
3. **OWNER_ID** — ваш id (напишите [@userinfobot](https://t.me/userinfobot)). 3. **OWNER_ID** — необязательно, только для служебных логов оператора. Доступа к чужим данным не даёт (изоляция по владельцу), можно не задавать.
4. **ENCRYPTION_KEY** — сгенерируйте: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
Затем одноразовый вход в аккаунт (телефон, код, при наличии — пароль 2FA): Включите боту режим бизнеса — этого достаточно, дальше его подключит каждый пользователь сам:
```bash 1. [@BotFather](https://t.me/BotFather) → `/mybots` → выберите бота → **Bot Settings** → **Business Mode** → **Enable**.
npm run login
```
Скрипт напечатает строку сессии — вставьте её в `.env` как `SESSION=...` и запускайте: Запуск:
```bash ```bash
npm run dev npm run dev
``` ```
Откройте своего бота, нажмите **Start** — появится панель. При удалении сообщения собеседником или получении одноразового медиа бот пришлёт уведомление с кнопкой «Открыть». Дальше это делает **любой пользователь** для своего аккаунта: **Настройки → Telegram для бизнеса → Чат-боты** → добавить бота и разрешить управление сообщениями. Сразу после подключения бот пришлёт в личку приветствие. Когда собеседник удалит сообщение, придёт уведомление с кнопкой «Открыть». Команда **/start** в любой момент откроет приветствие и личную ленту перехватов.
`npm run dev` — с автоперезапуском, `npm start` — обычный запуск. `npm run dev` — с автоперезапуском, `npm start` — обычный запуск.
@@ -60,63 +63,67 @@ export default {
name: 'keywords', name: 'keywords',
description: 'Уведомлять, если во входящем есть ключевое слово', description: 'Уведомлять, если во входящем есть ключевое слово',
async onMessage(message, ctx) { async onMessage(msg, ctx) {
if (message.out) return; // не своё if (!msg.isPrivateIncoming) return;
if (/срочно/i.test(message.text ?? '')) { if (/срочно/i.test(msg.text ?? '')) {
await ctx.capture('deleted', { // переиспользуем ленту перехватов await ctx.capture('deleted', { // переиспользуем ленту перехватов
chatId: message.chatId, ownerId: msg.ownerId, // чья это лента (изоляция)
senderId: message.senderId, ownerChatId: msg.ownerChatId, // куда уведомлять
sender: (await message.getSender().catch(() => null))?.username ?? '', chatId: msg.chatId,
text: message.text, senderId: msg.senderId,
sender: msg.sender,
text: msg.text,
}); });
} }
}, },
}; };
``` ```
Хук получает **нормализованное** сообщение с уже разрешённым владельцем подключения: `{ connId, ownerId, ownerChatId, chatId, msgId, senderId, sender, text, media, isPrivateIncoming }`.
| Хук | Когда вызывается | | Хук | Когда вызывается |
| --- | --- | | --- | --- |
| `setup(_, ctx)` | Один раз при старте | | `setup(_, ctx)` | Один раз при старте |
| `onMessage(message, ctx)` | Новое сообщение (`NewMessage`) | | `onMessage(msg, ctx)` | Новое бизнес-сообщение (`business_message`) |
| `onEdited(message, ctx)` | Правка (`EditedMessage`) | | `onEdited(msg, ctx)` | Правка (`edited_business_message`) |
| `onDeleted(event, ctx)` | Удаление; `event.deletedIds` — массив id | | `onDeleted(event, ctx)` | Удаление; `event.connId`, `event.ownerId`, `event.ownerChatId`, `event.chatId`, `event.msgIds` |
`ctx` даёт: `client` (GramJS), `me`, `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`).
## Устройство ## Устройство
``` ```
src/ src/
index.js сборка: юзербот + бот + модули + чистка index.js сборка: бот + модули + чистка
config.js .env, проверка на старте config.js .env, проверка на старте
core/ core/
crypto.js AES-256-GCM (шифр текста и медиа) crypto.js AES-256-GCM (шифр текста и медиа)
db.js node:sqlite: кэш входящих + лента перехватов db.js node:sqlite: кэш входящих + лента перехватов
media.js тип медиа, детект one-time, скачать+шифровать media.js тип медиа, скачать через getFile + шифровать
loader.js обход src/modules loader.js обход src/modules
registry.js реестр модулей, runHook registry.js реестр модулей, runHook
logger.js logger.js
userbot/ business/
client.js MTProto-клиент из строки сессии events.js подписка business_message/edited/deleted/connection → модули
events.js подписка NewMessage/Edited/Deleted → модули 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 удаление → достать из кэша → перехват
onetime.js one-time медиа → скачать → перехват
bot/ bot/
panel.js компаньон-бот: инлайн-панель, лента, выдача медиа panel.js бот: приветствие, инлайн-панель, лента, выдача медиа
scripts/ welcome.js текст красочного приветствия (/start и подключение)
login.js генерация строки сессии
``` ```
Поток: входящее → `cache`/`onetime` (кэш и/или скачивание) → при удалении `antidelete` поднимает из кэша → `ctx.capture` пишет в БД и шлёт уведомление → панель показывает и отдаёт расшифрованный файл. Поток: подключение (`business_connection`) → запоминаем владельца и шлём приветствие → входящее (`business_message`) → `cache` кэширует текст и качает медиа под этим подключением → при удалении (`deleted_business_messages`) `antidelete` поднимает из кэша по `(connId, chatId, msgId)` → `ctx.capture` пишет в БД под `ownerId` и шлёт уведомление в `ownerChatId` → панель показывает владельцу только его записи и отдаёт расшифрованный файл.
## Что стоит учесть ## Что стоит учесть
- Перехватываются только **входящие в личных диалогах**. Группы, каналы и свои сообщения — нет (см. `isPrivateIncoming` в [events.js](src/userbot/events.js)). - **Многопользовательский режим.** Бота подключают к себе разные люди; данные строго изолированы по владельцу (`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` останется только текст и пометка о типе медиа. - `CACHE_MEDIA=1` качает медиа каждого входящего, чтобы восстанавливать удалённые картинки. Это ест диск — при `=0` останется только текст и пометка о типе медиа.
- Ключ шифрования — в `.env`. Потеряете ключ — расшифровать хранилище нельзя. - Ключ шифрования — в `.env`, **один на все подключения**. Потеряете ключ — расшифровать хранилище нельзя.
- Хранилище локальное; `data/` в `.gitignore`. - Хранилище локальное; `data/` в `.gitignore`. Схема БД сменилась на мультитенантную — при обновлении со старой версии удалите прежнюю `data/` перед первым запуском.
```
+2 -854
View File
@@ -8,20 +8,12 @@
"name": "telegrambusiness", "name": "telegrambusiness",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"grammy": "^1.45.1", "grammy": "^1.45.1"
"input": "^1.0.1",
"telegram": "^2.26.22"
}, },
"engines": { "engines": {
"node": ">=22.9.0" "node": ">=22.5.0"
} }
}, },
"node_modules/@cryptography/aes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@cryptography/aes/-/aes-0.1.1.tgz",
"integrity": "sha512-PcYz4FDGblO6tM2kSC+VzhhK62vml6k6/YAkiWtyPvrgJVfnDRoHGDtKn5UiaRRUrvUTTocBpvc2rRgTCqxjsg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@grammyjs/types": { "node_modules/@grammyjs/types": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz",
@@ -40,182 +32,6 @@
"node": ">=6.5" "node": ">=6.5"
} }
}, },
"node_modules/ansi-escapes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz",
"integrity": "sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ansi-styles": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
"integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/async-mutex": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz",
"integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.1"
}
},
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"license": "MIT",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/chalk": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^2.2.1",
"escape-string-regexp": "^1.0.2",
"has-ansi": "^2.0.0",
"strip-ansi": "^3.0.0",
"supports-color": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/cli-cursor": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz",
"integrity": "sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A==",
"license": "MIT",
"dependencies": {
"restore-cursor": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/cli-width": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz",
"integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==",
"license": "ISC"
},
"node_modules/code-point-at": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
"integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -233,144 +49,6 @@
} }
} }
}, },
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.2.0",
"entities": "^2.0.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.2.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.2.0",
"domhandler": "^4.2.0"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/event-target-shim": { "node_modules/event-target-shim": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -380,43 +58,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/exit-hook": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz",
"integrity": "sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"license": "ISC",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/figures": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz",
"integrity": "sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.5",
"object-assign": "^4.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/grammy": { "node_modules/grammy": {
"version": "1.45.1", "version": "1.45.1",
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.45.1.tgz", "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.45.1.tgz",
@@ -432,165 +73,12 @@
"node": "^12.20.0 || >=14.13.1" "node": "^12.20.0 || >=14.13.1"
} }
}, },
"node_modules/has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
"integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
"integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.0.0",
"domutils": "^2.5.2",
"entities": "^2.0.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/input": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/input/-/input-1.0.1.tgz",
"integrity": "sha512-5DKQKQ7Nm/CaPGYKF74uUvk5ftC3S04fLYWcDrNG2rOVhhRgB4E2J8JNb7AAh+RlQ/954ukas4bEbrRQ3/kPGA==",
"license": "MIT",
"dependencies": {
"babel-runtime": "^6.6.1",
"chalk": "^1.1.1",
"inquirer": "^0.12.0",
"lodash": "^4.6.1"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/inquirer": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz",
"integrity": "sha512-bOetEz5+/WpgaW4D1NYOk1aD+JCqRjqu/FwRFgnIfiP7FC/zinsrfyO1vlS3nyH/R7S0IH3BIHBu4DBIDSqiGQ==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^1.1.0",
"ansi-regex": "^2.0.0",
"chalk": "^1.0.0",
"cli-cursor": "^1.0.1",
"cli-width": "^2.0.0",
"figures": "^1.3.5",
"lodash": "^4.3.0",
"readline2": "^1.0.1",
"run-async": "^0.1.0",
"rx-lite": "^3.1.2",
"string-width": "^1.0.1",
"strip-ansi": "^3.0.0",
"through": "^2.3.6"
}
},
"node_modules/ip-address": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
"license": "MIT",
"dependencies": {
"number-is-nan": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mute-stream": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz",
"integrity": "sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg==",
"license": "ISC"
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -611,331 +99,18 @@
} }
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-localstorage": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-2.2.1.tgz",
"integrity": "sha512-vv8fJuOUCCvSPjDjBLlMqYMHob4aGjkmrkaE42/mZr0VT+ZAU10jRF8oTnX9+pgU9/vYJ8P7YT3Vd6ajkmzSCw==",
"license": "MIT",
"dependencies": {
"write-file-atomic": "^1.1.4"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
"integrity": "sha512-GZ+g4jayMqzCRMgB2sol7GiCLjKfS1PINkjmx8spcKce1LiVqcbQreXwqs2YAFXC6R03VIG28ZS31t8M866v6A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/readline2": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz",
"integrity": "sha512-8/td4MmwUB6PkZUbV25uKz7dfrmjYWxsW8DVfibWdlHRk/l/DfHKn4pU+dfcoGLFgWOdyGCzINRQD7jn+Bv+/g==",
"license": "MIT",
"dependencies": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
"mute-stream": "0.0.5"
}
},
"node_modules/real-cancellable-promise": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/real-cancellable-promise/-/real-cancellable-promise-1.2.3.tgz",
"integrity": "sha512-hBI5Gy/55VEeeMtImMgEirD7eq5UmqJf1J8dFZtbJZA/3rB0pYFZ7PayMGueb6v4UtUtpKpP+05L0VwyE1hI9Q==",
"license": "MIT"
},
"node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
"license": "MIT"
},
"node_modules/restore-cursor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz",
"integrity": "sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw==",
"license": "MIT",
"dependencies": {
"exit-hook": "^1.0.0",
"onetime": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/run-async": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz",
"integrity": "sha512-qOX+w+IxFgpUpJfkv2oGN0+ExPs68F4sZHfaRRx4dDexAQkG83atugKVEylyT5ARees3HBbfmuvnjbrd8j9Wjw==",
"license": "MIT",
"dependencies": {
"once": "^1.3.0"
}
},
"node_modules/rx-lite": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz",
"integrity": "sha512-1I1+G2gteLB8Tkt8YI1sJvSIfa0lWuRtC8GjvtyPBcLSF5jBCCJJqKrpER5JU5r6Bhe+i9/pK3VMuUcXu0kdwQ=="
},
"node_modules/slide": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz",
"integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==",
"license": "ISC",
"engines": {
"node": "*"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/store2": {
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz",
"integrity": "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
"license": "MIT",
"dependencies": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
"strip-ansi": "^3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-ansi": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
"integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/telegram": {
"version": "2.26.22",
"resolved": "https://registry.npmjs.org/telegram/-/telegram-2.26.22.tgz",
"integrity": "sha512-EIj7Yrjiu0Yosa3FZ/7EyPg9s6UiTi/zDQrFmR/2Mg7pIUU+XjAit1n1u9OU9h2oRnRM5M+67/fxzQluZpaJJg==",
"deprecated": "This package is archived and no longer maintained. Development continues in teleproto (https://npmjs.com/package/teleproto), a largely compatible, actively maintained fork. See the migration guide at https://docs.teleproto.dev/migrating-from-gramjs",
"license": "MIT",
"dependencies": {
"@cryptography/aes": "^0.1.1",
"async-mutex": "^0.3.0",
"big-integer": "^1.6.48",
"buffer": "^6.0.3",
"htmlparser2": "^6.1.0",
"mime": "^3.0.0",
"node-localstorage": "^2.2.1",
"pako": "^2.0.3",
"path-browserify": "^1.0.1",
"real-cancellable-promise": "^1.1.1",
"socks": "^2.6.2",
"store2": "^2.13.0",
"ts-custom-error": "^3.2.0",
"websocket": "^1.0.34"
},
"optionalDependencies": {
"bufferutil": "^4.0.3",
"utf-8-validate": "^5.0.5"
}
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"license": "MIT"
},
"node_modules/tr46": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
"integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
"license": "MIT",
"dependencies": {
"is-typedarray": "^1.0.0"
}
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/websocket": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz",
"integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==",
"license": "Apache-2.0",
"dependencies": {
"bufferutil": "^4.0.1",
"debug": "^2.2.0",
"es5-ext": "^0.10.63",
"typedarray-to-buffer": "^3.1.5",
"utf-8-validate": "^5.0.2",
"yaeti": "^0.0.6"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/websocket/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/websocket/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -945,33 +120,6 @@
"tr46": "~0.0.3", "tr46": "~0.0.3",
"webidl-conversions": "^3.0.0" "webidl-conversions": "^3.0.0"
} }
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/write-file-atomic": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz",
"integrity": "sha512-SdrHoC/yVBPpV0Xq/mUZQIpW2sWXAShb/V4pomcJXh92RuaO+f3UTWItiR3Px+pLnV2PvC2/bfn5cwr5X6Vfxw==",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.11",
"imurmurhash": "^0.1.4",
"slide": "^1.1.5"
}
},
"node_modules/yaeti": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
"integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"engines": {
"node": ">=0.10.32"
}
} }
} }
} }
+3 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "telegrambusiness", "name": "telegrambusiness",
"version": "1.0.0", "version": "1.0.0",
"description": "Личный Telegram-юзербот на MTProto: antidelete, one-time медиа, управление через компаньон-бота", "description": "Личный Telegram-бот на официальном бизнес-подключении: antidelete через business-апдейты, управление через панель. Без хранения сессии.",
"type": "module", "type": "module",
"main": "src/index.js", "main": "src/index.js",
"engines": { "engines": {
@@ -9,12 +9,9 @@
}, },
"scripts": { "scripts": {
"start": "node --env-file-if-exists=.env src/index.js", "start": "node --env-file-if-exists=.env src/index.js",
"dev": "node --watch --env-file-if-exists=.env src/index.js", "dev": "node --watch --env-file-if-exists=.env src/index.js"
"login": "node --env-file-if-exists=.env scripts/login.js"
}, },
"dependencies": { "dependencies": {
"grammy": "^1.45.1", "grammy": "^1.45.1"
"input": "^1.0.1",
"telegram": "^2.26.22"
} }
} }
-31
View File
@@ -1,31 +0,0 @@
import { TelegramClient } from 'telegram';
import { StringSession } from 'telegram/sessions/index.js';
import input from 'input';
/**
* Одноразовый интерактивный вход: вводите телефон/код (и пароль 2FA, если есть),
* скрипт печатает строку сессии — её нужно вставить в .env как SESSION=...
* Запуск: npm run login
*/
const apiId = Number(process.env.API_ID);
const apiHash = process.env.API_HASH;
if (!Number.isInteger(apiId) || !apiHash) {
console.error('Сначала задайте API_ID и API_HASH в .env (получить: https://my.telegram.org).');
process.exit(1);
}
const client = new TelegramClient(new StringSession(''), apiId, apiHash, { connectionRetries: 5 });
await client.start({
phoneNumber: () => input.text('Телефон (в формате +7...): '),
password: () => input.text('Пароль 2FA (если нет — Enter): '),
phoneCode: () => input.text('Код из Telegram: '),
onError: (err) => console.error(err),
});
console.log('\n✅ Готово. Вставьте строку в .env как SESSION=\n');
console.log(client.session.save());
console.log('\nНикому не передавайте её — это полный доступ к аккаунту.');
await client.disconnect();
process.exit(0);
+31 -25
View File
@@ -1,39 +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: '🗑 Удалённые', onetime: '👁 Одноразовые' }; const KIND_TITLE = { deleted: '🗑 Удалённые' };
/** /**
* Компаньон-бот — единственный интерфейс юзербота. Всё управление и все * Бот — интерфейс для всех. Любой может открыть его, нажать 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) => {
bot.use(async (ctx, next) => { const text = welcomeText({ name: ctx.from?.first_name });
if (isOwner(ctx.from?.id)) return next(); await ctx.reply(text, { parse_mode: WELCOME_PARSE_MODE, reply_markup: mainMenu(ctx.from.id) });
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();
@@ -43,11 +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')
.row()
.text(`👁 Одноразовые (${countCaptures('onetime')})`, 'feed:onetime:0');
} }
function feedKeyboard(kind, page, items, total) { function feedKeyboard(kind, page, items, total) {
@@ -64,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))}`
@@ -74,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);
@@ -135,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 = item.kind === 'onetime' ? '👁 Одноразовое медиа' : '🗑 Удалённое сообщение'; 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;
}
@@ -4,17 +4,17 @@ import { notifyCapture } from '../bot/panel.js';
/** /**
* Контекст передаётся в каждый хук модуля. Это единственная точка, через которую * Контекст передаётся в каждый хук модуля. Это единственная точка, через которую
* модуль что-то делает: сохраняет перехват, уведомляет владельца, качает медиа. * модуль что-то делает: сохраняет перехват, уведомляет владельца, качает медиа.
* Модуль не знает ни про grammY, ни про схему БД — только про этот интерфейс. * Модуль не знает про схему БД — только про этот интерфейс.
*/ */
export function createContext({ client, bot, me, registry }) { export function createContext({ bot, registry }) {
return { return {
client, // MTProto-клиент (GramJS) api: bot.api, // Bot API (grammY) — для скачивания медиа через getFile
me, // объект себя
registry, registry,
/** /**
* Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted' | 'onetime'. * Сохраняет перехват в БД и уведомляет владельца. 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);
+110
View File
@@ -0,0 +1,110 @@
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 достаёт его из кэша по (connId, chatId, msgId).
*/
export function wireBusiness(bot, registry, ctx) {
bot.on('business_message', async (gctx) => {
const msg = await normalizeBusinessMessage(gctx);
return runHook(registry, 'onMessage', msg, ctx);
});
bot.on('edited_business_message', async (gctx) => {
const msg = await normalizeBusinessMessage(gctx);
return runHook(registry, 'onEdited', msg, ctx);
});
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',
{
connId,
ownerId: owner?.ownerId ?? '',
ownerChatId: owner?.ownerChatId ?? '',
chatId: d.chat?.id,
msgIds: d.message_ids ?? [],
},
ctx,
);
});
// Подключение/отключение бота владельцем. Здесь же — красочное приветствие.
bot.on('business_connection', async (gctx) => {
const c = gctx.businessConnection;
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.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-контекст бизнес-сообщения к плоскому виду, понятному модулям,
* и подмешивает владельца подключения (connId/ownerId/ownerChatId).
* isPrivateIncoming — входящее в личном 1-на-1 чате, НЕ от самого владельца.
* Если владельца распознать не удалось (ownerId пуст) — считаем сообщение
* неопределённым и НЕ кэшируем: лучше пропустить, чем сохранить своё исходящее.
*/
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),
isPrivateIncoming: chat?.type === 'private' && ownerId !== '' && String(from?.id) !== ownerId,
};
}
function senderLabel(from) {
if (!from) return '';
const name = [from.first_name, from.last_name].filter(Boolean).join(' ');
return from.username ? `${name} (@${from.username})`.trim() : name;
}
+13 -20
View File
@@ -13,20 +13,21 @@ const num = (name) => {
}; };
/** /**
* Конфиг MTProto-юзербота. api_id/api_hash берутся на https://my.telegram.org, * Конфиг бота. Работает через официальное бизнес-подключение Telegram —
* строка сессии генерируется скриптом login (npm run login) и кладётся в .env. * никакой строки сессии, только токен бота от @BotFather. Всё чувствительное
* Всё, что чувствительно (сессия, ключ), живёт только в .env и никуда не уходит. * (токен, ключ шифрования) живёт только в .env и никуда не уходит.
*
* Мультитенант: любой может подключить бота к своему бизнес-аккаунту. Доступ
* к перехватам изолирован по владельцу (см. db.js), поэтому OWNER_ID больше
* ничего не открывает и не обязателен.
*/ */
export const config = { export const config = {
// Доступ к MTProto. // Бот (@BotFather, Business Mode). К нему пользователи подключают свои
apiId: () => num('API_ID'), // бизнес-аккаунты (Настройки → Telegram для бизнеса → Чат-боты).
apiHash: () => req('API_HASH'),
session: process.env.SESSION ?? '',
// Компаньон-бот (@BotFather) — единственный UI юзербота.
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'),
@@ -35,7 +36,7 @@ export const config = {
retentionDays: Number(process.env.RETENTION_DAYS ?? 30), retentionDays: Number(process.env.RETENTION_DAYS ?? 30),
// Качать ли медиа входящих сразу (нужно, чтобы восстанавливать удалённые // Качать ли медиа входящих сразу (нужно, чтобы восстанавливать удалённые
// картинки/видео — при удалении ссылка на файл уже мертва). Ест диск. // картинки/видео — при удалении file_id уже не приходит). Ест диск.
cacheMedia: process.env.CACHE_MEDIA !== '0', cacheMedia: process.env.CACHE_MEDIA !== '0',
dataDir: new URL('../data/', import.meta.url), dataDir: new URL('../data/', import.meta.url),
@@ -48,17 +49,9 @@ export const mediaPath = () => fileURLToPath(config.mediaDir);
/** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */ /** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */
export function assertConfig() { export function assertConfig() {
config.apiId();
config.apiHash();
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-символа).');
} }
if (!config.session) {
throw new Error('SESSION пуст. Выполните: npm run login — и вставьте строку в .env.');
}
} }
export const isOwner = (id) => Number(id) === config.ownerId();
+96 -24
View File
@@ -10,25 +10,41 @@ 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()}/userbot.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 (
msg_id INTEGER PRIMARY KEY, conn_id TEXT,
msg_id INTEGER,
chat_id TEXT, chat_id TEXT,
sender_id TEXT, sender_id TEXT,
sender TEXT, sender TEXT,
text_enc TEXT, text_enc TEXT,
media_kind TEXT, media_kind TEXT,
media_path TEXT, media_path TEXT,
created_at INTEGER created_at INTEGER,
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' | 'onetime' owner_id TEXT,
kind TEXT, -- 'deleted'
chat_id TEXT, chat_id TEXT,
sender_id TEXT, sender_id TEXT,
sender TEXT, sender TEXT,
@@ -37,7 +53,7 @@ export function openDb() {
media_path 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;
@@ -45,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 ?? ''),
@@ -65,20 +119,23 @@ export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind,
); );
} }
export function getCachedMessage(msgId) { export function getCachedMessage(connId, chatId, msgId) {
const row = openDb().prepare('SELECT * FROM messages WHERE msg_id = ?').get(msgId); const row = openDb()
.prepare('SELECT * FROM messages WHERE conn_id = ? AND chat_id = ? AND msg_id = ?')
.get(String(connId ?? ''), String(chatId ?? ''), msgId);
return row ? decodeRow(row) : null; return row ? decodeRow(row) : null;
} }
/** Записываем факт перехвата (удаление или one-time) — это и есть лента в боте. */ /** Записываем факт перехвата (удалённое сообщение) — это и есть лента в боте. */
export function saveCapture(kind, data) { 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 ?? ''),
@@ -91,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. */
+44 -35
View File
@@ -6,54 +6,63 @@ import { config, mediaPath } from '../config.js';
import { encrypt } from './crypto.js'; import { encrypt } from './crypto.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
/** Определяем тип медиа по объекту сообщения GramJS. */ /**
export function detectMedia(message) { * Определяет тип медиа и file_id по объекту сообщения Bot API (grammY).
if (!message?.media) return null; * Возвращает { kind, fileId } либо null, если медиа нет.
if (message.photo) return 'photo'; * У фото берём самый крупный размер (последний в массиве photo).
if (message.video) return 'video'; */
if (message.voice) return 'voice'; export function detectMediaFromBusiness(msg) {
if (message.videoNote) return 'video_note'; if (!msg) return null;
if (message.gif) return 'gif'; if (msg.photo?.length) return { kind: 'photo', fileId: msg.photo.at(-1).file_id };
if (message.audio) return 'audio'; if (msg.video) return { kind: 'video', fileId: msg.video.file_id };
if (message.sticker) return 'sticker'; if (msg.voice) return { kind: 'voice', fileId: msg.voice.file_id };
if (message.document) return 'document'; if (msg.video_note) return { kind: 'video_note', fileId: msg.video_note.file_id };
return 'media'; if (msg.animation) return { kind: 'gif', fileId: msg.animation.file_id };
if (msg.audio) return { kind: 'audio', fileId: msg.audio.file_id };
if (msg.sticker) return { kind: 'sticker', fileId: msg.sticker.file_id };
if (msg.document) return { kind: 'document', fileId: msg.document.file_id };
return null;
} }
/** /**
* Одноразовые медиа помечены ttlSeconds на самом медиа. Такие файлы Telegram * Скачивает файл по file_id через Bot API, шифрует и кладёт на диск.
* стирает у клиента после просмотра — их важно скачать в момент прихода. * Возвращает имя файла (как хранится в БД) либо null.
* getFile отдаёт файлы до ~20 МБ — на бо́льших вернётся ошибка, её ловим.
* Качать нужно в момент прихода: при удалении file_id уже не приходит.
*
* key = { connId, chatId, msgId, tag } — уникализирует имя файла между
* подключениями и чатами (мультитенант): msg_id уникален только внутри чата,
* поэтому без connId/chatId два владельца могли бы затереть медиа друг друга.
*/ */
export function isOneTime(message) { export async function downloadEncrypted(api, fileId, key = {}) {
const media = message?.media; const { connId = '', chatId = '', msgId = 'x', tag = 'm' } = key;
const ttl = media?.ttlSeconds ?? media?.photo?.ttlSeconds ?? media?.document?.ttlSeconds;
return Boolean(ttl);
}
/**
* Скачивает медиа сообщения, шифрует и кладёт на диск. Возвращает
* { kind, path } либо null. Имя файла не раскрывает содержимого.
*/
export async function downloadEncrypted(client, message, tag = 'm') {
const kind = detectMedia(message);
if (!kind) return null;
try { try {
const buffer = await client.downloadMedia(message, {}); const file = await api.getFile(fileId); // { file_path, ... } или бросит на >20 МБ
if (!buffer || !buffer.length) return { kind, path: null }; if (!file?.file_path) return null;
const url = `https://api.telegram.org/file/bot${config.botToken()}/${file.file_path}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
if (!buffer.length) return null;
mkdirSync(mediaPath(), { recursive: true }); mkdirSync(mediaPath(), { recursive: true });
const name = `${tag}_${message.id ?? 'x'}_${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: сохранено ${kind} -> ${name} (${buffer.length} б)`); logger.debug(`media: сохранено -> ${name} (${buffer.length} б)`);
return { kind, path: name }; return name;
} catch (err) { } catch (err) {
logger.warn(`media: не удалось скачать ${kind}:`, err.message); logger.warn(`media: не удалось скачать файл (${tag}):`, err.message);
return { kind, path: null }; return null;
} }
} }
/** Делает сегмент имени файла безопасным: минус → '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)));
+26 -23
View File
@@ -4,9 +4,18 @@ import { openDb, purgeOld } from './core/db.js';
import { loadModules } from './core/loader.js'; import { loadModules } from './core/loader.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 { createUserbot, startUserbot } from './userbot/client.js'; import { createContext } from './business/context.js';
import { createContext } from './userbot/context.js'; import { wireBusiness } from './business/events.js';
import { wireEvents } from './userbot/events.js';
// Бизнес-апдейты Telegram по умолчанию не приходят — их нужно запросить явно.
const ALLOWED_UPDATES = [
'message',
'callback_query',
'business_connection',
'business_message',
'edited_business_message',
'deleted_business_messages',
];
// 1. Конфиг и БД — падаем понятной ошибкой, если что-то не задано. // 1. Конфиг и БД — падаем понятной ошибкой, если что-то не задано.
try { try {
@@ -17,32 +26,27 @@ try {
} }
openDb(); openDb();
// 2. Юзербот (MTProto): подключаемся уже готовой сессией. // 2. Бот (единственный UI и приёмник бизнес-апдейтов) — запускаем в фоне.
const client = createUserbot();
let me;
try {
me = await startUserbot(client);
} catch (err) {
logger.error('Не удалось подключить юзербот. Проверьте SESSION/API_ID/API_HASH.', err.message);
process.exit(1);
}
// 3. Компаньон-бот (единственный UI) — запускаем в фоне.
const bot = createBot(); const bot = createBot();
bot.start({ onStart: ({ username }) => logger.info(`Панель поднята: @${username}`) }).catch((err) => { bot
logger.error('Компаньон-бот не запустился (проверьте BOT_TOKEN):', err.message); .start({
allowed_updates: ALLOWED_UPDATES,
onStart: ({ username }) => logger.info(`Панель поднята: @${username}`),
})
.catch((err) => {
logger.error('Бот не запустился (проверьте BOT_TOKEN):', err.message);
process.exit(1); process.exit(1);
}); });
// 4. Модули: грузим и раздаём им события юзербота. // 3. Модули: грузим и раздаём им бизнес-апдейты через хуки.
const registry = await loadModules(); const registry = await loadModules();
const ctx = createContext({ client, bot, me, registry }); const ctx = createContext({ bot, registry });
await runHook(registry, 'setup', undefined, ctx); await runHook(registry, 'setup', undefined, ctx);
wireEvents(client, registry, ctx); wireBusiness(bot, registry, ctx);
logger.info(`Готово. Активных модулей: ${registry.list().map((m) => m.name).join(', ')}`); logger.info(`Готово. Активных модулей: ${registry.list().map((m) => m.name).join(', ')}`);
// 5. Периодическая чистка по сроку хранения. // 4. Периодическая чистка по сроку хранения.
if (config.retentionDays > 0) { if (config.retentionDays > 0) {
const timer = setInterval(() => { const timer = setInterval(() => {
const { messages, captures } = purgeOld(); const { messages, captures } = purgeOld();
@@ -51,12 +55,11 @@ if (config.retentionDays > 0) {
timer.unref(); timer.unref();
} }
// 6. Аккуратная остановка. // 5. Аккуратная остановка.
for (const signal of ['SIGINT', 'SIGTERM']) { for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => { process.once(signal, async () => {
logger.info('Останавливаюсь…'); logger.info('Останавливаюсь…');
await bot.stop().catch(() => {}); await bot.stop().catch(() => {});
await client.disconnect().catch(() => {});
process.exit(0); process.exit(0);
}); });
} }
+10 -9
View File
@@ -2,24 +2,25 @@ import { getCachedMessage } from '../core/db.js';
import { logger } from '../core/logger.js'; import { logger } from '../core/logger.js';
/** /**
* Ловит удаления. MTProto в событии удаления присылает ТОЛЬКО id сообщений — * Ловит удаления. Telegram в событии deleted_business_messages присылает ТОЛЬКО
* без содержимого и (в личных чатах) без чата. Поэтому восстанавливаем из кэша, * id сообщений и чат — без содержимого. Поэтому восстанавливаем из кэша, который
* который наполняет модуль [cache]. Нашли в кэше — значит это было входящее * наполняет модуль [cache], по тройке (connId, chatId, msgId). Нашли — значит это
* в личке: сохраняем как перехват и уведомляем владельца. * было входящее в личке: сохраняем как перехват владельца и уведомляем его.
*/ */
export default { export default {
name: 'antidelete', name: 'antidelete',
description: 'Сохраняет удалённые собеседником сообщения и медиа', description: 'Сохраняет удалённые собеседником сообщения и медиа',
async onDeleted(event, ctx) { async onDeleted({ connId, ownerId, ownerChatId, chatId, msgIds }, ctx) {
const ids = event.deletedIds ?? []; if (!msgIds?.length) return;
if (!ids.length) return;
for (const id of ids) { for (const id of msgIds) {
const cached = getCachedMessage(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,
+22 -29
View File
@@ -1,50 +1,43 @@
import { config } from '../config.js'; import { config } from '../config.js';
import { cacheMessage } from '../core/db.js'; import { cacheMessage } from '../core/db.js';
import { downloadEncrypted, isOneTime } from '../core/media.js'; import { downloadEncrypted } from '../core/media.js';
import { isPrivateIncoming } from '../userbot/events.js';
/** /**
* Фундамент для antidelete: кладёт каждое входящее из личных диалогов в кэш. * Фундамент для antidelete: кладёт каждое входящее из личных диалогов в кэш.
* При удалении сообщение уже недоступно у Telegram — восстановить его можно * При удалении Telegram присылает только id (без содержимого) — восстановить
* только из этого кэша. Поэтому текст сохраняем всегда, а медиа — сразу качаем * сообщение можно лишь из этого кэша. Поэтому текст сохраняем всегда, а медиа —
* (если включён CACHE_MEDIA), потому что после удаления ссылка на файл мертва. * сразу качаем (если включён CACHE_MEDIA): при удалении file_id уже не приходит.
*/ */
export default { export default {
name: 'cache', name: 'cache',
description: 'Кэширует входящие личные сообщения — основа для antidelete', description: 'Кэширует входящие личные сообщения — основа для antidelete',
async onMessage(message, ctx) { async onMessage(msg, ctx) {
if (!isPrivateIncoming(message, ctx.me)) return; if (!msg.isPrivateIncoming) return;
// Одноразовые ведёт отдельный модуль onetime — тут их не трогаем,
// чтобы не качать дважды.
if (isOneTime(message)) return;
let mediaKind = null; let mediaKind = null;
let mediaPath = null; let mediaPath = null;
if (message.media && config.cacheMedia) { if (msg.media && config.cacheMedia) {
const saved = await downloadEncrypted(ctx.client, message, 'cache'); mediaKind = msg.media.kind;
mediaKind = saved?.kind ?? null; mediaPath = await downloadEncrypted(ctx.api, msg.media.fileId, {
mediaPath = saved?.path ?? null; connId: msg.connId,
} else if (message.media) { chatId: msg.chatId,
mediaKind = 'media'; // знаем, что медиа было, но файл не кэшируем msgId: msg.msgId,
tag: 'cache',
});
} else if (msg.media) {
mediaKind = msg.media.kind; // знаем, что медиа было, но файл не кэшируем
} }
const sender = await message.getSender().catch(() => null);
cacheMessage({ cacheMessage({
msgId: message.id, connId: msg.connId,
chatId: message.chatId, msgId: msg.msgId,
senderId: message.senderId, chatId: msg.chatId,
sender: senderLabel(sender), senderId: msg.senderId,
text: message.text ?? '', sender: msg.sender,
text: msg.text,
mediaKind, mediaKind,
mediaPath, mediaPath,
}); });
}, },
}; };
function senderLabel(sender) {
if (!sender) return '';
const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ');
return sender.username ? `${name} (@${sender.username})`.trim() : name;
}
-37
View File
@@ -1,37 +0,0 @@
import { downloadEncrypted, isOneTime } from '../core/media.js';
import { logger } from '../core/logger.js';
import { isPrivateIncoming } from '../userbot/events.js';
/**
* Одноразовые фото/видео (ttl-медиа) Telegram стирает сразу после просмотра.
* Ловим их в момент прихода: качаем файл, шифруем, сохраняем как перехват.
* Работает только по входящим в личке — свои и групповые не трогаем.
*/
export default {
name: 'onetime',
description: 'Сохраняет одноразовые фото и видео',
async onMessage(message, ctx) {
if (!isPrivateIncoming(message, ctx.me)) return;
if (!isOneTime(message)) return;
const saved = await downloadEncrypted(ctx.client, message, 'onetime');
const sender = await message.getSender().catch(() => null);
await ctx.capture('onetime', {
chatId: message.chatId,
senderId: message.senderId,
sender: senderLabel(sender),
text: message.text ?? '',
mediaKind: saved?.kind ?? 'media',
mediaPath: saved?.path ?? null,
});
logger.info(`onetime: сохранено одноразовое ${saved?.kind ?? 'медиа'} от ${senderLabel(sender) || '?'}`);
},
};
function senderLabel(sender) {
if (!sender) return '';
const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ');
return sender.username ? `${name} (@${sender.username})`.trim() : name;
}
-27
View File
@@ -1,27 +0,0 @@
import { TelegramClient } from 'telegram';
import { StringSession } from 'telegram/sessions/index.js';
import { config } from '../config.js';
import { logger } from '../core/logger.js';
/**
* Создаёт (но не подключает) MTProto-клиент из строки сессии в .env.
* Логин делается один раз скриптом scripts/login.js — здесь сессия уже готова.
*/
export function createUserbot(session = config.session) {
const client = new TelegramClient(new StringSession(session), config.apiId(), config.apiHash(), {
connectionRetries: 5,
autoReconnect: true,
// Юзербот молчит в чужих чатах — нам нужны только апдейты, не рассылки.
floodSleepThreshold: 60,
});
client.setLogLevel('error');
return client;
}
export async function startUserbot(client) {
await client.connect();
const me = await client.getMe();
logger.info(`Юзербот подключён как ${me.username ? '@' + me.username : me.firstName} (id ${me.id})`);
return me;
}
-37
View File
@@ -1,37 +0,0 @@
import { NewMessage } from 'telegram/events/NewMessage.js';
import { DeletedMessage } from 'telegram/events/DeletedMessage.js';
import { EditedMessage } from 'telegram/events/EditedMessage.js';
import { logger } from '../core/logger.js';
import { runHook } from '../core/registry.js';
/**
* Подписывает клиент на апдметы и раздаёт их модулям через хуки.
* Мы слушаем всё, но модули сами решают, что делать (обычно — только личку).
*/
export function wireEvents(client, registry, ctx) {
client.addEventHandler(
(event) => runHook(registry, 'onMessage', event.message, ctx),
new NewMessage({}),
);
client.addEventHandler(
(event) => runHook(registry, 'onEdited', event.message, ctx),
new EditedMessage({}),
);
client.addEventHandler(
(event) => runHook(registry, 'onDeleted', event, ctx),
new DeletedMessage({}),
);
logger.debug('события юзербота подписаны: new / edited / deleted');
}
/** Личный диалог один-на-один (не группа, не канал, не свои Избранные). */
export function isPrivateIncoming(message, me) {
if (!message) return false;
if (message.out) return false; // исходящие от себя не перехватываем
const cls = message.peerId?.className;
return cls === 'PeerUser' && String(message.senderId) !== String(me?.id ?? '');
}