Переписать проект в личный MTProto-юзербот
Смена архитектуры с бизнес-бота на 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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,20 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm test *)",
|
"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 $?\\) ===\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-6
@@ -1,11 +1,24 @@
|
|||||||
|
# === MTProto (юзербот) ===
|
||||||
|
# Получить на https://my.telegram.org → API development tools
|
||||||
|
API_ID=
|
||||||
|
API_HASH=
|
||||||
|
# Строка сессии. Сгенерировать: npm run login — и вставить сюда.
|
||||||
|
SESSION=
|
||||||
|
|
||||||
|
# === Компаньон-бот (единственный интерфейс) ===
|
||||||
# Токен от @BotFather
|
# Токен от @BotFather
|
||||||
BOT_TOKEN=123456:AA...
|
BOT_TOKEN=
|
||||||
|
# Ваш Telegram id (узнать: напишите @userinfobot). Только вы управляете ботом.
|
||||||
|
OWNER_ID=
|
||||||
|
|
||||||
# ID администраторов через запятую (узнать свой: команда /id)
|
# === Локальное хранилище ===
|
||||||
ADMIN_IDS=
|
# Ключ шифрования: 64 hex-символа ИЛИ длинная парольная фраза (≥32 байт).
|
||||||
|
# Сгенерировать hex: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
# Сколько минут молчать в чате после того, как владелец ответил вручную
|
ENCRYPTION_KEY=
|
||||||
HANDOVER_PAUSE_MINUTES=30
|
# Сколько дней хранить перехваченное (0 — бессрочно)
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
# Качать медиа входящих сразу, чтобы восстанавливать удалённые картинки (0 — выключить)
|
||||||
|
CACHE_MEDIA=1
|
||||||
|
|
||||||
# debug | info | warn | error
|
# debug | info | warn | error
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Сторонний код (изучался как референс, в проект не входит)
|
||||||
|
Hcrgram/
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
```
|
||||||
Generated
+856
-2
@@ -7,11 +7,21 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "telegrambusiness",
|
"name": "telegrambusiness",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"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": {
|
"node_modules/@grammyjs/types": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz",
|
||||||
@@ -30,6 +40,182 @@
|
|||||||
"node": ">=6.5"
|
"node": ">=6.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-escapes": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/async-mutex": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/babel-runtime": {
|
||||||
|
"version": "6.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||||
|
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-js": "^2.4.0",
|
||||||
|
"regenerator-runtime": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/big-integer": {
|
||||||
|
"version": "1.6.52",
|
||||||
|
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
|
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bufferutil": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build": "^4.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.14.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^2.2.1",
|
||||||
|
"escape-string-regexp": "^1.0.2",
|
||||||
|
"has-ansi": "^2.0.0",
|
||||||
|
"strip-ansi": "^3.0.0",
|
||||||
|
"supports-color": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cli-cursor": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"restore-cursor": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cli-width": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/code-point-at": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||||
|
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/d": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"es5-ext": "^0.10.64",
|
||||||
|
"type": "^2.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -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": {
|
"node_modules/event-target-shim": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
@@ -56,6 +380,43 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exit-hook": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ext": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"type": "^2.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/figures": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": "^1.0.5",
|
||||||
|
"object-assign": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/grammy": {
|
"node_modules/grammy": {
|
||||||
"version": "1.45.1",
|
"version": "1.45.1",
|
||||||
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.45.1.tgz",
|
||||||
@@ -71,12 +432,165 @@
|
|||||||
"node": "^12.20.0 || >=14.13.1"
|
"node": "^12.20.0 || >=14.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-ansi": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.0.1",
|
||||||
|
"domhandler": "^4.0.0",
|
||||||
|
"domutils": "^2.5.2",
|
||||||
|
"entities": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/imurmurhash": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/input": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/input/-/input-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5DKQKQ7Nm/CaPGYKF74uUvk5ftC3S04fLYWcDrNG2rOVhhRgB4E2J8JNb7AAh+RlQ/954ukas4bEbrRQ3/kPGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"babel-runtime": "^6.6.1",
|
||||||
|
"chalk": "^1.1.1",
|
||||||
|
"inquirer": "^0.12.0",
|
||||||
|
"lodash": "^4.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inquirer": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-bOetEz5+/WpgaW4D1NYOk1aD+JCqRjqu/FwRFgnIfiP7FC/zinsrfyO1vlS3nyH/R7S0IH3BIHBu4DBIDSqiGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-escapes": "^1.1.0",
|
||||||
|
"ansi-regex": "^2.0.0",
|
||||||
|
"chalk": "^1.0.0",
|
||||||
|
"cli-cursor": "^1.0.1",
|
||||||
|
"cli-width": "^2.0.0",
|
||||||
|
"figures": "^1.3.5",
|
||||||
|
"lodash": "^4.3.0",
|
||||||
|
"readline2": "^1.0.1",
|
||||||
|
"run-async": "^0.1.0",
|
||||||
|
"rx-lite": "^3.1.2",
|
||||||
|
"string-width": "^1.0.1",
|
||||||
|
"strip-ansi": "^3.0.0",
|
||||||
|
"through": "^2.3.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||||
|
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"number-is-nan": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-typedarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mute-stream": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/next-tick": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/node-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
@@ -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": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-custom-error": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/type": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/typedarray-to-buffer": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-typedarray": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utf-8-validate": {
|
||||||
|
"version": "5.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
|
||||||
|
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build": "^4.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.14.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/websocket": {
|
||||||
|
"version": "1.0.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz",
|
||||||
|
"integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"debug": "^2.2.0",
|
||||||
|
"es5-ext": "^0.10.63",
|
||||||
|
"typedarray-to-buffer": "^3.1.5",
|
||||||
|
"utf-8-validate": "^5.0.2",
|
||||||
|
"yaeti": "^0.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/websocket/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/websocket/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
@@ -118,6 +945,33 @@
|
|||||||
"tr46": "~0.0.3",
|
"tr46": "~0.0.3",
|
||||||
"webidl-conversions": "^3.0.0"
|
"webidl-conversions": "^3.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/write-file-atomic": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-SdrHoC/yVBPpV0Xq/mUZQIpW2sWXAShb/V4pomcJXh92RuaO+f3UTWItiR3Px+pLnV2PvC2/bfn5cwr5X6Vfxw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.1.11",
|
||||||
|
"imurmurhash": "^0.1.4",
|
||||||
|
"slide": "^1.1.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaeti": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.32"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "telegrambusiness",
|
"name": "telegrambusiness",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Telegram bot: modular commands + Business Mode support",
|
"description": "Личный Telegram-юзербот на MTProto: antidelete, one-time медиа, управление через компаньон-бота",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.9.0"
|
"node": ">=22.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --env-file-if-exists=.env src/index.js",
|
"start": "node --env-file-if-exists=.env src/index.js",
|
||||||
"dev": "node --watch --env-file-if-exists=.env src/index.js",
|
"dev": "node --watch --env-file-if-exists=.env src/index.js",
|
||||||
"check": "node scripts/check.js",
|
"login": "node --env-file-if-exists=.env scripts/login.js"
|
||||||
"test": "node --test \"test/**/*.test.js\"",
|
|
||||||
"sync": "node --env-file-if-exists=.env scripts/sync-commands.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"grammy": "^1.45.1"
|
"grammy": "^1.45.1",
|
||||||
|
"input": "^1.0.1",
|
||||||
|
"telegram": "^2.26.22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(' ') || '—'}`);
|
|
||||||
@@ -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);
|
||||||
@@ -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}`);
|
|
||||||
-63
@@ -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('Состояние сохранено.');
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>
|
|
||||||
*/
|
|
||||||
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}")`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 [
|
|
||||||
`<b>Подключение</b> <code>${id}</code>`,
|
|
||||||
`владелец: <code>${record.ownerId}</code>${record.ownerUsername ? ` (@${record.ownerUsername})` : ''}`,
|
|
||||||
`активно: ${record.isEnabled ? 'да' : 'нет'}`,
|
|
||||||
`права: ${rights.length ? rights.join(', ') : 'не переданы'}`,
|
|
||||||
].join('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
await ctx.reply(blocks.join('\n\n'), { parse_mode: 'HTML' });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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')}` : 'Команд пока нет.');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export default {
|
|
||||||
command: 'id',
|
|
||||||
aliases: ['whoami'],
|
|
||||||
description: 'Показать ID пользователя и чата',
|
|
||||||
scope: 'all',
|
|
||||||
|
|
||||||
async handler(ctx) {
|
|
||||||
const lines = [
|
|
||||||
`Ваш ID: <code>${ctx.from?.id}</code>`,
|
|
||||||
`Чат: <code>${ctx.chat?.id}</code> (${ctx.chat?.type})`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (ctx.business) {
|
|
||||||
lines.push(
|
|
||||||
`Подключение: <code>${ctx.business.connectionId}</code>`,
|
|
||||||
`Владелец: <code>${ctx.business.ownerId ?? 'неизвестен'}</code>`,
|
|
||||||
`Вы владелец: ${ctx.business.isOwner ? 'да' : 'нет'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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'));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+56
-18
@@ -1,26 +1,64 @@
|
|||||||
const parseIds = (raw) =>
|
import { fileURLToPath } from 'node:url';
|
||||||
(raw ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(Number)
|
|
||||||
.filter((id) => Number.isInteger(id));
|
|
||||||
|
|
||||||
export const config = {
|
const req = (name) => {
|
||||||
token: process.env.BOT_TOKEN ?? '',
|
const value = process.env[name];
|
||||||
adminIds: parseIds(process.env.ADMIN_IDS),
|
if (!value) throw new Error(`${name} не задан в .env (см. .env.example).`);
|
||||||
handoverPauseMs: Number(process.env.HANDOVER_PAUSE_MINUTES ?? 30) * 60_000,
|
return value;
|
||||||
logLevel: process.env.LOG_LEVEL ?? 'info',
|
|
||||||
dataDir: new URL('../data/', import.meta.url),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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() {
|
export function assertConfig() {
|
||||||
if (!config.token) {
|
config.apiId();
|
||||||
throw new Error('BOT_TOKEN не задан. Скопируйте .env.example в .env и вставьте токен от @BotFather.');
|
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) {
|
if (!config.session) {
|
||||||
throw new Error('HANDOVER_PAUSE_MINUTES должен быть неотрицательным числом.');
|
throw new Error('SESSION пуст. Выполните: npm run login — и вставьте строку в .env.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isAdmin = (userId) => config.adminIds.includes(userId);
|
export const isOwner = (id) => Number(id) === config.ownerId();
|
||||||
|
|||||||
@@ -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');
|
||||||
+137
@@ -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 '[не удалось расшифровать]';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+11
-11
@@ -3,30 +3,30 @@ import { join, relative, sep } from 'node:path';
|
|||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
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) {
|
export async function loadModules(dir = MODULES_DIR) {
|
||||||
const registry = new CommandRegistry();
|
const registry = new ModuleRegistry();
|
||||||
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
||||||
|
|
||||||
const files = entries
|
const files = entries
|
||||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.js') && !entry.name.startsWith('_'))
|
.filter((e) => e.isFile() && e.name.endsWith('.js') && !e.name.startsWith('_'))
|
||||||
.map((entry) => join(entry.parentPath ?? entry.path, entry.name))
|
.map((e) => join(e.parentPath ?? e.path, e.name))
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const source = relative(dir, file).split(sep).join('/');
|
const source = relative(dir, file).split(sep).join('/');
|
||||||
const module = await import(pathToFileURL(file).href);
|
const mod = await import(pathToFileURL(file).href);
|
||||||
registry.register(module.default, source);
|
registry.register(mod.default, source);
|
||||||
logger.debug(`команда загружена: ${source}`);
|
logger.debug(`модуль загружен: ${source}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Загружено команд: ${registry.list().length}`);
|
logger.info(`Загружено модулей: ${registry.list().length}`);
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
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) => {
|
const write = (level, stream, args) => {
|
||||||
if (LEVELS[level] < threshold) return;
|
if (LEVELS[level] < threshold()) return;
|
||||||
stream(`[${new Date().toISOString()}] ${level.toUpperCase()}`, ...args);
|
stream(`[${new Date().toISOString()}] ${level.toUpperCase()}`, ...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
+65
-62
@@ -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}$/;
|
* Реестр модулей юзербота. Модуль — это файл в src/modules, который экспортирует
|
||||||
|
* по умолчанию объект вида:
|
||||||
export class CommandRegistry {
|
*
|
||||||
/** @type {Map<string, object>} триггер (имя или алиас) -> команда */
|
* export default {
|
||||||
#triggers = new Map();
|
* name: 'antidelete',
|
||||||
/** @type {object[]} */
|
* description: 'Ловит удалённые сообщения',
|
||||||
#commands = [];
|
* 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 = '<inline>') {
|
register(definition, source = '<inline>') {
|
||||||
const command = normalize(definition, source);
|
const module = normalize(definition, source);
|
||||||
|
if (this.#byName.has(module.name)) {
|
||||||
for (const trigger of [command.command, ...command.aliases]) {
|
throw new Error(`Модуль "${module.name}" уже зарегистрирован (${source}).`);
|
||||||
const taken = this.#triggers.get(trigger);
|
|
||||||
if (taken) {
|
|
||||||
throw new Error(
|
|
||||||
`Конфликт триггеров: "${trigger}" из ${source} уже занят командой "${taken.command}" (${taken.source}).`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.#triggers.set(trigger, command);
|
this.#byName.set(module.name, module);
|
||||||
|
this.#modules.push(module);
|
||||||
|
return module;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#commands.push(command);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(trigger) {
|
|
||||||
return this.#triggers.get(trigger.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Все команды в порядке регистрации. */
|
|
||||||
list() {
|
list() {
|
||||||
return [...this.#commands];
|
return [...this.#modules];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Что показываем в /help. */
|
get(name) {
|
||||||
visible() {
|
return this.#byName.get(name);
|
||||||
return this.#commands.filter((cmd) => !cmd.hidden);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Что уходит в setMyCommands: без скрытых, админских и нелатинских алиасов. */
|
/** Модули, у которых объявлен нужный хук — чтобы не дёргать пустышки. */
|
||||||
forTelegram() {
|
withHook(hook) {
|
||||||
return this.#commands
|
return this.#modules.filter((m) => typeof m[hook] === 'function');
|
||||||
.filter((cmd) => !cmd.hidden && !cmd.adminOnly && TELEGRAM_COMMAND.test(cmd.command))
|
}
|
||||||
.map((cmd) => ({ command: cmd.command, description: cmd.description }));
|
|
||||||
|
panels() {
|
||||||
|
return this.#modules.filter((m) => m.panel).map((m) => ({ ...m.panel, module: m.name }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(definition, source) {
|
function normalize(def, source) {
|
||||||
if (!definition || typeof definition !== 'object') {
|
if (!def || typeof def !== 'object') {
|
||||||
throw new Error(`${source}: файл команды должен экспортировать объект по умолчанию.`);
|
throw new Error(`${source}: модуль должен экспортировать объект по умолчанию.`);
|
||||||
}
|
}
|
||||||
|
if (typeof def.name !== 'string' || !def.name.trim()) {
|
||||||
const { command, aliases = [], description = '', scope = 'all', adminOnly = false, hidden = false, handler } = definition;
|
throw new Error(`${source}: у модуля не задано поле "name".`);
|
||||||
|
|
||||||
if (typeof command !== 'string' || !command.trim()) {
|
|
||||||
throw new Error(`${source}: не задано поле "command".`);
|
|
||||||
}
|
}
|
||||||
if (command.startsWith('/')) {
|
for (const hook of ['setup', 'onMessage', 'onDeleted', 'onEdited']) {
|
||||||
throw new Error(`${source}: имя команды указывается без слэша — "${command.slice(1)}", а не "${command}".`);
|
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 {
|
return {
|
||||||
command: command.toLowerCase(),
|
name: def.name,
|
||||||
aliases: aliases.map((alias) => alias.toLowerCase()),
|
description: def.description ?? '',
|
||||||
description,
|
setup: def.setup,
|
||||||
scope,
|
onMessage: def.onMessage,
|
||||||
adminOnly: Boolean(adminOnly),
|
onDeleted: def.onDeleted,
|
||||||
hidden: Boolean(hidden),
|
onEdited: def.onEdited,
|
||||||
handler,
|
panel: def.panel,
|
||||||
source,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
@@ -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}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+56
-28
@@ -1,34 +1,62 @@
|
|||||||
import { GrammyError } from 'grammy';
|
import { assertConfig, config } from './config.js';
|
||||||
|
import { createBot } from './bot/panel.js';
|
||||||
import { ALLOWED_UPDATES, createBot, shutdown, syncCommands } from './bot.js';
|
import { openDb, purgeOld } from './core/db.js';
|
||||||
|
import { loadModules } from './core/loader.js';
|
||||||
import { logger } from './core/logger.js';
|
import { logger } from './core/logger.js';
|
||||||
|
import { runHook } from './core/registry.js';
|
||||||
|
import { 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 {
|
||||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
assertConfig();
|
||||||
process.once(signal, () => {
|
} catch (err) {
|
||||||
shutdown(bot).then(
|
logger.error(err.message);
|
||||||
() => process.exit(0),
|
process.exit(1);
|
||||||
(err) => {
|
}
|
||||||
logger.error('Ошибка при остановке', err);
|
openDb();
|
||||||
|
|
||||||
|
// 2. Юзербот (MTProto): подключаемся уже готовой сессией.
|
||||||
|
const client = createUserbot();
|
||||||
|
let me;
|
||||||
|
try {
|
||||||
|
me = await startUserbot(client);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Не удалось подключить юзербот. Проверьте SESSION/API_ID/API_HASH.', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 3. Компаньон-бот (единственный UI) — запускаем в фоне.
|
||||||
await bot.start({
|
const bot = createBot();
|
||||||
allowed_updates: ALLOWED_UPDATES,
|
bot.start({ onStart: ({ username }) => logger.info(`Панель поднята: @${username}`) }).catch((err) => {
|
||||||
// Первый апдейт приходит уже после getMe, поэтому меню обновляем здесь.
|
logger.error('Компаньон-бот не запустился (проверьте BOT_TOKEN):', err.message);
|
||||||
onStart: async ({ username }) => {
|
process.exit(1);
|
||||||
await syncCommands(bot, registry).catch((err) => logger.warn('setMyCommands не прошёл', err));
|
|
||||||
logger.info(`@${username} запущен. Апдейты: ${ALLOWED_UPDATES.join(', ')}`);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
// bot.catch() ловит ошибки апдейтов, но не падения на старте.
|
// 4. Модули: грузим и раздаём им события юзербота.
|
||||||
if (err instanceof GrammyError && err.error_code === 401) {
|
const registry = await loadModules();
|
||||||
logger.error('Telegram не принял токен (401). Проверьте BOT_TOKEN в .env.');
|
const ctx = createContext({ client, bot, me, registry });
|
||||||
} else {
|
await runHook(registry, 'setup', undefined, ctx);
|
||||||
logger.error('Не удалось запустить б�
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 || '?'}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 ?? '');
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 не должен попадать в реестр');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user