From 15a03a77fd586b300ff9f941aafc7a04da05a8b1 Mon Sep 17 00:00:00 2001 From: ares Date: Sun, 9 Aug 2026 19:16:52 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20MTProto-=D1=8E?= =?UTF-8?q?=D0=B7=D0=B5=D1=80=D0=B1=D0=BE=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Смена архитектуры с бизнес-бота на Bot API (команды со слешем) на личный юзербот на GramJS/MTProto с управлением через компаньон-бота. Возможности: - antidelete — кэш входящих в личке, восстановление удалённых «у всех» - onetime — перехват «просмотр один раз» фото/видео до истечения ссылки - локальное хранилище node:sqlite + шифрование AES-256-GCM, чистка по сроку - компаньон-бот (grammY): инлайн-панель, лента с пагинацией, выдача медиа, доступ только владельцу по OWNER_ID Устройство: - core/ — crypto, db, media, loader, registry, logger - userbot/ — MTProto-клиент из строки сессии, подписка на события - modules/ — фичи как модули с хуками setup/onMessage/onEdited/onDeleted - bot/ — компаньон-бот (единственный UI) - scripts/login.js — генерация строки сессии Удалён старый код Bot API (commands/, handlers/, middlewares/, dispatcher, store). Обновлены README и .env.example. Hcrgram/ (сторонний референс) добавлен в .gitignore. Co-Authored-By: Claude Opus 5 --- .claude/settings.local.json | 15 +- .env.example | 25 +- .gitignore | 3 + README.md | 122 +++++ package-lock.json | 858 +++++++++++++++++++++++++++++++- package.json | 12 +- scripts/check.js | 20 - scripts/login.js | 31 ++ scripts/sync-commands.js | 18 - src/bot.js | 63 --- src/bot/panel.js | 150 ++++++ src/commands/_template.js | 26 - src/commands/business/pause.js | 28 -- src/commands/business/status.js | 40 -- src/commands/help.js | 23 - src/commands/id.js | 23 - src/commands/ping.js | 17 - src/commands/start.js | 28 -- src/config.js | 74 ++- src/core/crypto.js | 47 ++ src/core/db.js | 137 +++++ src/core/dispatcher.js | 68 --- src/core/loader.js | 22 +- src/core/logger.js | 4 +- src/core/media.js | 60 +++ src/core/registry.js | 129 ++--- src/core/store.js | 83 --- src/handlers/connection.js | 33 -- src/handlers/fallback.js | 32 -- src/index.js | 84 ++-- src/middlewares/business.js | 76 --- src/middlewares/handover.js | 39 -- src/modules/antidelete.js | 33 ++ src/modules/cache.js | 50 ++ src/modules/onetime.js | 37 ++ src/userbot/client.js | 27 + src/userbot/context.js | 25 + src/userbot/events.js | 37 ++ test/dispatcher.test.js | 122 ----- test/registry.test.js | 46 -- 40 files changed, 1845 insertions(+), 922 deletions(-) create mode 100644 README.md delete mode 100644 scripts/check.js create mode 100644 scripts/login.js delete mode 100644 scripts/sync-commands.js delete mode 100644 src/bot.js create mode 100644 src/bot/panel.js delete mode 100644 src/commands/_template.js delete mode 100644 src/commands/business/pause.js delete mode 100644 src/commands/business/status.js delete mode 100644 src/commands/help.js delete mode 100644 src/commands/id.js delete mode 100644 src/commands/ping.js delete mode 100644 src/commands/start.js create mode 100644 src/core/crypto.js create mode 100644 src/core/db.js delete mode 100644 src/core/dispatcher.js create mode 100644 src/core/media.js delete mode 100644 src/core/store.js delete mode 100644 src/handlers/connection.js delete mode 100644 src/handlers/fallback.js delete mode 100644 src/middlewares/business.js delete mode 100644 src/middlewares/handover.js create mode 100644 src/modules/antidelete.js create mode 100644 src/modules/cache.js create mode 100644 src/modules/onetime.js create mode 100644 src/userbot/client.js create mode 100644 src/userbot/context.js create mode 100644 src/userbot/events.js delete mode 100644 test/dispatcher.test.js delete mode 100644 test/registry.test.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d470b2f..c6f8d7d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,20 @@ "permissions": { "allow": [ "Bash(npm test *)", - "Bash(BOT_TOKEN=\"123456:AAFAKE_TOKEN_FOR_SMOKE_TEST\" LOG_LEVEL=debug timeout 25 node src/index.js)" + "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 $?\\) ===\")" ] } } diff --git a/.env.example b/.env.example index c58dacf..f351a47 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,24 @@ +# === MTProto (юзербот) === +# Получить на https://my.telegram.org → API development tools +API_ID= +API_HASH= +# Строка сессии. Сгенерировать: npm run login — и вставить сюда. +SESSION= + +# === Компаньон-бот (единственный интерфейс) === # Токен от @BotFather -BOT_TOKEN=123456:AA... +BOT_TOKEN= +# Ваш Telegram id (узнать: напишите @userinfobot). Только вы управляете ботом. +OWNER_ID= -# ID администраторов через запятую (узнать свой: команда /id) -ADMIN_IDS= - -# Сколько минут молчать в чате после того, как владелец ответил вручную -HANDOVER_PAUSE_MINUTES=30 +# === Локальное хранилище === +# Ключ шифрования: 64 hex-символа ИЛИ длинная парольная фраза (≥32 байт). +# Сгенерировать hex: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +ENCRYPTION_KEY= +# Сколько дней хранить перехваченное (0 — бессрочно) +RETENTION_DAYS=30 +# Качать медиа входящих сразу, чтобы восстанавливать удалённые картинки (0 — выключить) +CACHE_MEDIA=1 # debug | info | warn | error LOG_LEVEL=info diff --git a/.gitignore b/.gitignore index 40f5df4..0bc22d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ .env data/ *.log + +# Сторонний код (изучался как референс, в проект не входит) +Hcrgram/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..290ae46 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# telegrambusiness + +Личный Telegram-**юзербот** на Node.js + MTProto (GramJS). Работает от вашего аккаунта и сохраняет то, что Bot API недоступно: + +- **antidelete** — сообщения, которые собеседник удалил в личке; +- **onetime** — одноразовые («просмотр один раз») фото и видео. + +Всё управление и все перехваты приходят в **компаньон-бота** — отдельного бота от [@BotFather](https://t.me/BotFather), в личку владельца. Наружу юзербот ничего не пишет, слеш-команд в чатах нет. + +> ⚠️ **Дисклеймер.** Инструмент для личного использования на **своём** аккаунте и своей переписке. Юзерботы нарушают ToS Telegram и аккаунт могут заблокировать — держите его на отдельном номере, не на основном. Одноразовые медиа отправляются с расчётом на удаление; сохраняя их, вы берёте ответственность на себя. + +## Как устроено (важно понимать до запуска) + +MTProto в событии удаления присылает **только id сообщений** — без текста и без медиа. Поэтому «сохранить удалённое» реактивно невозможно: юзербот **заранее кэширует каждое входящее** в личке, а при удалении достаёт его из своего кэша. То же с медиа — файл качается **в момент прихода**, после удаления ссылка мертва. + +Отсюда следствия: +- тексты и метаданные пишутся в локальную БД (`node:sqlite`), **зашифрованы** (AES-256-GCM); +- медиа лежат отдельными **зашифрованными** файлами в `data/media/`; +- всё чистится по `RETENTION_DAYS`. + +## Установка + +Нужен Node.js ≥ 22.5 (для встроенного `node:sqlite`). + +```bash +npm install +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'))"`. + +Затем одноразовый вход в аккаунт (телефон, код, при наличии — пароль 2FA): + +```bash +npm run login +``` + +Скрипт напечатает строку сессии — вставьте её в `.env` как `SESSION=...` и запускайте: + +```bash +npm run dev +``` + +Откройте своего бота, нажмите **Start** — появится панель. При удалении сообщения собеседником или получении одноразового медиа бот пришлёт уведомление с кнопкой «Открыть». + +`npm run dev` — с автоперезапуском, `npm start` — обычный запуск. + +## Как добавить модуль + +Модуль — файл в `src/modules/`. Загрузчик обходит папку рекурсивно; файлы на `_` игнорируются. Объявляйте только нужные хуки: + +```js +// src/modules/keywords.js +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, + }); + } + }, +}; +``` + +| Хук | Когда вызывается | +| --- | --- | +| `setup(_, ctx)` | Один раз при старте | +| `onMessage(message, ctx)` | Новое сообщение (`NewMessage`) | +| `onEdited(message, ctx)` | Правка (`EditedMessage`) | +| `onDeleted(event, ctx)` | Удаление; `event.deletedIds` — массив id | + +`ctx` даёт: `client` (GramJS), `me`, `registry` и `capture(kind, data)` — сохранить перехват и уведомить владельца. Ошибка в одном модуле не роняет остальные ([registry.js](src/core/registry.js) — `runHook`). + +## Устройство + +``` +src/ + index.js сборка: юзербот + бот + модули + чистка + config.js .env, проверка на старте + core/ + crypto.js AES-256-GCM (шифр текста и медиа) + db.js node:sqlite: кэш входящих + лента перехватов + media.js тип медиа, детект one-time, скачать+шифровать + loader.js обход src/modules + registry.js реестр модулей, runHook + logger.js + userbot/ + client.js MTProto-клиент из строки сессии + events.js подписка NewMessage/Edited/Deleted → модули + context.js ctx.capture — мост модуль↔БД↔бот + modules/ + cache.js кэширует входящие (основа antidelete) + antidelete.js удаление → достать из кэша → перехват + onetime.js one-time медиа → скачать → перехват + bot/ + panel.js компаньон-бот: инлайн-панель, лента, выдача медиа +scripts/ + login.js генерация строки сессии +``` + +Поток: входящее → `cache`/`onetime` (кэш и/или скачивание) → при удалении `antidelete` поднимает из кэша → `ctx.capture` пишет в БД и шлёт уведомление → панель показывает и отдаёт расшифрованный файл. + +## Что стоит учесть + +- Перехватываются только **входящие в личных диалогах**. Группы, каналы и свои сообщения — нет (см. `isPrivateIncoming` в [events.js](src/userbot/events.js)). +- «Удалить у себя» на стороне собеседника юзерботу не приходит — ловится только «удалить у всех». +- `CACHE_MEDIA=1` качает медиа каждого входящего, чтобы восстанавливать удалённые картинки. Это ест диск — при `=0` останется только текст и пометка о типе медиа. +- Ключ шифрования — в `.env`. Потеряете ключ — расшифровать хранилище нельзя. +- Хранилище локальное; `data/` в `.gitignore`. +``` diff --git a/package-lock.json b/package-lock.json index f8549eb..f4551da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,21 @@ "": { "name": "telegrambusiness", "version": "1.0.0", - "license": "ISC", "dependencies": { - "grammy": "^1.45.1" + "grammy": "^1.45.1", + "input": "^1.0.1", + "telegram": "^2.26.22" + }, + "engines": { + "node": ">=22.9.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", @@ -30,6 +40,182 @@ "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", @@ -47,6 +233,144 @@ } } }, + "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", @@ -56,6 +380,43 @@ "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", @@ -71,12 +432,165 @@ "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", @@ -97,18 +611,331 @@ } } }, + "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", @@ -118,6 +945,33 @@ "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 9f72780..bfac13c 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,20 @@ { "name": "telegrambusiness", "version": "1.0.0", - "description": "Telegram bot: modular commands + Business Mode support", + "description": "Личный Telegram-юзербот на MTProto: antidelete, one-time медиа, управление через компаньон-бота", "type": "module", "main": "src/index.js", "engines": { - "node": ">=22.9.0" + "node": ">=22.5.0" }, "scripts": { "start": "node --env-file-if-exists=.env src/index.js", "dev": "node --watch --env-file-if-exists=.env src/index.js", - "check": "node scripts/check.js", - "test": "node --test \"test/**/*.test.js\"", - "sync": "node --env-file-if-exists=.env scripts/sync-commands.js" + "login": "node --env-file-if-exists=.env scripts/login.js" }, "dependencies": { - "grammy": "^1.45.1" + "grammy": "^1.45.1", + "input": "^1.0.1", + "telegram": "^2.26.22" } } diff --git a/scripts/check.js b/scripts/check.js deleted file mode 100644 index 0ba67c2..0000000 --- a/scripts/check.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Проверка структуры без обращения к Telegram: грузит все команды, - * ловит конфликты триггеров и печатает таблицу. Токен не нужен. - * - * npm run check - */ -import { loadCommands } from '../src/core/loader.js'; - -const registry = await loadCommands(); -const rows = registry.list().map((cmd) => ({ - команда: `/${cmd.command}`, - алиасы: cmd.aliases.join(', ') || '—', - scope: cmd.scope, - admin: cmd.adminOnly ? 'да' : '', - hidden: cmd.hidden ? 'да' : '', - файл: cmd.source, -})); - -console.table(rows); -console.log(`В меню Telegram уйдёт: ${registry.forTelegram().map((c) => `/${c.command}`).join(' ') || '—'}`); diff --git a/scripts/login.js b/scripts/login.js new file mode 100644 index 0000000..f057a2f --- /dev/null +++ b/scripts/login.js @@ -0,0 +1,31 @@ +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/scripts/sync-commands.js b/scripts/sync-commands.js deleted file mode 100644 index 8a63e12..0000000 --- a/scripts/sync-commands.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Отправляет список команд в Telegram, не поднимая бота. - * - * npm run sync - */ -import { Bot } from 'grammy'; - -import { assertConfig, config } from '../src/config.js'; -import { loadCommands } from '../src/core/loader.js'; - -assertConfig(); - -const registry = await loadCommands(); -const bot = new Bot(config.token); -const commands = registry.forTelegram(); - -await bot.api.setMyCommands(commands); -console.log(`Готово. Отправлено команд: ${commands.length}`); diff --git a/src/bot.js b/src/bot.js deleted file mode 100644 index 88f925d..0000000 --- a/src/bot.js +++ /dev/null @@ -1,63 +0,0 @@ -import { Bot, GrammyError, HttpError } from 'grammy'; - -import { assertConfig, config } from './config.js'; -import { createDispatcher } from './core/dispatcher.js'; -import { loadCommands } from './core/loader.js'; -import { logger } from './core/logger.js'; -import { connections, handovers } from './core/store.js'; -import { registerConnectionHandlers } from './handlers/connection.js'; -import { fallback } from './handlers/fallback.js'; -import { businessContext } from './middlewares/business.js'; -import { handover } from './middlewares/handover.js'; - -/** Без явного allowed_updates Telegram не пришлёт business-апдейты. */ -export const ALLOWED_UPDATES = [ - 'message', - 'edited_message', - 'callback_query', - 'business_connection', - 'business_message', - 'edited_business_message', - 'deleted_business_messages', -]; - -export async function createBot() { - assertConfig(); - await Promise.all([connections.load(), handovers.load()]); - - const registry = await loadCommands(); - const bot = new Bot(config.token); - - bot.catch(({ error, ctx }) => { - const where = `update ${ctx?.update?.update_id}`; - if (error instanceof GrammyError) { - logger.error(`${where}: Telegram отклонил запрос — ${error.description}`); - } else if (error instanceof HttpError) { - logger.error(`${where}: сеть недоступна`, error); - } else { - logger.error(`${where}: необработанная ошибка`, error); - } - }); - - bot.use(businessContext()); - - // Подключение/отключение бота к бизнес-аккаунту — до пайплайна сообщений. - registerConnectionHandlers(bot); - - bot.on(['message', 'business_message'], handover(), createDispatcher(registry), fallback()); - - return { bot, registry }; -} - -export async function syncCommands(bot, registry) { - const commands = registry.forTelegram(); - await bot.api.setMyCommands(commands); - logger.info(`Меню команд обновлено: ${commands.map((c) => `/${c.command}`).join(' ') || '—'}`); -} - -export async function shutdown(bot) { - logger.info('Останавливаюсь…'); - await bot.stop(); - await Promise.all([connections.flush(), handovers.flush()]); - logger.info('Состояние сохранено.'); -} diff --git a/src/bot/panel.js b/src/bot/panel.js new file mode 100644 index 0000000..f9535e9 --- /dev/null +++ b/src/bot/panel.js @@ -0,0 +1,150 @@ +import { Bot, InlineKeyboard, InputFile } from 'grammy'; + +import { config, isOwner } from '../config.js'; +import { decrypt } from '../core/crypto.js'; +import { countCaptures, getCapture, listCaptures } from '../core/db.js'; +import { logger } from '../core/logger.js'; +import { readMedia } from '../core/media.js'; + +const PAGE = 5; +const KIND_TITLE = { deleted: '🗑 Удалённые', onetime: '👁 Одноразовые' }; + +/** + * Компаньон-бот — единственный интерфейс юзербота. Всё управление и все + * перехваты живут здесь, в личке владельца. Никаких слеш-команд снаружи: + * бот отвечает только владельцу (OWNER_ID), любому другому — молчит. + */ +export function createBot() { + const bot = new Bot(config.botToken()); + + // Жёсткий барьер: чужие апдейты не обрабатываем вообще. + bot.use(async (ctx, next) => { + if (isOwner(ctx.from?.id)) return next(); + logger.debug(`bot: игнорирую чужой апдейт от ${ctx.from?.id}`); + }); + + bot.command('start', (ctx) => ctx.reply('Панель юзербота.', { reply_markup: mainMenu() })); + bot.callbackQuery('home', (ctx) => edit(ctx, 'Панель юзербота.', mainMenu())); + + // Списки перехватов с пагинацией. + bot.callbackQuery(/^feed:(\w+):(\d+)$/, async (ctx) => { + const [, kind, pageRaw] = ctx.match; + await renderFeed(ctx, kind, Number(pageRaw)); + await ctx.answerCallbackQuery(); + }); + + // Открыть одну запись — с медиа, если оно есть. + bot.callbackQuery(/^item:(\d+)$/, async (ctx) => { + await renderItem(ctx, Number(ctx.match[1])); + await ctx.answerCallbackQuery(); + }); + + bot.catch((err) => logger.error('bot: ошибка обработки', err.error ?? err)); + return bot; +} + +function mainMenu() { + return new InlineKeyboard() + .text(`🗑 Удалённые (${countCaptures('deleted')})`, 'feed:deleted:0') + .row() + .text(`👁 Одноразовые (${countCaptures('onetime')})`, 'feed:onetime:0'); +} + +function feedKeyboard(kind, page, items, total) { + const kb = new InlineKeyboard(); + for (const it of items) { + kb.text(`${previewLine(it)}`, `item:${it.id}`).row(); + } + const nav = []; + if (page > 0) nav.push(['◀', `feed:${kind}:${page - 1}`]); + if ((page + 1) * PAGE < total) nav.push(['▶', `feed:${kind}:${page + 1}`]); + nav.forEach(([label, data]) => kb.text(label, data)); + kb.row().text('🏠 В меню', 'home'); + return kb; +} + +async function renderFeed(ctx, kind, page) { + const total = countCaptures(kind); + const items = listCaptures({ kind, limit: PAGE, offset: page * PAGE }); + const title = KIND_TITLE[kind] ?? kind; + const body = items.length + ? `${title} — стр. ${page + 1}/${Math.max(1, Math.ceil(total / PAGE))}` + : `${title}: пусто.`; + await edit(ctx, body, feedKeyboard(kind, page, items, total)); +} + +async function renderItem(ctx, id) { + const item = getCapture(id); + if (!item) return edit(ctx, 'Запись не найдена (возможно, удалена по сроку хранения).', backTo('home')); + + const caption = formatCapture(item); + const back = backTo(`feed:${item.kind}:0`); + + if (item.media_path) { + try { + const buffer = decrypt(await readMedia(item.media_path)); + await ctx.replyWithDocument(new InputFile(buffer, fileName(item)), { caption, reply_markup: back }); + return; + } catch (err) { + logger.warn('bot: не удалось отдать медиа', err.message); + return ctx.reply(`${caption}\n\n⚠️ Медиа не удалось расшифровать.`, { reply_markup: back }); + } + } + await edit(ctx, caption, back); +} + +// ——— helpers ——— + +const backTo = (data) => new InlineKeyboard().text('◀ Назад', data); + +function previewLine(item) { + const who = item.sender || 'неизвестно'; + const what = item.media_kind ? `[${item.media_kind}]` : truncate(item.text ?? '', 30) || '—'; + return `${who}: ${what}`; +} + +function formatCapture(item) { + const when = new Date(item.captured_at * 1000).toLocaleString('ru-RU'); + const lines = [ + `${KIND_TITLE[item.kind] ?? item.kind}`, + `От: ${item.sender || 'неизвестно'}`, + `Когда: ${when}`, + ]; + if (item.media_kind) lines.push(`Медиа: ${item.media_kind}`); + if (item.text) lines.push('', item.text); + return lines.join('\n'); +} + +function fileName(item) { + const ext = { photo: 'jpg', video: 'mp4', voice: 'ogg', gif: 'mp4', audio: 'mp3' }[item.media_kind] ?? 'bin'; + return `${item.kind}_${item.id}.${ext}`; +} + +const truncate = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s); + +async function edit(ctx, text, keyboard) { + try { + if (ctx.callbackQuery) { + await ctx.editMessageText(text, { reply_markup: keyboard }); + } else { + await ctx.reply(text, { reply_markup: keyboard }); + } + } catch (err) { + if (!String(err.description ?? err).includes('message is not modified')) throw err; + } +} + +/** + * Присылает владельцу уведомление о свежем перехвате. Дёргается из модулей + * через контекст (ctx.notify) — модуль не знает деталей бота. + */ +export async function notifyCapture(bot, item) { + const kb = new InlineKeyboard().text('Открыть', `item:${item.id}`); + const head = item.kind === 'onetime' ? '👁 Одноразовое медиа' : '🗑 Удалённое сообщение'; + 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 }); + } catch (err) { + logger.warn('bot: не удалось уведомить владельца', err.message); + } +} diff --git a/src/commands/_template.js b/src/commands/_template.js deleted file mode 100644 index 3beeeab..0000000 --- a/src/commands/_template.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Шаблон команды. Файлы, чьё имя начинается с `_`, загрузчик пропускает, - * поэтому этот файл можно скопировать и переименовать — например, в price.js. - * - * Поля: - * command — имя без слэша, обязательное - * aliases — дополнительные триггеры (можно кириллицей) - * description — строка для /help и setMyCommands - * scope — 'all' | 'private' (только личка с ботом) | 'business' (только бизнес-чаты) - * adminOnly — доступно владельцу бизнес-аккаунта и ID из ADMIN_IDS - * hidden — не показывать в /help и не отправлять в setMyCommands - * handler — (ctx, args, { registry, rest }) => Promise - */ -export default { - command: 'template', - aliases: [], - description: 'Пример команды', - scope: 'all', - adminOnly: false, - hidden: true, - - async handler(ctx, args, { rest }) { - // args — массив слов после команды, rest — та же строка целиком. - await ctx.reply(args.length ? `Аргументы: ${args.join(', ')}` : `Команда без аргументов (rest: "${rest}")`); - }, -}; diff --git a/src/commands/business/pause.js b/src/commands/business/pause.js deleted file mode 100644 index 267fc08..0000000 --- a/src/commands/business/pause.js +++ /dev/null @@ -1,28 +0,0 @@ -import { config } from '../../config.js'; - -export default { - command: 'pause', - aliases: ['пауза'], - description: 'Остановить автоответы в этом чате: /pause [минуты | off]', - scope: 'business', - adminOnly: true, - - async handler(ctx, args) { - const [arg] = args; - - if (arg === 'off' || arg === 'выкл') { - ctx.business.resume(); - await ctx.reply('Автоответы снова включены в этом чате.'); - return; - } - - const minutes = arg ? Number(arg) : config.handoverPauseMs / 60_000; - if (!Number.isFinite(minutes) || minutes <= 0) { - await ctx.reply('Нужно число минут или "off". Например: /pause 60'); - return; - } - - ctx.business.pause(minutes * 60_000); - await ctx.reply(`Молчу в этом чате ${minutes} мин. Вернуть раньше — /pause off`); - }, -}; diff --git a/src/commands/business/status.js b/src/commands/business/status.js deleted file mode 100644 index 607f752..0000000 --- a/src/commands/business/status.js +++ /dev/null @@ -1,40 +0,0 @@ -import { connections } from '../../core/store.js'; - -const RIGHTS_TO_SHOW = [ - ['can_reply', 'отвечать'], - ['can_read_messages', 'читать переписку'], - ['can_delete_outgoing_messages', 'удалять свои сообщения'], - ['can_edit_name', 'менять имя'], - ['can_view_gifts_and_stars', 'видеть подарки и звёзды'], - ['can_manage_stories', 'управлять историями'], -]; - -export default { - command: 'status', - description: 'Состояние бизнес-подключений', - scope: 'all', - adminOnly: true, - - async handler(ctx) { - const all = connections.entries(); - if (!all.length) { - await ctx.reply( - 'Активных подключений нет.\n\nВключите Business Mode у бота в @BotFather, затем добавьте бота в Settings → Telegram Business → Chatbots.', - ); - return; - } - - const blocks = all.map(([id, record]) => { - const rights = RIGHTS_TO_SHOW.filter(([key]) => record.rights?.[key]).map(([, label]) => label); - - return [ - `Подключение ${id}`, - `владелец: ${record.ownerId}${record.ownerUsername ? ` (@${record.ownerUsername})` : ''}`, - `активно: ${record.isEnabled ? 'да' : 'нет'}`, - `права: ${rights.length ? rights.join(', ') : 'не переданы'}`, - ].join('\n'); - }); - - await ctx.reply(blocks.join('\n\n'), { parse_mode: 'HTML' }); - }, -}; diff --git a/src/commands/help.js b/src/commands/help.js deleted file mode 100644 index 4821c5c..0000000 --- a/src/commands/help.js +++ /dev/null @@ -1,23 +0,0 @@ -import { isOperator } from '../middlewares/business.js'; - -export default { - command: 'help', - aliases: ['помощь', 'справка'], - description: 'Список команд', - scope: 'all', - - async handler(ctx, args, { registry }) { - const operator = isOperator(ctx); - - // Админские команды показываем только тем, кто может их вызвать. - const available = registry.visible().filter((cmd) => operator || !cmd.adminOnly); - - const lines = available.map((cmd) => { - const aliases = cmd.aliases.length ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : ''; - const mark = cmd.adminOnly ? ' 🔒' : ''; - return `/${cmd.command}${aliases} — ${cmd.description}${mark}`; - }); - - await ctx.reply(lines.length ? `Команды:\n${lines.join('\n')}` : 'Команд пока нет.'); - }, -}; diff --git a/src/commands/id.js b/src/commands/id.js deleted file mode 100644 index 9022b9f..0000000 --- a/src/commands/id.js +++ /dev/null @@ -1,23 +0,0 @@ -export default { - command: 'id', - aliases: ['whoami'], - description: 'Показать ID пользователя и чата', - scope: 'all', - - async handler(ctx) { - const lines = [ - `Ваш ID: ${ctx.from?.id}`, - `Чат: ${ctx.chat?.id} (${ctx.chat?.type})`, - ]; - - if (ctx.business) { - lines.push( - `Подключение: ${ctx.business.connectionId}`, - `Владелец: ${ctx.business.ownerId ?? 'неизвестен'}`, - `Вы владелец: ${ctx.business.isOwner ? 'да' : 'нет'}`, - ); - } - - await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' }); - }, -}; diff --git a/src/commands/ping.js b/src/commands/ping.js deleted file mode 100644 index d734b47..0000000 --- a/src/commands/ping.js +++ /dev/null @@ -1,17 +0,0 @@ -export default { - command: 'ping', - description: 'Проверка живости и задержки апдейта', - scope: 'all', - - async handler(ctx) { - // Telegram отдаёт date в секундах, поэтому точность лага — около секунды. - const sentAt = (ctx.msg?.date ?? 0) * 1000; - const lagMs = sentAt ? Date.now() - sentAt : null; - - const lines = ['pong']; - if (lagMs !== null) lines.push(`задержка апдейта: ~${lagMs} мс`); - lines.push(`uptime: ${Math.round(process.uptime())} с`); - - await ctx.reply(lines.join('\n')); - }, -}; diff --git a/src/commands/start.js b/src/commands/start.js deleted file mode 100644 index 18a0bd9..0000000 --- a/src/commands/start.js +++ /dev/null @@ -1,28 +0,0 @@ -export default { - command: 'start', - aliases: ['старт'], - description: 'Приветствие и краткая справка', - scope: 'all', - - async handler(ctx, args, { registry }) { - const name = ctx.from?.first_name ?? 'там'; - - // В бизнес-чате бот пишет от имени владельца, поэтому текст другой: - // клиент не должен видеть, что общается с ботом-помощником. - if (ctx.business) { - await ctx.reply( - `Здравствуйте, ${name}! Спасибо за обращение — уже смотрю ваш вопрос и скоро вернусь с ответом.`, - ); - return; - } - - const commands = registry - .visible() - .map((cmd) => `/${cmd.command} — ${cmd.description}`) - .join('\n'); - - await ctx.reply( - `Привет, ${name}. Я бот-помощник для Telegram Business.\n\nДоступные команды:\n${commands}`, - ); - }, -}; diff --git a/src/config.js b/src/config.js index 885a3d7..08a11bd 100644 --- a/src/config.js +++ b/src/config.js @@ -1,26 +1,64 @@ -const parseIds = (raw) => - (raw ?? '') - .split(',') - .map((part) => part.trim()) - .filter(Boolean) - .map(Number) - .filter((id) => Number.isInteger(id)); +import { fileURLToPath } from 'node:url'; -export const config = { - token: process.env.BOT_TOKEN ?? '', - adminIds: parseIds(process.env.ADMIN_IDS), - handoverPauseMs: Number(process.env.HANDOVER_PAUSE_MINUTES ?? 30) * 60_000, - logLevel: process.env.LOG_LEVEL ?? 'info', - dataDir: new URL('../data/', import.meta.url), +const req = (name) => { + const value = process.env[name]; + if (!value) throw new Error(`${name} не задан в .env (см. .env.example).`); + return value; }; +const num = (name) => { + const value = Number(process.env[name]); + if (!Number.isInteger(value)) throw new Error(`${name} должен быть целым числом.`); + return value; +}; + +/** + * Конфиг MTProto-юзербота. api_id/api_hash берутся на https://my.telegram.org, + * строка сессии генерируется скриптом login (npm run login) и кладётся в .env. + * Всё, что чувствительно (сессия, ключ), живёт только в .env и никуда не уходит. + */ +export const config = { + // Доступ к MTProto. + apiId: () => num('API_ID'), + apiHash: () => req('API_HASH'), + session: process.env.SESSION ?? '', + + // Компаньон-бот (@BotFather) — единственный UI юзербота. + botToken: () => req('BOT_TOKEN'), + // Telegram id владельца: только он управляет ботом и получает перехваты. + ownerId: () => num('OWNER_ID'), + + // Ключ шифрования локального хранилища (32+ байта, hex или ascii). + encryptionKey: () => req('ENCRYPTION_KEY'), + + // Сколько дней держать перехваченное, прежде чем чистить (0 — бессрочно). + retentionDays: Number(process.env.RETENTION_DAYS ?? 30), + + // Качать ли медиа входящих сразу (нужно, чтобы восстанавливать удалённые + // картинки/видео — при удалении ссылка на файл уже мертва). Ест диск. + cacheMedia: process.env.CACHE_MEDIA !== '0', + + dataDir: new URL('../data/', import.meta.url), + mediaDir: new URL('../data/media/', import.meta.url), + logLevel: process.env.LOG_LEVEL ?? 'info', +}; + +export const dataPath = () => fileURLToPath(config.dataDir); +export const mediaPath = () => fileURLToPath(config.mediaDir); + +/** Проверяем всё, что нужно для старта, одним махом — понятная ошибка вместо падения в рантайме. */ export function assertConfig() { - if (!config.token) { - throw new Error('BOT_TOKEN не задан. Скопируйте .env.example в .env и вставьте токен от @BotFather.'); + 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 (!Number.isFinite(config.handoverPauseMs) || config.handoverPauseMs < 0) { - throw new Error('HANDOVER_PAUSE_MINUTES должен быть неотрицательным числом.'); + if (!config.session) { + throw new Error('SESSION пуст. Выполните: npm run login — и вставьте строку в .env.'); } } -export const isAdmin = (userId) => config.adminIds.includes(userId); +export const isOwner = (id) => Number(id) === config.ownerId(); diff --git a/src/core/crypto.js b/src/core/crypto.js new file mode 100644 index 0000000..67a57e2 --- /dev/null +++ b/src/core/crypto.js @@ -0,0 +1,47 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'; + +import { config } from '../config.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LEN = 12; +const TAG_LEN = 16; + +let keyCache = null; + +/** + * Ключ из ENCRYPTION_KEY. Принимаем как 64-символьный hex (готовые 32 байта), + * так и произвольную парольную фразу — её растягиваем scrypt до 32 байт. + */ +function getKey() { + if (keyCache) return keyCache; + const raw = config.encryptionKey(); + if (/^[0-9a-f]{64}$/i.test(raw)) { + keyCache = Buffer.from(raw, 'hex'); + } else { + keyCache = scryptSync(raw, 'telegrambusiness/v1', 32); + } + return keyCache; +} + +/** + * Шифрует буфер. Формат на выходе: [iv(12)][tag(16)][ciphertext]. + * Так весь артефакт самодостаточен — расшифровка не требует внешних метаданных. + */ +export function encrypt(plaintext) { + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv(ALGORITHM, getKey(), iv); + const data = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), data]); +} + +export function decrypt(payload) { + const iv = payload.subarray(0, IV_LEN); + const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN); + const data = payload.subarray(IV_LEN + TAG_LEN); + const decipher = createDecipheriv(ALGORITHM, getKey(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]); +} + +export const encryptString = (text) => encrypt(Buffer.from(text, 'utf8')).toString('base64'); +export const decryptString = (b64) => decrypt(Buffer.from(b64, 'base64')).toString('utf8'); diff --git a/src/core/db.js b/src/core/db.js new file mode 100644 index 0000000..4f383a3 --- /dev/null +++ b/src/core/db.js @@ -0,0 +1,137 @@ +import { mkdirSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; + +import { config, dataPath } from '../config.js'; +import { encryptString, decryptString } from './crypto.js'; +import { logger } from './logger.js'; + +let db = null; + +/** + * Локальный индекс на node:sqlite. Тексты храним зашифрованными (AES-GCM), + * бинарные медиа лежат отдельными файлами в data/media, здесь только путь. + */ +export function openDb() { + if (db) return db; + mkdirSync(dataPath(), { recursive: true }); + db = new DatabaseSync(`${dataPath()}/userbot.db`); + db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + msg_id INTEGER PRIMARY KEY, + chat_id TEXT, + sender_id TEXT, + sender TEXT, + text_enc TEXT, + media_kind TEXT, + media_path TEXT, + created_at INTEGER + ); + CREATE TABLE IF NOT EXISTS captures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT, -- 'deleted' | 'onetime' + chat_id TEXT, + sender_id TEXT, + sender TEXT, + text_enc TEXT, + media_kind TEXT, + media_path TEXT, + captured_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_captures_time ON captures(captured_at DESC); + `); + logger.debug('sqlite: база готова'); + return db; +} + +const now = () => Math.floor(Date.now() / 1000); + +/** Кладём каждое входящее в кольцевой кэш — источник для восстановления удалённых. */ +export function cacheMessage({ msgId, chatId, senderId, sender, text, mediaKind, mediaPath }) { + openDb() + .prepare( + `INSERT OR REPLACE INTO messages + (msg_id, chat_id, sender_id, sender, text_enc, media_kind, media_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + msgId, + String(chatId ?? ''), + String(senderId ?? ''), + sender ?? '', + text ? encryptString(text) : null, + mediaKind ?? null, + mediaPath ?? null, + now(), + ); +} + +export function getCachedMessage(msgId) { + const row = openDb().prepare('SELECT * FROM messages WHERE msg_id = ?').get(msgId); + return row ? decodeRow(row) : null; +} + +/** Записываем факт перехвата (удаление или one-time) — это и есть лента в боте. */ +export function saveCapture(kind, data) { + const info = openDb() + .prepare( + `INSERT INTO captures + (kind, chat_id, sender_id, sender, text_enc, media_kind, media_path, captured_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + kind, + String(data.chatId ?? ''), + String(data.senderId ?? ''), + data.sender ?? '', + data.text ? encryptString(data.text) : null, + data.mediaKind ?? null, + data.mediaPath ?? null, + now(), + ); + return Number(info.lastInsertRowid); +} + +export function listCaptures({ kind, limit = 10, offset = 0 } = {}) { + const where = kind ? 'WHERE kind = ?' : ''; + const args = kind ? [kind, limit, offset] : [limit, offset]; + return openDb() + .prepare(`SELECT * FROM captures ${where} ORDER BY captured_at DESC LIMIT ? OFFSET ?`) + .all(...args) + .map(decodeRow); +} + +export function getCapture(id) { + const row = openDb().prepare('SELECT * FROM captures WHERE id = ?').get(id); + return row ? decodeRow(row) : null; +} + +export function countCaptures(kind) { + const where = kind ? 'WHERE kind = ?' : ''; + const args = kind ? [kind] : []; + return openDb().prepare(`SELECT COUNT(*) AS n FROM captures ${where}`).get(...args).n; +} + +/** Чистка по retentionDays — вызывается по таймеру из index.js. */ +export function purgeOld() { + if (!config.retentionDays) return { messages: 0, captures: 0 }; + const cutoff = now() - config.retentionDays * 86400; + const m = openDb().prepare('DELETE FROM messages WHERE created_at < ?').run(cutoff); + const c = openDb().prepare('DELETE FROM captures WHERE captured_at < ?').run(cutoff); + return { messages: Number(m.changes), captures: Number(c.changes) }; +} + +function decodeRow(row) { + return { + ...row, + text: row.text_enc ? safeDecrypt(row.text_enc) : null, + }; +} + +function safeDecrypt(value) { + try { + return decryptString(value); + } catch (err) { + logger.warn('sqlite: не удалось расшифровать текст записи', err.message); + return '[не удалось расшифровать]'; + } +} diff --git a/src/core/dispatcher.js b/src/core/dispatcher.js deleted file mode 100644 index 97db10e..0000000 --- a/src/core/dispatcher.js +++ /dev/null @@ -1,68 +0,0 @@ -import { isAdmin } from '../config.js'; -import { logger } from './logger.js'; - -/** - * Разбирает команду из ctx.msg — то есть и из `message`, и из `business_message`. - * Родной bot.command() в grammY смотрит только на message/channel_post, - * поэтому в бизнес-чатах он молчит. - */ -export function parseCommand(ctx) { - const msg = ctx.msg; - const text = msg?.text ?? msg?.caption; - if (!text || !text.startsWith('/')) return null; - - const entities = msg.entities ?? msg.caption_entities ?? []; - const entity = entities.find((e) => e.type === 'bot_command' && e.offset === 0); - - // Латинские команды Telegram размечает сам. Для алиасов вроде "/старт" - // разметки не будет, поэтому режем текст руками. - const rawLength = entity?.length ?? (text.split(/\s/, 1)[0]?.length ?? 0); - const raw = text.slice(1, rawLength); - if (!raw) return null; - - const [name, target] = raw.split('@'); - if (target && target.toLowerCase() !== ctx.me?.username?.toLowerCase()) return null; - - const rest = text.slice(rawLength).trim(); - return { - name: name.toLowerCase(), - rest, - args: rest.length ? rest.split(/\s+/) : [], - }; -} - -const inScope = (scope, ctx) => { - if (scope === 'all') return true; - if (scope === 'business') return Boolean(ctx.businessConnectionId); - return !ctx.businessConnectionId && ctx.chat?.type === 'private'; -}; - -/** - * @param {import('./registry.js').CommandRegistry} registry - */ -export function createDispatcher(registry) { - return async (ctx, next) => { - // На правках сообщений команды не выполняем, иначе один /start сработает дважды. - if (ctx.editedMessage || ctx.editedBusinessMessage) return next(); - - const parsed = parseCommand(ctx); - if (!parsed) return next(); - - const command = registry.resolve(parsed.name); - if (!command) return next(); - - if (!inScope(command.scope, ctx)) { - logger.debug(`/${parsed.name}: вне scope "${command.scope}", пропускаем`); - return next(); - } - - const allowed = !command.adminOnly || isAdmin(ctx.from?.id) || ctx.business?.isOwner === true; - if (!allowed) { - logger.warn(`/${parsed.name}: отказано пользователю ${ctx.from?.id}`); - return; - } - - logger.info(`/${command.command} от ${ctx.from?.id}${ctx.businessConnectionId ? ' (business)' : ''}`); - await command.handler(ctx, parsed.args, { registry, rest: parsed.rest }); - }; -} diff --git a/src/core/loader.js b/src/core/loader.js index b73b7d9..49f5e75 100644 --- a/src/core/loader.js +++ b/src/core/loader.js @@ -3,30 +3,30 @@ import { join, relative, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { logger } from './logger.js'; -import { CommandRegistry } from './registry.js'; +import { ModuleRegistry } from './registry.js'; -const COMMANDS_DIR = fileURLToPath(new URL('../commands/', import.meta.url)); +const MODULES_DIR = fileURLToPath(new URL('../modules/', import.meta.url)); /** - * Забирает все *.js из src/commands (включая подпапки) и складывает в реестр. + * Забирает все *.js из src/modules (включая подпапки) в реестр. * Файлы, начинающиеся с `_`, игнорируются — удобно для черновиков и хелперов. */ -export async function loadCommands(dir = COMMANDS_DIR) { - const registry = new CommandRegistry(); +export async function loadModules(dir = MODULES_DIR) { + const registry = new ModuleRegistry(); const entries = await readdir(dir, { recursive: true, withFileTypes: true }); const files = entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.js') && !entry.name.startsWith('_')) - .map((entry) => join(entry.parentPath ?? entry.path, entry.name)) + .filter((e) => e.isFile() && e.name.endsWith('.js') && !e.name.startsWith('_')) + .map((e) => join(e.parentPath ?? e.path, e.name)) .sort(); for (const file of files) { const source = relative(dir, file).split(sep).join('/'); - const module = await import(pathToFileURL(file).href); - registry.register(module.default, source); - logger.debug(`команда загружена: ${source}`); + const mod = await import(pathToFileURL(file).href); + registry.register(mod.default, source); + logger.debug(`модуль загружен: ${source}`); } - logger.info(`Загружено команд: ${registry.list().length}`); + logger.info(`Загружено модулей: ${registry.list().length}`); return registry; } diff --git a/src/core/logger.js b/src/core/logger.js index 2df60bf..fd2edf1 100644 --- a/src/core/logger.js +++ b/src/core/logger.js @@ -1,10 +1,10 @@ import { config } from '../config.js'; const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; -const threshold = LEVELS[config.logLevel] ?? LEVELS.info; +const threshold = () => LEVELS[config.logLevel] ?? LEVELS.info; const write = (level, stream, args) => { - if (LEVELS[level] < threshold) return; + if (LEVELS[level] < threshold()) return; stream(`[${new Date().toISOString()}] ${level.toUpperCase()}`, ...args); }; diff --git a/src/core/media.js b/src/core/media.js new file mode 100644 index 0000000..d0fcde7 --- /dev/null +++ b/src/core/media.js @@ -0,0 +1,60 @@ +import { mkdirSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +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'; +} + +/** + * Одноразовые медиа помечены ttlSeconds на самом медиа. Такие файлы Telegram + * стирает у клиента после просмотра — их важно скачать в момент прихода. + */ +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; + + try { + const buffer = await client.downloadMedia(message, {}); + if (!buffer || !buffer.length) return { kind, path: null }; + + mkdirSync(mediaPath(), { recursive: true }); + const name = `${tag}_${message.id ?? 'x'}_${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 }; + } catch (err) { + logger.warn(`media: не удалось скачать ${kind}:`, err.message); + return { kind, path: null }; + } +} + +/** Читает зашифрованный медиа-файл с диска по имени (как хранится в БД). */ +export function readMedia(name) { + return readFile(fileURLToPath(new URL(name, config.mediaDir))); +} diff --git a/src/core/registry.js b/src/core/registry.js index 9a20a9d..1681225 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -1,84 +1,87 @@ -export const SCOPES = ['all', 'private', 'business']; +import { logger } from './logger.js'; -/** Telegram принимает в setMyCommands только [a-z0-9_]{1,32} */ -const TELEGRAM_COMMAND = /^[a-z0-9_]{1,32}$/; - -export class CommandRegistry { - /** @type {Map} триггер (имя или алиас) -> команда */ - #triggers = new Map(); - /** @type {object[]} */ - #commands = []; +/** + * Реестр модулей юзербота. Модуль — это файл в src/modules, который экспортирует + * по умолчанию объект вида: + * + * export default { + * name: 'antidelete', + * description: 'Ловит удалённые сообщения', + * setup(ctx) { ... }, // однократная инициализация + * onMessage(message, ctx) { ... }, // новое входящее (личные чаты) + * onDeleted(event, ctx) { ... }, // событие удаления + * onEdited(message, ctx) { ... }, // правка сообщения + * panel: { title, id, render, ... }, // раздел в компаньон-боте (опц.) + * } + * + * Обработчики опциональны — модуль объявляет только то, что ему нужно. + */ +export class ModuleRegistry { + #modules = []; + #byName = new Map(); register(definition, source = '') { - const command = normalize(definition, source); - - for (const trigger of [command.command, ...command.aliases]) { - const taken = this.#triggers.get(trigger); - if (taken) { - throw new Error( - `Конфликт триггеров: "${trigger}" из ${source} уже занят командой "${taken.command}" (${taken.source}).`, - ); - } - this.#triggers.set(trigger, command); + const module = normalize(definition, source); + if (this.#byName.has(module.name)) { + throw new Error(`Модуль "${module.name}" уже зарегистрирован (${source}).`); } - - this.#commands.push(command); - return command; + this.#byName.set(module.name, module); + this.#modules.push(module); + return module; } - resolve(trigger) { - return this.#triggers.get(trigger.toLowerCase()); - } - - /** Все команды в порядке регистрации. */ list() { - return [...this.#commands]; + return [...this.#modules]; } - /** Что показываем в /help. */ - visible() { - return this.#commands.filter((cmd) => !cmd.hidden); + get(name) { + return this.#byName.get(name); } - /** Что уходит в setMyCommands: без скрытых, админских и нелатинских алиасов. */ - forTelegram() { - return this.#commands - .filter((cmd) => !cmd.hidden && !cmd.adminOnly && TELEGRAM_COMMAND.test(cmd.command)) - .map((cmd) => ({ command: cmd.command, description: cmd.description })); + /** Модули, у которых объявлен нужный хук — чтобы не дёргать пустышки. */ + withHook(hook) { + return this.#modules.filter((m) => typeof m[hook] === 'function'); + } + + panels() { + return this.#modules.filter((m) => m.panel).map((m) => ({ ...m.panel, module: m.name })); } } -function normalize(definition, source) { - if (!definition || typeof definition !== 'object') { - throw new Error(`${source}: файл команды должен экспортировать объект по умолчанию.`); +function normalize(def, source) { + if (!def || typeof def !== 'object') { + throw new Error(`${source}: модуль должен экспортировать объект по умолчанию.`); } - - const { command, aliases = [], description = '', scope = 'all', adminOnly = false, hidden = false, handler } = definition; - - if (typeof command !== 'string' || !command.trim()) { - throw new Error(`${source}: не задано поле "command".`); + if (typeof def.name !== 'string' || !def.name.trim()) { + throw new Error(`${source}: у модуля не задано поле "name".`); } - if (command.startsWith('/')) { - throw new Error(`${source}: имя команды указывается без слэша — "${command.slice(1)}", а не "${command}".`); + for (const hook of ['setup', 'onMessage', 'onDeleted', 'onEdited']) { + if (def[hook] !== undefined && typeof def[hook] !== 'function') { + throw new Error(`${source}: "${hook}" должен быть функцией.`); + } } - if (typeof handler !== 'function') { - throw new Error(`${source}: поле "handler" должно быть функцией.`); - } - if (!SCOPES.includes(scope)) { - throw new Error(`${source}: неизвестный scope "${scope}". Допустимо: ${SCOPES.join(', ')}.`); - } - if (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== 'string')) { - throw new Error(`${source}: "aliases" должен быть массивом строк.`); - } - return { - command: command.toLowerCase(), - aliases: aliases.map((alias) => alias.toLowerCase()), - description, - scope, - adminOnly: Boolean(adminOnly), - hidden: Boolean(hidden), - handler, + name: def.name, + description: def.description ?? '', + setup: def.setup, + onMessage: def.onMessage, + onDeleted: def.onDeleted, + onEdited: def.onEdited, + panel: def.panel, source, }; } + +/** + * Прогоняет хук по всем модулям, где он объявлен. Ошибка одного модуля + * не роняет остальные — логируем и идём дальше. + */ +export async function runHook(registry, hook, payload, ctx) { + for (const module of registry.withHook(hook)) { + try { + await module[hook](payload, ctx); + } catch (err) { + logger.error(`Модуль "${module.name}", хук ${hook}:`, err); + } + } +} diff --git a/src/core/store.js b/src/core/store.js deleted file mode 100644 index fcd4415..0000000 --- a/src/core/store.js +++ /dev/null @@ -1,83 +0,0 @@ -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; - -import { config } from '../config.js'; -import { logger } from './logger.js'; - -/** - * Простейшее JSON-хранилище: держим состояние в памяти, на диск пишем - * дебаунсом через временный файл. Для продакшена меняется на Redis/Postgres, - * интерфейс (get/set/delete) при этом остаётся тем же. - */ -export class JsonStore { - #path; - #data = new Map(); - #flushTimer = null; - #writing = Promise.resolve(); - - constructor(fileName) { - this.#path = fileURLToPath(new URL(fileName, config.dataDir)); - } - - async load() { - try { - const raw = await readFile(this.#path, 'utf8'); - this.#data = new Map(Object.entries(JSON.parse(raw))); - logger.debug(`store ${this.#path}: загружено записей — ${this.#data.size}`); - } catch (err) { - if (err.code !== 'ENOENT') throw err; - logger.debug(`store ${this.#path}: файла нет, начинаем с пустого состояния`); - } - return this; - } - - get(key) { - return this.#data.get(String(key)); - } - - has(key) { - return this.#data.has(String(key)); - } - - entries() { - return [...this.#data.entries()]; - } - - set(key, value) { - this.#data.set(String(key), value); - this.#scheduleFlush(); - return value; - } - - delete(key) { - const existed = this.#data.delete(String(key)); - if (existed) this.#scheduleFlush(); - return existed; - } - - #scheduleFlush() { - if (this.#flushTimer) return; - this.#flushTimer = setTimeout(() => { - this.#flushTimer = null; - this.flush().catch((err) => logger.error('store: не удалось записать файл', err)); - }, 300); - this.#flushTimer.unref?.(); - } - - async flush() { - this.#writing = this.#writing.then(async () => { - const payload = JSON.stringify(Object.fromEntries(this.#data), null, 2); - const tmp = `${this.#path}.tmp`; - await mkdir(fileURLToPath(config.dataDir), { recursive: true }); - await writeFile(tmp, payload, 'utf8'); - await rename(tmp, this.#path); - }); - return this.#writing; - } -} - -/** business_connection_id -> { ownerId, userChatId, isEnabled, rights, updatedAt } */ -export const connections = new JsonStore('connections.json'); - -/** `${connectionId}:${chatId}` -> timestamp, до которого автоответы выключены */ -export const handovers = new JsonStore('handovers.json'); diff --git a/src/handlers/connection.js b/src/handlers/connection.js deleted file mode 100644 index 261fc58..0000000 --- a/src/handlers/connection.js +++ /dev/null @@ -1,33 +0,0 @@ -import { logger } from '../core/logger.js'; -import { connections } from '../core/store.js'; -import { saveConnection } from '../middlewares/business.js'; - -/** - * business_connection приходит, когда владелец подключает бота, меняет права - * или отключает его. Здесь единственное место, где мы узнаём connection.id, — - * без него нельзя отправить ни одного сообщения, поэтому пишем его в хранилище. - */ -export function registerConnectionHandlers(bot) { - bot.on('business_connection', async (ctx) => { - const connection = ctx.businessConnection; - - if (connection.is_enabled) { - saveConnection(connection); - logger.info(`Бот подключён к аккаунту ${connection.user.id} (${connection.id})`); - - // user_chat_id — личный чат с владельцем, туда пишем без business_connection_id. - await ctx.api.sendMessage( - connection.user_chat_id, - 'Бот подключён к вашему бизнес-аккаунту. Проверить права — /status, остановить автоответы в конкретном чате — /pause.', - ).catch((err) => logger.warn('Не удалось написать владельцу', err)); - return; - } - - connections.delete(connection.id); - logger.info(`Подключение ${connection.id} отключено владельцем ${connection.user.id}`); - }); - - bot.on('deleted_business_messages', (ctx) => { - logger.debug(`Удалено сообщений: ${ctx.deletedBusinessMessages.message_ids.length}`); - }); -} diff --git a/src/handlers/fallback.js b/src/handlers/fallback.js deleted file mode 100644 index 83978b3..0000000 --- a/src/handlers/fallback.js +++ /dev/null @@ -1,32 +0,0 @@ -import { logger } from '../core/logger.js'; - -/** - * Всё, что не команда. Сюда же вешается автоответчик, FAQ или переход в LLM. - * Вызывается последним в цепочке, поэтому next() уже не нужен. - */ -export function fallback() { - return async (ctx) => { - const text = ctx.msg?.text ?? ctx.msg?.caption ?? ''; - - if (ctx.business) { - logger.debug(`Бизнес-чат ${ctx.chat?.id}: сообщение без команды`); - - // Отметку «прочитано» ставим только если владелец выдал право читать чат. - if (ctx.business.rights?.can_read_messages && ctx.msgId) { - await ctx.api - .readBusinessMessage(ctx.business.connectionId, ctx.chat.id, ctx.msgId) - .catch((err) => logger.debug('readBusinessMessage не сработал', err.description ?? err)); - } - - await ctx.reply('Принял ваше сообщение, скоро ответим.'); - return; - } - - if (!text.startsWith('/')) { - await ctx.reply('Не понял. Посмотрите /help — там список команд.'); - return; - } - - await ctx.reply(`Команда ${text.split(/\s/)[0]} не найдена. Список — /help`); - }; -} diff --git a/src/index.js b/src/index.js index a11ca06..14a7cb0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,34 +1,62 @@ -import { GrammyError } from 'grammy'; - -import { ALLOWED_UPDATES, createBot, shutdown, syncCommands } from './bot.js'; +import { assertConfig, config } from './config.js'; +import { createBot } from './bot/panel.js'; +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'; -const { bot, registry } = await createBot(); +// 1. Конфиг и БД — падаем понятной ошибкой, если что-то не задано. +try { + assertConfig(); +} catch (err) { + logger.error(err.message); + process.exit(1); +} +openDb(); -for (const signal of ['SIGINT', 'SIGTERM']) { - process.once(signal, () => { - shutdown(bot).then( - () => process.exit(0), - (err) => { - logger.error('Ошибка при остановке', err); - process.exit(1); - }, - ); - }); +// 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); } -try { - await bot.start({ - allowed_updates: ALLOWED_UPDATES, - // Первый апдейт приходит уже после getMe, поэтому меню обновляем здесь. - onStart: async ({ username }) => { - await syncCommands(bot, registry).catch((err) => logger.warn('setMyCommands не прошёл', err)); - logger.info(`@${username} запущен. Апдейты: ${ALLOWED_UPDATES.join(', ')}`); - }, +// 3. Компаньон-бот (единственный UI) — запускаем в фоне. +const bot = createBot(); +bot.start({ onStart: ({ username }) => logger.info(`Панель поднята: @${username}`) }).catch((err) => { + logger.error('Компаньон-бот не запустился (проверьте BOT_TOKEN):', err.message); + process.exit(1); +}); + +// 4. Модули: грузим и раздаём им события юзербота. +const registry = await loadModules(); +const ctx = createContext({ client, bot, me, registry }); +await runHook(registry, 'setup', undefined, ctx); +wireEvents(client, registry, ctx); + +logger.info(`Готово. Активных модулей: ${registry.list().map((m) => m.name).join(', ')}`); + +// 5. Периодическая чистка по сроку хранения. +if (config.retentionDays > 0) { + const timer = setInterval(() => { + const { messages, captures } = purgeOld(); + if (messages || captures) logger.info(`Чистка: удалено сообщений ${messages}, перехватов ${captures}`); + }, 6 * 3600 * 1000); + timer.unref(); +} + +// 6. Аккуратная остановка. +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, async () => { + logger.info('Останавливаюсь…'); + await bot.stop().catch(() => {}); + await client.disconnect().catch(() => {}); + process.exit(0); }); -} catch (err) { - // bot.catch() ловит ошибки апдейтов, но не падения на старте. - if (err instanceof GrammyError && err.error_code === 401) { - logger.error('Telegram не принял токен (401). Проверьте BOT_TOKEN в .env.'); - } else { - logger.error('Не удалось запустить б \ No newline at end of file +} diff --git a/src/middlewares/business.js b/src/middlewares/business.js deleted file mode 100644 index 0d03aa3..0000000 --- a/src/middlewares/business.js +++ /dev/null @@ -1,76 +0,0 @@ -import { isAdmin } from '../config.js'; -import { logger } from '../core/logger.js'; -import { connections, handovers } from '../core/store.js'; - -export const pauseKey = (connectionId, chatId) => `${connectionId}:${chatId}`; - -export const isPaused = (connectionId, chatId) => { - const until = handovers.get(pauseKey(connectionId, chatId)); - if (!until) return false; - if (until > Date.now()) return true; - handovers.delete(pauseKey(connectionId, chatId)); - return false; -}; - -export function saveConnection(connection) { - return connections.set(connection.id, { - ownerId: connection.user.id, - ownerUsername: connection.user.username ?? null, - userChatId: connection.user_chat_id, - isEnabled: connection.is_enabled ?? true, - rights: connection.rights ?? null, - updatedAt: Date.now(), - }); -} - -/** - * Кладёт в ctx.business всё, что нужно обработчикам: кто владелец аккаунта, - * пишет ли он сам прямо сейчас, какие права выданы боту. - * - * Данные о подключении приходят один раз в апдейте business_connection, - * поэтому храним их и при промахе кэша дотягиваем через getBusinessConnection. - */ -export function businessContext() { - return async (ctx, next) => { - const connectionId = ctx.businessConnectionId; - if (!connectionId) { - ctx.business = null; - return next(); - } - - let record = connections.get(connectionId); - if (!record) { - try { - record = saveConnection(await ctx.api.getBusinessConnection(connectionId)); - logger.info(`Подключение ${connectionId} восстановлено через getBusinessConnection`); - } catch (err) { - logger.error(`Не удалось получить подключение ${connectionId}`, err); - } - } - - const chatId = ctx.chat?.id; - ctx.business = { - connectionId, - ownerId: record?.ownerId ?? null, - userChatId: record?.userChatId ?? null, - rights: record?.rights ?? null, - isEnabled: record?.isEnabled ?? true, - canReply: record?.rights?.can_reply ?? true, - isOwner: Boolean(record?.ownerId && ctx.from?.id === record.ownerId), - isPaused: chatId != null ? isPaused(connectionId, chatId) : false, - pause(ms) { - if (chatId == null) return; - handovers.set(pauseKey(connectionId, chatId), Date.now() + ms); - }, - resume() { - if (chatId == null) return; - handovers.delete(pauseKey(connectionId, chatId)); - }, - }; - - return next(); - }; -} - -/** Владелец аккаунта или админ из ADMIN_IDS. */ -export const isOperator = (ctx) => ctx.business?.isOwner === true || isAdmin(ctx.from?.id); diff --git a/src/middlewares/handover.js b/src/middlewares/handover.js deleted file mode 100644 index ab97426..0000000 --- a/src/middlewares/handover.js +++ /dev/null @@ -1,39 +0,0 @@ -import { config } from '../config.js'; -import { parseCommand } from '../core/dispatcher.js'; -import { logger } from '../core/logger.js'; - -/** - * Передача диалога человеку. - * - * В бизнес-чате апдейт business_message приходит и на сообщения клиента, - * и на собственные сообщения владельца. Если владелец ответил вручную — - * бот замолкает в этом чате на HANDOVER_PAUSE_MINUTES, чтобы клиент - * не получал два ответа сразу. Команды владельца при этом продолжают работать. - */ -export function handover() { - return async (ctx, next) => { - const business = ctx.business; - if (!business) return next(); - - const isCommand = Boolean(parseCommand(ctx)); - - if (business.isOwner) { - if (isCommand) return next(); - business.pause(config.handoverPauseMs); - logger.info(`Владелец отвечает сам в чате ${ctx.chat?.id} — пауза на ${config.handoverPauseMs / 60_000} мин`); - return; - } - - if (business.isPaused) { - logger.debug(`Чат ${ctx.chat?.id} на паузе — молчим`); - return; - } - - if (!business.canReply) { - logger.warn(`Нет права can_reply в подключении ${business.connectionId}`); - return; - } - - return next(); - }; -} diff --git a/src/modules/antidelete.js b/src/modules/antidelete.js new file mode 100644 index 0000000..a0615e4 --- /dev/null +++ b/src/modules/antidelete.js @@ -0,0 +1,33 @@ +import { getCachedMessage } from '../core/db.js'; +import { logger } from '../core/logger.js'; + +/** + * Ловит удаления. MTProto в событии удаления присылает ТОЛЬКО id сообщений — + * без содержимого и (в личных чатах) без чата. Поэтому восстанавливаем из кэша, + * который наполняет модуль [cache]. Нашли в кэше — значит это было входящее + * в личке: сохраняем как перехват и уведомляем владельца. + */ +export default { + name: 'antidelete', + description: 'Сохраняет удалённые собеседником сообщения и медиа', + + async onDeleted(event, ctx) { + const ids = event.deletedIds ?? []; + if (!ids.length) return; + + for (const id of ids) { + const cached = getCachedMessage(id); + if (!cached) continue; // не наше/не кэшировали — пропускаем + + await ctx.capture('deleted', { + chatId: cached.chat_id, + senderId: cached.sender_id, + sender: cached.sender, + text: cached.text, + mediaKind: cached.media_kind, + mediaPath: cached.media_path, + }); + logger.info(`antidelete: восстановлено удалённое сообщение ${id} от ${cached.sender || '?'}`); + } + }, +}; diff --git a/src/modules/cache.js b/src/modules/cache.js new file mode 100644 index 0000000..892132b --- /dev/null +++ b/src/modules/cache.js @@ -0,0 +1,50 @@ +import { config } from '../config.js'; +import { cacheMessage } from '../core/db.js'; +import { downloadEncrypted, isOneTime } from '../core/media.js'; +import { isPrivateIncoming } from '../userbot/events.js'; + +/** + * Фундамент для antidelete: кладёт каждое входящее из личных диалогов в кэш. + * При удалении сообщение уже недоступно у Telegram — восстановить его можно + * только из этого кэша. Поэтому текст сохраняем всегда, а медиа — сразу качаем + * (если включён CACHE_MEDIA), потому что после удаления ссылка на файл мертва. + */ +export default { + name: 'cache', + description: 'Кэширует входящие личные сообщения — основа для antidelete', + + async onMessage(message, ctx) { + if (!isPrivateIncoming(message, ctx.me)) return; + + // Одноразовые ведёт отдельный модуль onetime — тут их не трогаем, + // чтобы не качать дважды. + if (isOneTime(message)) 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'; // знаем, что медиа было, но файл не кэшируем + } + + const sender = await message.getSender().catch(() => null); + cacheMessage({ + msgId: message.id, + chatId: message.chatId, + senderId: message.senderId, + sender: senderLabel(sender), + text: message.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 new file mode 100644 index 0000000..c3af1db --- /dev/null +++ b/src/modules/onetime.js @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..5240511 --- /dev/null +++ b/src/userbot/client.js @@ -0,0 +1,27 @@ +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/context.js b/src/userbot/context.js new file mode 100644 index 0000000..6837b95 --- /dev/null +++ b/src/userbot/context.js @@ -0,0 +1,25 @@ +import { saveCapture } from '../core/db.js'; +import { notifyCapture } from '../bot/panel.js'; + +/** + * Контекст передаётся в каждый хук модуля. Это единственная точка, через которую + * модуль что-то делает: сохраняет перехват, уведомляет владельца, качает медиа. + * Модуль не знает ни про grammY, ни про схему БД — только про этот интерфейс. + */ +export function createContext({ client, bot, me, registry }) { + return { + client, // MTProto-клиент (GramJS) + me, // объект себя + registry, + + /** + * Сохраняет перехват в БД и уведомляет владельца. kind: 'deleted' | 'onetime'. + * data: { chatId, senderId, sender, text, mediaKind, mediaPath } + */ + async capture(kind, data) { + const id = saveCapture(kind, data); + await notifyCapture(bot, { id, kind, ...data }); + return id; + }, + }; +} diff --git a/src/userbot/events.js b/src/userbot/events.js new file mode 100644 index 0000000..c97bbb3 --- /dev/null +++ b/src/userbot/events.js @@ -0,0 +1,37 @@ +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 ?? ''); +} diff --git a/test/dispatcher.test.js b/test/dispatcher.test.js deleted file mode 100644 index d489713..0000000 --- a/test/dispatcher.test.js +++ /dev/null @@ -1,122 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { createDispatcher, parseCommand } from '../src/core/dispatcher.js'; -import { CommandRegistry } from '../src/core/registry.js'; - -/** Telegram сам размечает только латинские команды — здесь это воспроизводим. */ -const entities = (text) => { - const match = /^\/[a-zA-Z0-9_@]+/.exec(text); - return match ? [{ type: 'bot_command', offset: 0, length: match[0].length }] : []; -}; - -const ctxFor = (text, extra = {}) => ({ - me: { username: 'MyBot' }, - from: { id: 1 }, - chat: { id: 1, type: 'private' }, - msg: { text, entities: entities(text), date: 0 }, - business: null, - ...extra, -}); - -describe('parseCommand', () => { - it('разбирает команду без аргументов', () => { - assert.deepEqual(parseCommand(ctxFor('/start')), { name: 'start', rest: '', args: [] }); - }); - - it('отделяет аргументы от имени', () => { - const parsed = parseCommand(ctxFor('/pause 60 now')); - assert.equal(parsed.name, 'pause'); - assert.deepEqual(parsed.args, ['60', 'now']); - assert.equal(parsed.rest, '60 now'); - }); - - it('понимает кириллический алиас без bot_command entity', () => { - assert.equal(parseCommand(ctxFor('/старт')).name, 'старт'); - }); - - it('принимает /cmd@своего_бота', () => { - assert.equal(parseCommand(ctxFor('/ping@mybot')).name, 'ping'); - }); - - it('игнорирует /cmd@чужого_бота', () => { - assert.equal(parseCommand(ctxFor('/ping@OtherBot')), null); - }); - - it('игнорирует обычный текст и команду не в начале строки', () => { - assert.equal(parseCommand(ctxFor('привет')), null); - assert.equal(parseCommand(ctxFor('см. /help')), null); - }); - - it('читает команду из подписи к медиа', () => { - const ctx = { me: { username: 'MyBot' }, msg: { caption: '/start', caption_entities: entities('/start') } }; - assert.equal(parseCommand(ctx).name, 'start'); - }); -}); - -describe('createDispatcher', () => { - const build = (definition) => { - const registry = new CommandRegistry(); - const calls = []; - registry.register({ handler: (ctx, args) => calls.push(args), ...definition }, 'test.js'); - return { dispatch: createDispatcher(registry), calls }; - }; - - const noop = async () => {}; - - it('вызывает обработчик и передаёт аргументы', async () => { - const { dispatch, calls } = build({ command: 'echo' }); - await dispatch(ctxFor('/echo раз два'), noop); - assert.deepEqual(calls, [['раз', 'два']]); - }); - - it('срабатывает на алиас', async () => { - const { dispatch, calls } = build({ command: 'help', aliases: ['помощь'] }); - await dispatch(ctxFor('/помощь'), noop); - assert.equal(calls.length, 1); - }); - - it('пропускает дальше неизвестную команду', async () => { - const { dispatch, calls } = build({ command: 'help' }); - let nexted = false; - await dispatch(ctxFor('/nope'), async () => { - nexted = true; - }); - assert.equal(calls.length, 0); - assert.ok(nexted); - }); - - it('не выполняет команду повторно при редактировании сообщения', async () => { - const { dispatch, calls } = build({ command: 'start' }); - await dispatch({ ...ctxFor('/start'), editedMessage: { text: '/start' } }, noop); - assert.equal(calls.length, 0); - }); - - it('scope business не работает в личке с ботом', async () => { - const { dispatch, calls } = build({ command: 'pause', scope: 'business', adminOnly: false }); - await dispatch(ctxFor('/pause'), noop); - assert.equal(calls.length, 0); - }); - - it('scope private не работает в бизнес-чате', async () => { - const { dispatch, calls } = build({ command: 'start', scope: 'private' }); - await dispatch(ctxFor('/start', { businessConnectionId: 'conn-1' }), noop); - assert.equal(calls.length, 0); - }); - - it('adminOnly пускает владельца бизнес-аккаунта и блокирует клиента', async () => { - const owner = build({ command: 'pause', scope: 'business', adminOnly: true }); - await owner.dispatch( - ctxFor('/pause', { businessConnectionId: 'conn-1', business: { isOwner: true } }), - noop, - ); - assert.equal(owner.calls.length, 1); - - const client = build({ command: 'pause', scope: 'business', adminOnly: true }); - await client.dispatch( - ctxFor('/pause', { businessConnectionId: 'conn-1', business: { isOwner: false } }), - noop, - ); - assert.equal(client.calls.length, 0); - }); -}); diff --git a/test/registry.test.js b/test/registry.test.js deleted file mode 100644 index f91b267..0000000 --- a/test/registry.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { loadCommands } from '../src/core/loader.js'; -import { CommandRegistry } from '../src/core/registry.js'; - -const valid = { command: 'demo', handler: async () => {} }; - -describe('CommandRegistry', () => { - it('ругается на дубли имён и алиасов', () => { - const registry = new CommandRegistry(); - registry.register(valid, 'a.js'); - assert.throws(() => registry.register(valid, 'b.js'), /Конфликт триггеров/); - assert.throws(() => registry.register({ ...valid, command: 'other', aliases: ['demo'] }, 'c.js'), /Конфликт/); - }); - - it('проверяет обязательные поля', () => { - const registry = new CommandRegistry(); - assert.throws(() => registry.register({ handler: () => {} }, 'x.js'), /не задано поле "command"/); - assert.throws(() => registry.register({ command: 'a' }, 'x.js'), /"handler" должно быть функцией/); - assert.throws(() => registry.register({ ...valid, command: '/demo' }, 'x.js'), /без слэша/); - assert.throws(() => registry.register({ ...valid, scope: 'group' }, 'x.js'), /неизвестный scope/); - assert.throws(() => registry.register(null, 'x.js'), /объект по умолчанию/); - }); - - it('в меню Telegram не попадают скрытые, админские и нелатинские', () => { - const registry = new CommandRegistry(); - registry.register({ ...valid, description: 'ок' }, 'a.js'); - registry.register({ ...valid, command: 'secret', hidden: true }, 'b.js'); - registry.register({ ...valid, command: 'root', adminOnly: true }, 'c.js'); - registry.register({ ...valid, command: 'привет' }, 'd.js'); - - assert.deepEqual(registry.forTelegram(), [{ command: 'demo', description: 'ок' }]); - }); -}); - -describe('loadCommands', () => { - it('поднимает все команды проекта и пропускает файлы с _', async () => { - const registry = await loadCommands(); - const names = registry.list().map((cmd) => cmd.command); - - assert.ok(names.includes('start')); - assert.ok(names.includes('pause'), 'команды из подпапок тоже должны грузиться'); - assert.ok(!names.includes('template'), '_template.js не должен попадать в реестр'); - }); -});