Files
PnvPanel/docs/telegram-bot.md
T
Leonid Pershin 1452e5c4af
CI / Backend (build + test) (push) Successful in 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
Implement Telegram bot configuration updates and user messaging enhancements
- Added inline button functionality to the `/configs` command, allowing users to request connection strings for their configurations without displaying them in chat history.
- Introduced a constant message for unlinked Telegram accounts to improve user understanding of the linking process.
- Updated the handling of configuration messages to include inline buttons for better user interaction and experience.
2026-07-02 18:29:32 +03:00

186 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Telegram Bot
Telegram-бот — **второй канал доставки** (presentation-адаптер) поверх той же Application-логики,
что и REST API. Он не содержит бизнес-правил: обработчики апдейтов вызывают те же CQRS-команды/запросы
(`ICommand`/`IQuery` через собственный `ISender`), что и веб. Бизнес-инварианты живут в домене.
## Возможности (реализовано)
1. **Мои конфиги**`/configs` присылает по сообщению на конфиг (метка/локация, протокол, статус) с
inline-кнопкой **«🔗 Показать ссылку»**; connection string приходит отдельным сообщением только по
нажатию (не светится в списке/истории чата без явного действия пользователя). Только текстовая
ссылка, без QR-картинки — за QR пользователь идёт на сайт. Доступно только привязанному аккаунту.
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
получает сообщение о каждом запросе активации с именем и комментарием заявителя, жмёт
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
принудительный отзыв конфига админом.
5. **Отвязка**`/unlink`.
**Не реализовано / backlog:**
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
- Регистрация нового аккаунта из бота (только привязка существующего).
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
просмотр списка и показ существующей ссылки по кнопке).
## Размещение в архитектуре
- Бот работает **в том же процессе**, что и API, как `BackgroundService` (`TelegramBotHostedService`,
`PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
- Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
содержит только `BotToken`, `BotUsername`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
`Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
- Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
как HTTP-запрос — свежие scoped-сервисы на апдейт).
- Обращения к домену — **только** через `ISender`. `Telegram.Bot` не проникает в Application/Domain.
- Если `Telegram:BotToken` не задан — `TelegramBotHostedService.ExecuteAsync` сразу возвращается,
бот не стартует, панель работает без него (лог `Telegram__BotToken не задан — бот не стартует.`).
```
Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandler (Api/Telegram/)
│ ISender.Send(command/query) // свой диспетчер, свой DI-scope на апдейт
Application (те же хендлеры, что и REST)
```
## Модель данных (добавления)
- `AppUser.TelegramUserId : long?`, `TelegramUsername : string?`, `TelegramLinkedAt : DateTimeOffset?`.
- `TelegramLinkToken` — короткоживущий одноразовый токен привязки (`token`, `userId`, `expiresAt`, `consumedAt`).
- `TelegramLoginRequest` — запрос passwordless-входа: `id`, `status`
(`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `context?`
(IP инициатора, собирается, но **в текст подтверждения в боте не выводится** — известный TODO),
`createdAt`, `expiresAt`.
Подробности полей — в [domain-model.md](domain-model.md).
## Флоу 1 — Привязка Telegram к аккаунту
Предусловие: пользователь уже вошёл на сайте.
1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token``{ deepLink, expiresAt }`,
`deepLink` вида `https://t.me/<bot>?start=link_<token>`. Фронт рисует QR из `deepLink` сам
(`qrcode.react`) — бэкенд картинку не генерирует.
2. Пользователь открывает бота по ссылке → `/start link_<token>`.
3. Бот берёт `from.id`, вызывает `LinkTelegramCommand(token, telegramUserId, telegramUsername)`
валидирует токен, проставляет `TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен.
4. Бот отвечает «✅ Telegram успешно привязан к вашему аккаунту» (или текст ошибки). Сайт узнаёт об
успехе поллингом статуса активации/профиля.
Инварианты: один `TelegramUserId` ↔ один аккаунт; токен одноразовый и истекает.
## Флоу 2 — Passwordless-вход через бота
Предусловие: Telegram уже привязан к аккаунту.
1. На сайте «Войти через Telegram» → `POST /api/auth/telegram/login-request`
`{ requestId, deepLink, expiresAt }`. Сайт начинает поллить
`GET /api/auth/telegram/login-request/{requestId}`.
2. Пользователь открывает `https://t.me/<bot>?start=login_<requestId>` → бот по `from.id` находит
привязанный аккаунт (если не найден — просит сначала привязать) и показывает сообщение
«Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?» с инлайн-кнопками
**«✅ Подтвердить вход» / «❌ Отклонить»**.
3. Подтверждение → `ApproveTelegramLoginCommand`/`RejectTelegramLoginCommand` → запрос переходит в
`Approved`/`Rejected`.
4. Сайт по следующему поллингу получает результат: при `Approved``accessToken` в теле,
`refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая
`Secure = request.IsHttps`). Запрос помечается `Consumed`.
Если Telegram **не привязан** — бот сразу сообщает «Сначала привяжите Telegram к аккаунту на сайте»,
подтвердить вход невозможно. Регистрация целиком через Telegram — вне MVP.
## Флоу 3 — Просмотр конфигов в боте
1. Привязанный пользователь: `/configs`.
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
3. Ответ — отдельное сообщение на каждый конфиг: `• {Label ?? Location} ({Protocol}) — {Status}` +
inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет —
«У вас пока нет конфигов.»
4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что
эндпоинт `/api/configs/{id}/link`) от текущего пользователя и присылает connection string отдельным
сообщением. Ссылка не дублируется никуда до явного нажатия. QR-картинки нет — только текст.
## Флоу 4 — Обработка активации админом в боте
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`) →
`RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает
`ITelegramNotifier.NotifyAdminsActivationRequestedAsync` (прямой вызов из хендлера, без диспетчера событий).
2. Каждому админу (по `Telegram__AdminTelegramUserIds`) уходит сообщение с именем и комментарием
заявителя и кнопками **«✅ Активировать / ❌ Отклонить»**.
3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) →
пользователь активируется/отклоняется, ему уходит realtime `userActivated` (только при одобрении) +
Telegram-DM, если привязан; нажавшему админу приходит короткое подтверждение («✅ Пользователь
активирован.»/«❌ Запрос отклонён.») — само сообщение с кнопками не редактируется.
4. Проверка прав — на стороне бота (`TrySetAdminCurrentUserAsync`) перед вызовом команды: Telegram id
должен быть в `Telegram__AdminTelegramUserIds`.
`/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для
кого он доступен — та же проверка админ-id.
## Команды и клавиатуры
| Команда / кнопка | Действие | Требует привязки |
| -------------------------- | -------------------------------------------------------------- | ----------------- |
| `/start` | Приветствие + справка по командам | нет |
| `/start link_<token>` | Привязка аккаунта по токену | нет |
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
| `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да |
| `/unlink` | Отвязать Telegram от аккаунта | да |
| `/help` | Справка (то же сообщение, что `/start`) | нет |
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
Любой другой текст → «Не понимаю эту команду. /help — список команд.»
## Безопасность
- Токены привязки и `requestId` входа: высокоэнтропийные, короткоживущие, одноразовые (см.
`TelegramLinkToken`/`TelegramLoginRequest` в [domain-model.md](domain-model.md) — точный TTL не
вынесен в отдельное конфигурируемое значение, см. [tech-stack.md](tech-stack.md)).
- Подтверждение входа **не показывает** контекст (время/IP/устройство) инициатора — поле `Context`
собирается (`CreateLoginRequestCommand`), но в текст сообщения бота не подставляется. Если это
важно для защиты от фишинга — доработка на будущее, не текущее поведение.
- Транспорт — только long polling: прямой канал к Bot API по TLS, без верификации webhook-заголовка
(webhook не реализован).
- `Telegram:BotToken` — секрет (env/secret-store), в логи не попадает; при пустом токене
`TelegramBotClient` конструируется с синтаксической заглушкой вместо падения при старте — реальный
HTTP-вызов всё равно не происходит, т.к. `TelegramBotHostedService` и `TelegramNotifier` сами
проверяют `BotToken` перед использованием клиента.
- Passwordless-вход выпускает те же JWT/refresh, что и обычный (тот же `AuthResult`, та же cookie-логика).
- Явного rate-limit на команды бота нет (в отличие от HTTP-эндпоинтов `/api/auth/*`).
## Конфигурация
Реальные поля `TelegramOptions` (секция `Telegram`):
```jsonc
"Telegram": {
"BotToken": "…", // секрет; пусто = бот не стартует
"ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси
"AdminTelegramUserIds": "123456789,987654321" // через запятую
// "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
// не задавай его — эффекта не будет)
}
```
Username бота для deepLink (кнопка «Привязать Telegram»/QR, `?start=link_<token>`/`?start=login_<id>`)
панель получает сама через Bot API (`getMe`) и кэширует на время жизни процесса (`ITelegramBotInfo`,
`Api/Telegram/TelegramBotInfo.cs`) — отдельного поля конфигурации для него больше нет (было
`BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в
самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`.
Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__AdminTelegramUserIds`
(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют,
кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой
сид-админа (`AdminSeed:*`), это независимый список.
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —
не реализована, backlog.