diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ec6744d..80ec1f8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,26 @@ "Bash(gh api *)", "Bash(cd /tmp)", "Bash(curl -sL \"https://raw.githubusercontent.com/tdlib/telegram-bot-api/master/telegram-bot-api/Client.cpp\" -o client.cpp)", - "Bash(awk 'NR<=18629 && /^[a-zA-Z].*Client::.*\\\\\\(/ {line=NR; text=$0} END{print line\": \"text}' client.cpp)" + "Bash(awk 'NR<=18629 && /^[a-zA-Z].*Client::.*\\\\\\(/ {line=NR; text=$0} END{print line\": \"text}' client.cpp)", + "Bash(awk 'NR<=19167 && /^\\(void|bool|td|const|int|auto\\).*Client::.*\\\\\\(/ {line=NR; text=$0} END{print line\": \"text}' client.cpp)", + "Bash(awk 'NR<=19212 && /^\\(void|bool|td|const|int|auto\\).*Client::.*\\\\\\(/ {line=NR; text=$0} END{print line\": \"text}' client.cpp)", + "Bash(git add *)", + "WebFetch(domain:docs.python-telegram-bot.org)", + "Bash(curl -s \"https://core.telegram.org/bots/api-changelog\" -o /tmp/changelog.html)", + "Read(//tmp/**)", + "Bash(curl -s \"https://core.telegram.org/bots/api\" -o /tmp/api.html)", + "Bash(python -c ' *)", + "Bash(curl -s \"https://core.telegram.org/bots/api\" -o api.html)", + "Bash(curl -s \"https://core.telegram.org/bots/api-changelog\" -o changelog.html)", + "Bash(git rm *)", + "Bash(git mv *)", + "Bash(mkdir -p src/business)", + "Bash(rm -f api.html changelog.html)", + "Bash(rmdir src/userbot)", + "Bash(npm install *)", + "Bash(node _offline_test.mjs)", + "Bash(rm -f _offline_test.mjs)", + "Bash(rm -rf data)" ] } } diff --git a/.env.example b/.env.example index f351a47..23ebab3 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,8 @@ -# === MTProto (юзербот) === -# Получить на https://my.telegram.org → API development tools -API_ID= -API_HASH= -# Строка сессии. Сгенерировать: npm run login — и вставить сюда. -SESSION= - -# === Компаньон-бот (единственный интерфейс) === -# Токен от @BotFather +# === Бот (единственный интерфейс + приёмник бизнес-апдейтов) === +# Токен от @BotFather. Обязательно включите боту режим бизнеса: +# @BotFather → /mybots → выбрать бота → Bot Settings → Business Mode → Enable. +# Затем в приложении: Настройки → Telegram для бизнеса → Чат-боты → добавить бота. +# Сессия НЕ нужна — работаем только через официальное бизнес-подключение. BOT_TOKEN= # Ваш Telegram id (узнать: напишите @userinfobot). Только вы управляете ботом. OWNER_ID= diff --git a/README.md b/README.md index 290ae46..121c35e 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,29 @@ # telegrambusiness -Личный Telegram-**юзербот** на Node.js + MTProto (GramJS). Работает от вашего аккаунта и сохраняет то, что Bot API недоступно: +Личный Telegram-бот на Node.js, работающий через **официальное бизнес-подключение** Telegram (Business API). Бот подключается к вашему аккаунту как чат-бот для бизнеса и сохраняет то, что собеседник удалил: -- **antidelete** — сообщения, которые собеседник удалил в личке; -- **onetime** — одноразовые («просмотр один раз») фото и видео. +- **antidelete** — сообщения (и медиа), которые собеседник удалил в личке. -Всё управление и все перехваты приходят в **компаньон-бота** — отдельного бота от [@BotFather](https://t.me/BotFather), в личку владельца. Наружу юзербот ничего не пишет, слеш-команд в чатах нет. +Всё управление и все перехваты приходят в этого же бота — в личку владельца. Наружу бот ничего не пишет, слеш-команд в чатах нет. -> ⚠️ **Дисклеймер.** Инструмент для личного использования на **своём** аккаунте и своей переписке. Юзерботы нарушают ToS Telegram и аккаунт могут заблокировать — держите его на отдельном номере, не на основном. Одноразовые медиа отправляются с расчётом на удаление; сохраняя их, вы берёте ответственность на себя. +> ✅ **Без сессии, по правилам Telegram.** Проект **не хранит строку сессии** и не использует MTProto-юзербот. Работает исключительно через штатную функцию «Telegram для бизнеса» — обычный бот от [@BotFather](https://t.me/BotFather) с включённым Business Mode. Это соответствует ToS. + +> ⚠️ **Дисклеймер.** Инструмент для личного использования на **своём** аккаунте и своей переписке. Не применяйте против других людей. ## Как устроено (важно понимать до запуска) -MTProto в событии удаления присылает **только id сообщений** — без текста и без медиа. Поэтому «сохранить удалённое» реактивно невозможно: юзербот **заранее кэширует каждое входящее** в личке, а при удалении достаёт его из своего кэша. То же с медиа — файл качается **в момент прихода**, после удаления ссылка мертва. +Telegram в бизнес-событии удаления (`deleted_business_messages`) присылает **только id сообщений** — без текста и без медиа. Поэтому «сохранить удалённое» реактивно невозможно: бот **заранее кэширует каждое входящее** в личке, а при удалении достаёт его из своего кэша. То же с медиа — файл качается **в момент прихода**, после удаления ссылка мертва. Отсюда следствия: - тексты и метаданные пишутся в локальную БД (`node:sqlite`), **зашифрованы** (AES-256-GCM); - медиа лежат отдельными **зашифрованными** файлами в `data/media/`; - всё чистится по `RETENTION_DAYS`. +### Чего этот подход НЕ умеет + +- **Одноразовые медиа** («просмотр один раз», самоуничтожающиеся) перехватить **невозможно**: сервер Bot API отбрасывает такие сообщения ещё до доставки боту. Это ограничение платформы, обойти его без юзербота нельзя — а от юзербота мы отказались сознательно. +- **Медиа больше ~20 МБ** не скачиваются: у Bot API (`getFile`) лимит на размер файла. Такое медиа сохранится только как пометка о типе. + ## Установка Нужен Node.js ≥ 22.5 (для встроенного `node:sqlite`). @@ -29,24 +35,22 @@ cp .env.example .env Заполните `.env`: -1. **API_ID / API_HASH** — [my.telegram.org](https://my.telegram.org) → API development tools. -2. **BOT_TOKEN** — новый бот у [@BotFather](https://t.me/BotFather) (это и есть панель). -3. **OWNER_ID** — ваш id (напишите [@userinfobot](https://t.me/userinfobot)). -4. **ENCRYPTION_KEY** — сгенерируйте: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. +1. **BOT_TOKEN** — новый бот у [@BotFather](https://t.me/BotFather). +2. **OWNER_ID** — ваш id (напишите [@userinfobot](https://t.me/userinfobot)). +3. **ENCRYPTION_KEY** — сгенерируйте: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. -Затем одноразовый вход в аккаунт (телефон, код, при наличии — пароль 2FA): +Включите боту режим бизнеса и подключите его к аккаунту: -```bash -npm run login -``` +1. [@BotFather](https://t.me/BotFather) → `/mybots` → выберите бота → **Bot Settings** → **Business Mode** → **Enable**. +2. В приложении Telegram: **Настройки → Telegram для бизнеса → Чат-боты** → добавьте своего бота. Дайте ему право читать/управлять сообщениями. -Скрипт напечатает строку сессии — вставьте её в `.env` как `SESSION=...` и запускайте: +Запуск: ```bash npm run dev ``` -Откройте своего бота, нажмите **Start** — появится панель. При удалении сообщения собеседником или получении одноразового медиа бот пришлёт уведомление с кнопкой «Открыть». +Откройте своего бота, нажмите **Start** — появится панель. Когда собеседник удалит сообщение, бот пришлёт уведомление с кнопкой «Открыть». `npm run dev` — с автоперезапуском, `npm start` — обычный запуск. @@ -60,63 +64,61 @@ export default { name: 'keywords', description: 'Уведомлять, если во входящем есть ключевое слово', - async onMessage(message, ctx) { - if (message.out) return; // не своё - if (/срочно/i.test(message.text ?? '')) { - await ctx.capture('deleted', { // переиспользуем ленту перехватов - chatId: message.chatId, - senderId: message.senderId, - sender: (await message.getSender().catch(() => null))?.username ?? '', - text: message.text, + async onMessage(msg, ctx) { + if (!msg.isPrivateIncoming) return; + if (/срочно/i.test(msg.text ?? '')) { + await ctx.capture('deleted', { // переиспользуем ленту перехватов + chatId: msg.chatId, + senderId: msg.senderId, + sender: msg.sender, + text: msg.text, }); } }, }; ``` +Хук получает **нормализованное** сообщение: `{ chatId, msgId, senderId, sender, text, media, isPrivateIncoming }`. + | Хук | Когда вызывается | | --- | --- | | `setup(_, ctx)` | Один раз при старте | -| `onMessage(message, ctx)` | Новое сообщение (`NewMessage`) | -| `onEdited(message, ctx)` | Правка (`EditedMessage`) | -| `onDeleted(event, ctx)` | Удаление; `event.deletedIds` — массив id | +| `onMessage(msg, ctx)` | Новое бизнес-сообщение (`business_message`) | +| `onEdited(msg, ctx)` | Правка (`edited_business_message`) | +| `onDeleted(event, ctx)` | Удаление; `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)` — сохранить перехват и уведомить владельца. Ошибка в одном модуле не роняет остальные ([registry.js](src/core/registry.js) — `runHook`). ## Устройство ``` src/ - index.js сборка: юзербот + бот + модули + чистка + index.js сборка: бот + модули + чистка config.js .env, проверка на старте core/ crypto.js AES-256-GCM (шифр текста и медиа) db.js node:sqlite: кэш входящих + лента перехватов - media.js тип медиа, детект one-time, скачать+шифровать + media.js тип медиа, скачать через getFile + шифровать loader.js обход src/modules registry.js реестр модулей, runHook logger.js - userbot/ - client.js MTProto-клиент из строки сессии - events.js подписка NewMessage/Edited/Deleted → модули + business/ + events.js подписка business_message/edited/deleted → модули context.js ctx.capture — мост модуль↔БД↔бот modules/ cache.js кэширует входящие (основа antidelete) antidelete.js удаление → достать из кэша → перехват - onetime.js one-time медиа → скачать → перехват bot/ - panel.js компаньон-бот: инлайн-панель, лента, выдача медиа -scripts/ - login.js генерация строки сессии + panel.js бот: инлайн-панель, лента, выдача медиа ``` -Поток: входящее → `cache`/`onetime` (кэш и/или скачивание) → при удалении `antidelete` поднимает из кэша → `ctx.capture` пишет в БД и шлёт уведомление → панель показывает и отдаёт расшифрованный файл. +Поток: входящее (`business_message`) → `cache` кэширует текст и качает медиа → при удалении (`deleted_business_messages`) `antidelete` поднимает из кэша → `ctx.capture` пишет в БД и шлёт уведомление → панель показывает и отдаёт расшифрованный файл. ## Что стоит учесть -- Перехватываются только **входящие в личных диалогах**. Группы, каналы и свои сообщения — нет (см. `isPrivateIncoming` в [events.js](src/userbot/events.js)). -- «Удалить у себя» на стороне собеседника юзерботу не приходит — ловится только «удалить у всех». +- Перехватываются только **входящие в личных диалогах**. Свои сообщения — нет (см. `isPrivateIncoming` в [events.js](src/business/events.js)). +- Id сообщения в Bot API уникален **только внутри чата**, поэтому в БД составной ключ `(chat_id, msg_id)`. +- Если вы отключите бизнес-подключение в настройках Telegram, перехват приостановится (бот залогирует это по апдейту `business_connection`). - `CACHE_MEDIA=1` качает медиа каждого входящего, чтобы восстанавливать удалённые картинки. Это ест диск — при `=0` останется только текст и пометка о типе медиа. - Ключ шифрования — в `.env`. Потеряете ключ — расшифровать хранилище нельзя. - Хранилище локальное; `data/` в `.gitignore`. -``` diff --git a/package-lock.json b/package-lock.json index f4551da..1fa7242 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,20 +8,12 @@ "name": "telegrambusiness", "version": "1.0.0", "dependencies": { - "grammy": "^1.45.1", - "input": "^1.0.1", - "telegram": "^2.26.22" + "grammy": "^1.45.1" }, "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz", @@ -40,182 +32,6 @@ "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": { "version": "4.4.3", "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -380,43 +58,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": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.45.1.tgz", @@ -432,165 +73,12 @@ "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "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": { "version": "2.7.0", "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": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -945,33 +120,6 @@ "tr46": "~0.0.3", "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" - } } } } diff --git a/package.json b/package.json index bfac13c..531bcc0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "telegrambusiness", "version": "1.0.0", - "description": "Личный Telegram-юзербот на MTProto: antidelete, one-time медиа, управление через компаньон-бота", + "description": "Личный Telegram-бот на официальном бизнес-подключении: antidelete через business-апдейты, управление через панель. Без хранения сессии.", "type": "module", "main": "src/index.js", "engines": { @@ -9,12 +9,9 @@ }, "scripts": { "start": "node --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" + "dev": "node --watch --env-file-if-exists=.env src/index.js" }, "dependencies": { - "grammy": "^1.45.1", - "input": "^1.0.1", - "telegram": "^2.26.22" + "grammy": "^1.45.1" } } diff --git a/scripts/login.js b/scripts/login.js deleted file mode 100644 index f057a2f..0000000 --- a/scripts/login.js +++ /dev/null @@ -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); diff --git a/src/bot/panel.js b/src/bot/panel.js index f9535e9..b75e634 100644 --- a/src/bot/panel.js +++ b/src/bot/panel.js @@ -7,7 +7,17 @@ import { logger } from '../core/logger.js'; import { readMedia } from '../core/media.js'; const PAGE = 5; -const KIND_TITLE = { deleted: '🗑 Удалённые', onetime: '👁 Одноразовые' }; +const KIND_TITLE = { deleted: '🗑 Удалённые' }; + +/** Бизнес-апдейт от подключённого аккаунта: у него ctx.from — собеседник, не владелец. */ +function isBusinessUpdate(ctx) { + return Boolean( + ctx.businessMessage ?? + ctx.editedBusinessMessage ?? + ctx.deletedBusinessMessages ?? + ctx.businessConnection, + ); +} /** * Компаньон-бот — единственный интерфейс юзербота. Всё управление и все @@ -17,9 +27,11 @@ const KIND_TITLE = { deleted: '🗑 Удалённые', onetime: '👁 Одно export function createBot() { const bot = new Bot(config.botToken()); - // Жёсткий барьер: чужие апдейты не обрабатываем вообще. + // Жёсткий барьер: панельные апдейты обрабатываем только от владельца. + // Бизнес-апдейты (business_message/deleted/…) пропускаем — у них ctx.from + // это собеседник, а не владелец; их разбирает wireBusiness отдельно. bot.use(async (ctx, next) => { - if (isOwner(ctx.from?.id)) return next(); + if (isBusinessUpdate(ctx) || isOwner(ctx.from?.id)) return next(); logger.debug(`bot: игнорирую чужой апдейт от ${ctx.from?.id}`); }); @@ -45,9 +57,7 @@ export function createBot() { function mainMenu() { return new InlineKeyboard() - .text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0') - .row() - .text(`👁 Одноразовые (${countCaptures('onetime')})`, 'feed:onetime:0'); + .text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0'); } function feedKeyboard(kind, page, items, total) { @@ -140,7 +150,7 @@ async function edit(ctx, text, keyboard) { */ export async function notifyCapture(bot, item) { 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)}`; try { await bot.api.sendMessage(config.ownerId(), text, { reply_markup: kb }); diff --git a/src/userbot/context.js b/src/business/context.js similarity index 68% rename from src/userbot/context.js rename to src/business/context.js index 6837b95..0b5a6c2 100644 --- a/src/userbot/context.js +++ b/src/business/context.js @@ -4,16 +4,15 @@ import { notifyCapture } from '../bot/panel.js'; /** * Контекст передаётся в каждый хук модуля. Это единственная точка, через которую * модуль что-то делает: сохраняет перехват, уведомляет владельца, качает медиа. - * Модуль не знает ни про grammY, ни про схему БД — только про этот интерфейс. + * Модуль не знает про схему БД — только про этот интерфейс. */ -export function createContext({ client, bot, me, registry }) { +export function createContext({ bot, registry }) { return { - client, // MTProto-клиент (GramJS) - me, // объект себя + api: bot.api, // Bot API (grammY) — для скачивания медиа через getFile registry, /** - * Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted' | 'onetime'. + * Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted'. * data: { chatId, senderId, sender, text, mediaKind, mediaPath } */ async capture(kind, data) { diff --git a/src/business/events.js b/src/business/events.js new file mode 100644 index 0000000..e807c9b --- /dev/null +++ b/src/business/events.js @@ -0,0 +1,70 @@ +import { isOwner } from '../config.js'; +import { detectMediaFromBusiness } from '../core/media.js'; +import { logger } from '../core/logger.js'; +import { runHook } from '../core/registry.js'; + +/** + * Подписывает бота на бизнес-апдейты Telegram и раздаёт их модулям через хуки. + * Работает через официальное бизнес-подключение (Настройки → Telegram для + * бизнеса → Чат-боты) — никакой сессии, только апдейты от подключённого аккаунта. + * + * Событие удаления (deleted_business_messages) несёт только id сообщений и чат — + * без содержимого. Поэтому модуль cache складывает каждое входящее заранее, + * а antidelete достаёт его из кэша по (chatId, msgId). + */ +export function wireBusiness(bot, registry, ctx) { + bot.on('business_message', (gctx) => + runHook(registry, 'onMessage', normalizeBusinessMessage(gctx), ctx), + ); + + bot.on('edited_business_message', (gctx) => + runHook(registry, 'onEdited', normalizeBusinessMessage(gctx), ctx), + ); + + bot.on('deleted_business_messages', (gctx) => { + const d = gctx.deletedBusinessMessages; + return runHook( + registry, + 'onDeleted', + { chatId: d.chat?.id, msgIds: d.message_ids ?? [] }, + ctx, + ); + }); + + bot.on('business_connection', (gctx) => { + const c = gctx.businessConnection; + if (c?.is_enabled) { + logger.info(`Бизнес-подключение активно (id ${c.id})`); + } else { + logger.warn(`Бизнес-подключение отключено владельцем (id ${c?.id}). Перехват приостановлен.`); + } + }); + + logger.debug('бизнес-апдейты подписаны: message / edited / deleted / connection'); +} + +/** + * Приводит grammY-контекст бизнес-сообщения к плоскому виду, понятному модулям. + * isPrivateIncoming — входящее в личном 1-на-1 чате, не от самого владельца. + */ +export function normalizeBusinessMessage(gctx) { + const msg = gctx.businessMessage ?? gctx.editedBusinessMessage ?? gctx.msg; + const from = msg?.from; + const chat = msg?.chat; + return { + chatId: chat?.id, + msgId: msg?.message_id, + senderId: from?.id, + sender: senderLabel(from), + text: msg?.text ?? msg?.caption ?? '', + media: detectMediaFromBusiness(msg), + // Свои исходящие отсекаем через isOwner (как раньше через message.out). + isPrivateIncoming: chat?.type === 'private' && !isOwner(from?.id), + }; +} + +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; +} diff --git a/src/config.js b/src/config.js index 08a11bd..2158a1d 100644 --- a/src/config.js +++ b/src/config.js @@ -13,17 +13,13 @@ const num = (name) => { }; /** - * Конфиг MTProto-юзербота. api_id/api_hash берутся на https://my.telegram.org, - * строка сессии генерируется скриптом login (npm run login) и кладётся в .env. - * Всё, что чувствительно (сессия, ключ), живёт только в .env и никуда не уходит. + * Конфиг бота. Работает через официальное бизнес-подключение Telegram — + * никакой строки сессии, только токен бота от @BotFather. Всё чувствительное + * (токен, ключ шифрования) живёт только в .env и никуда не уходит. */ export const config = { - // Доступ к MTProto. - apiId: () => num('API_ID'), - apiHash: () => req('API_HASH'), - session: process.env.SESSION ?? '', - - // Компаньон-бот (@BotFather) — единственный UI юзербота. + // Бот-панель (@BotFather). К нему же владелец подключает бизнес-аккаунт + // (Настройки → Telegram для бизнеса → Чат-боты). Единственный UI. botToken: () => req('BOT_TOKEN'), // Telegram id владельца: только он управляет ботом и получает перехваты. ownerId: () => num('OWNER_ID'), @@ -35,7 +31,7 @@ export const config = { retentionDays: Number(process.env.RETENTION_DAYS ?? 30), // Качать ли медиа входящих сразу (нужно, чтобы восстанавливать удалённые - // картинки/видео — при удалении ссылка на файл уже мертва). Ест диск. + // картинки/видео — при удалении file_id уже не приходит). Ест диск. cacheMedia: process.env.CACHE_MEDIA !== '0', dataDir: new URL('../data/', import.meta.url), @@ -48,17 +44,12 @@ export const mediaPath = () => fileURLToPath(config.mediaDir); /** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */ export function assertConfig() { - config.apiId(); - config.apiHash(); config.botToken(); config.ownerId(); const key = config.encryptionKey(); if (Buffer.from(key, key.match(/^[0-9a-f]+$/i) ? 'hex' : 'utf8').length < 32) { throw new Error('ENCRYPTION_KEY должен быть не короче 32 байт (64 hex-символа).'); } - if (!config.session) { - throw new Error('SESSION пуст. Выполните: npm run login — и вставьте строку в .env.'); - } } export const isOwner = (id) => Number(id) === config.ownerId(); diff --git a/src/core/db.js b/src/core/db.js index 4f383a3..25a1fd5 100644 --- a/src/core/db.js +++ b/src/core/db.js @@ -14,21 +14,22 @@ let db = null; export function openDb() { if (db) return db; mkdirSync(dataPath(), { recursive: true }); - db = new DatabaseSync(`${dataPath()}/userbot.db`); + db = new DatabaseSync(`${dataPath()}/business.db`); db.exec(` CREATE TABLE IF NOT EXISTS messages ( - msg_id INTEGER PRIMARY KEY, + msg_id INTEGER, chat_id TEXT, sender_id TEXT, sender TEXT, text_enc TEXT, media_kind TEXT, media_path TEXT, - created_at INTEGER + created_at INTEGER, + PRIMARY KEY (chat_id, msg_id) ); CREATE TABLE IF NOT EXISTS captures ( id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT, -- 'deleted' | 'onetime' + kind TEXT, -- 'deleted' chat_id TEXT, sender_id TEXT, sender TEXT, @@ -65,12 +66,14 @@ export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind, ); } -export function getCachedMessage(msgId) { - const row = openDb().prepare('SELECT * FROM messages WHERE msg_id = ?').get(msgId); +export function getCachedMessage(chatId, msgId) { + const row = openDb() + .prepare('SELECT * FROM messages WHERE chat_id = ? AND msg_id = ?') + .get(String(chatId ?? ''), msgId); return row ? decodeRow(row) : null; } -/** Записываем факт перехвата (удаление или one-time) — это и есть лента в боте. */ +/** Записываем факт перехвата (удалённое сообщение) — это и есть лента в боте. */ export function saveCapture(kind, data) { const info = openDb() .prepare( diff --git a/src/core/media.js b/src/core/media.js index d0fcde7..1f453e1 100644 --- a/src/core/media.js +++ b/src/core/media.js @@ -6,51 +6,50 @@ import { config, mediaPath } from '../config.js'; import { encrypt } from './crypto.js'; import { logger } from './logger.js'; -/** Определяем тип медиа по объекту сообщения GramJS. */ -export function detectMedia(message) { - if (!message?.media) return null; - if (message.photo) return 'photo'; - if (message.video) return 'video'; - if (message.voice) return 'voice'; - if (message.videoNote) return 'video_note'; - if (message.gif) return 'gif'; - if (message.audio) return 'audio'; - if (message.sticker) return 'sticker'; - if (message.document) return 'document'; - return 'media'; +/** + * Определяет тип медиа и file_id по объекту сообщения Bot API (grammY). + * Возвращает { kind, fileId } либо null, если медиа нет. + * У фото берём самый крупный размер (последний в массиве photo). + */ +export function detectMediaFromBusiness(msg) { + if (!msg) return null; + if (msg.photo?.length) return { kind: 'photo', fileId: msg.photo.at(-1).file_id }; + if (msg.video) return { kind: 'video', fileId: msg.video.file_id }; + if (msg.voice) return { kind: 'voice', fileId: msg.voice.file_id }; + if (msg.video_note) return { kind: 'video_note', fileId: msg.video_note.file_id }; + 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 уже не приходит. */ -export function isOneTime(message) { - const media = message?.media; - const ttl = media?.ttlSeconds ?? media?.photo?.ttlSeconds ?? media?.document?.ttlSeconds; - return Boolean(ttl); -} - -/** - * Скачивает медиа сообщения, шифрует и кладёт на диск. Возвращает - * { kind, path } либо null. Имя файла не раскрывает содержимого. - */ -export async function downloadEncrypted(client, message, tag = 'm') { - const kind = detectMedia(message); - if (!kind) return null; - +export async function downloadEncrypted(api, fileId, tag = 'm', msgId = 'x') { try { - const buffer = await client.downloadMedia(message, {}); - if (!buffer || !buffer.length) return { kind, path: null }; + const file = await api.getFile(fileId); // { file_path, ... } или бросит на >20 МБ + 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 }); - const name = `${tag}_${message.id ?? 'x'}_${buffer.length}.enc`; + const name = `${tag}_${msgId}_${buffer.length}.enc`; const full = fileURLToPath(new URL(name, config.mediaDir)); await writeFile(full, encrypt(buffer)); - logger.debug(`media: сохранено ${kind} -> ${name} (${buffer.length} б)`); - return { kind, path: name }; + logger.debug(`media: сохранено -> ${name} (${buffer.length} б)`); + return name; } catch (err) { - logger.warn(`media: не удалось скачать ${kind}:`, err.message); - return { kind, path: null }; + logger.warn(`media: не удалось скачать файл (${tag}):`, err.message); + return null; } } diff --git a/src/index.js b/src/index.js index 14a7cb0..316b5bc 100644 --- a/src/index.js +++ b/src/index.js @@ -4,9 +4,18 @@ import { openDb, purgeOld } from './core/db.js'; import { loadModules } from './core/loader.js'; import { logger } from './core/logger.js'; import { runHook } from './core/registry.js'; -import { createUserbot, startUserbot } from './userbot/client.js'; -import { createContext } from './userbot/context.js'; -import { wireEvents } from './userbot/events.js'; +import { createContext } from './business/context.js'; +import { wireBusiness } from './business/events.js'; + +// Бизнес-апдейты Telegram по умолчанию не приходят — их нужно запросить явно. +const ALLOWED_UPDATES = [ + 'message', + 'callback_query', + 'business_connection', + 'business_message', + 'edited_business_message', + 'deleted_business_messages', +]; // 1. Конфиг и БД — падаем понятной ошибкой, если что-то не задано. try { @@ -17,32 +26,27 @@ try { } openDb(); -// 2. Юзербот (MTProto): подключаемся уже готовой сессией. -const client = createUserbot(); -let me; -try { - me = await startUserbot(client); -} catch (err) { - logger.error('Не удалось подключить юзербот. Проверьте SESSION/API_ID/API_HASH.', err.message); - process.exit(1); -} - -// 3. Компаньон-бот (единственный UI) — запускаем в фоне. +// 2. Бот (единственный UI и приёмник бизнес-апдейтов) — запускаем в фоне. const bot = createBot(); -bot.start({ onStart: ({ username }) => logger.info(`Панель поднята: @${username}`) }).catch((err) => { - logger.error('Компаньон-бот не запустился (проверьте BOT_TOKEN):', err.message); - process.exit(1); -}); +bot + .start({ + allowed_updates: ALLOWED_UPDATES, + onStart: ({ username }) => logger.info(`Панель поднята: @${username}`), + }) + .catch((err) => { + logger.error('Бот не запустился (проверьте BOT_TOKEN):', err.message); + process.exit(1); + }); -// 4. Модули: грузим и раздаём им события юзербота. +// 3. Модули: грузим и раздаём им бизнес-апдейты через хуки. const registry = await loadModules(); -const ctx = createContext({ client, bot, me, registry }); +const ctx = createContext({ bot, registry }); await runHook(registry, 'setup', undefined, ctx); -wireEvents(client, registry, ctx); +wireBusiness(bot, registry, ctx); logger.info(`Готово. Активных модулей: ${registry.list().map((m) => m.name).join(', ')}`); -// 5. Периодическая чистка по сроку хранения. +// 4. Периодическая чистка по сроку хранения. if (config.retentionDays > 0) { const timer = setInterval(() => { const { messages, captures } = purgeOld(); @@ -51,12 +55,11 @@ if (config.retentionDays > 0) { timer.unref(); } -// 6. Аккуратная остановка. +// 5. Аккуратная остановка. for (const signal of ['SIGINT', 'SIGTERM']) { process.once(signal, async () => { logger.info('Останавливаюсь…'); await bot.stop().catch(() => {}); - await client.disconnect().catch(() => {}); process.exit(0); }); } diff --git a/src/modules/antidelete.js b/src/modules/antidelete.js index a0615e4..4751501 100644 --- a/src/modules/antidelete.js +++ b/src/modules/antidelete.js @@ -2,21 +2,20 @@ import { getCachedMessage } from '../core/db.js'; import { logger } from '../core/logger.js'; /** - * Ловит удаления. MTProto в событии удаления присылает ТОЛЬКО id сообщений — - * без содержимого и (в личных чатах) без чата. Поэтому восстанавливаем из кэша, - * который наполняет модуль [cache]. Нашли в кэше — значит это было входящее - * в личке: сохраняем как перехват и уведомляем владельца. + * Ловит удаления. Telegram в событии deleted_business_messages присылает ТОЛЬКО + * id сообщений и чат — без содержимого. Поэтому восстанавливаем из кэша, который + * наполняет модуль [cache], по паре (chatId, msgId). Нашли — значит это было + * входящее в личке: сохраняем как перехват и уведомляем владельца. */ export default { name: 'antidelete', description: 'Сохраняет удалённые собеседником сообщения и медиа', - async onDeleted(event, ctx) { - const ids = event.deletedIds ?? []; - if (!ids.length) return; + async onDeleted({ chatId, msgIds }, ctx) { + if (!msgIds?.length) return; - for (const id of ids) { - const cached = getCachedMessage(id); + for (const id of msgIds) { + const cached = getCachedMessage(chatId, id); if (!cached) continue; // не наше/не кэшировали — пропускаем await ctx.capture('deleted', { diff --git a/src/modules/cache.js b/src/modules/cache.js index 892132b..4c5b00d 100644 --- a/src/modules/cache.js +++ b/src/modules/cache.js @@ -1,50 +1,37 @@ import { config } from '../config.js'; import { cacheMessage } from '../core/db.js'; -import { downloadEncrypted, isOneTime } from '../core/media.js'; -import { isPrivateIncoming } from '../userbot/events.js'; +import { downloadEncrypted } from '../core/media.js'; /** * Фундамент для antidelete: кладёт каждое входящее из личных диалогов в кэш. - * При удалении сообщение уже недоступно у Telegram — восстановить его можно - * только из этого кэша. Поэтому текст сохраняем всегда, а медиа — сразу качаем - * (если включён CACHE_MEDIA), потому что после удаления ссылка на файл мертва. + * При удалении Telegram присылает только id (без содержимого) — восстановить + * сообщение можно лишь из этого кэша. Поэтому текст сохраняем всегда, а медиа — + * сразу качаем (если включён CACHE_MEDIA): при удалении file_id уже не приходит. */ export default { name: 'cache', description: 'Кэширует входящие личные сообщения — основа для antidelete', - async onMessage(message, ctx) { - if (!isPrivateIncoming(message, ctx.me)) return; - - // Одноразовые ведёт отдельный модуль onetime — тут их не трогаем, - // чтобы не качать дважды. - if (isOneTime(message)) return; + async onMessage(msg, ctx) { + if (!msg.isPrivateIncoming) return; let mediaKind = null; let mediaPath = null; - if (message.media && config.cacheMedia) { - const saved = await downloadEncrypted(ctx.client, message, 'cache'); - mediaKind = saved?.kind ?? null; - mediaPath = saved?.path ?? null; - } else if (message.media) { - mediaKind = 'media'; // знаем, что медиа было, но файл не кэшируем + if (msg.media && config.cacheMedia) { + mediaKind = msg.media.kind; + mediaPath = await downloadEncrypted(ctx.api, msg.media.fileId, 'cache', msg.msgId); + } else if (msg.media) { + mediaKind = msg.media.kind; // знаем, что медиа было, но файл не кэшируем } - const sender = await message.getSender().catch(() => null); cacheMessage({ - msgId: message.id, - chatId: message.chatId, - senderId: message.senderId, - sender: senderLabel(sender), - text: message.text ?? '', + msgId: msg.msgId, + chatId: msg.chatId, + senderId: msg.senderId, + sender: msg.sender, + text: msg.text, mediaKind, mediaPath, }); }, }; - -function senderLabel(sender) { - if (!sender) return ''; - const name = [sender.firstName, sender.lastName].filter(Boolean).join(' '); - return sender.username ? `${name} (@${sender.username})`.trim() : name; -} diff --git a/src/modules/onetime.js b/src/modules/onetime.js deleted file mode 100644 index c3af1db..0000000 --- a/src/modules/onetime.js +++ /dev/null @@ -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; -} diff --git a/src/userbot/client.js b/src/userbot/client.js deleted file mode 100644 index 5240511..0000000 --- a/src/userbot/client.js +++ /dev/null @@ -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; -} diff --git a/src/userbot/events.js b/src/userbot/events.js deleted file mode 100644 index c97bbb3..0000000 --- a/src/userbot/events.js +++ /dev/null @@ -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 ?? ''); -}