Files
PnvPanel/docs/telegram-bot.md
T
Leonid Pershin ad94c6ef22
CI / Backend (build + test) (push) Successful in 1m33s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
Update documentation and clarify MVP status
- Revised the CLAUDE.md and README.md files to reflect the current MVP status, emphasizing completed features and intentionally omitted elements such as traffic limits and billing.
- Enhanced clarity in the documentation regarding the architecture, tech stack, and user roles.
- Removed the outdated roadmap section and streamlined references to tech stack decisions.
- Updated API design documentation to clarify the absence of versioning in the MVP and the handling of configuration details.
2026-07-02 21:11:59 +03:00

229 lines
23 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` (или кнопка «📋 Мои конфиги» из главного меню) присылает **одно**
сообщение со списком (метка/локация, протокол, статус) и по кнопке `🔗 {Label}` на каждый активный
конфиг + кнопкой **«🔙 В меню»** внизу. Нажатие на конфиг **редактирует то же сообщение**: дописывает
connection string моноширинным блоком (тап = копирование целиком) и убирает именно эту кнопку —
остальные конфиги и «В меню» остаются на месте. Ничего не светится без явного нажатия, отдельных
сообщений не плодится. Только текстовая ссылка, без QR-картинки — за QR пользователь идёт на сайт.
Доступно только привязанному аккаунту.
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
получает сообщение о каждом запросе активации с именем и комментарием заявителя, жмёт
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
принудительный отзыв конфига админом.
5. **Отвязка**`/unlink` или кнопка «🔓 Отвязать Telegram» из главного меню (редактирует то же
сообщение в подтверждение + меню для непривязанного состояния).
6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен
привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless-
входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется
Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните
его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает
роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте.
7. **Главное меню**`/start`/`/help` показывают одно сообщение с кнопками вместо текстового списка
команд: привязанному аккаунту — «📋 Мои конфиги» / «🔓 Отвязать Telegram», непривязанному — «📝
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
**Не реализовано:**
- 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`, `ProxyUrl`, `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 **не привязан** — подтвердить вход невозможно; бот присылает `NotLinkedMessage`
(«Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram...») с кнопкой
«📝 Зарегистрироваться» — см. Флоу 3.
## Флоу 3 — Регистрация прямо из бота
Показывается кнопкой «📝 Зарегистрироваться» везде, где боту нужен привязанный аккаунт, а его нет
(`/start`, `/help`, `/configs`, запрос passwordless-входа для непривязанного Telegram).
1. Нажатие → callback `reg:new``RegisterViaTelegramCommand(telegramUserId, telegramUsername)`.
2. Если `TelegramUserId` уже привязан к какому-то аккаунту — `TelegramErrors.AlreadyLinked`, регистрация
не создаёт второй аккаунт.
3. Логин: пробуем `@username` из Telegram (`identityService.CreateUserAsync`); если username пуст или
занят на сайте — используем `TelegramUserId.ToString()` (гарантированно уникален). Пароль генерируется
(`RandomNumberGenerator`, 12 символов, гарантированы заглавная/строчная буква и цифра под текущую
политику пароля) и присылается в чат **один раз**, отдельным HTML-сообщением (`<code>`).
4. Сразу после создания — `identityService.LinkTelegramAsync(...)`, аккаунт уже привязан, без
отдельного шага как во Флоу 1.
5. Новый аккаунт — роль `user`, `IsActivated = false`: активация нужна как для обычной регистрации на
сайте, `/configs` будет недоступен до неё.
6. Логин можно сменить в Настройках на сайте (`ChangeUserNameCommand`, `POST /api/auth/change-username`)
— актуально, если логином стал Telegram id.
## Флоу 4 — Просмотр конфигов в боте
1. Привязанный пользователь: `/configs` (текстовая команда → новое сообщение) или «📋 Мои конфиги» из
главного меню (callback `menu:configs` → редактирует текущее сообщение, см. `BuildConfigsMenuAsync`).
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
3. Текст — **одно** сообщение: `Ваши конфиги:` + по строке `• {Label ?? Location} ({Protocol}) — {Status}`.
Клавиатура — по кнопке `🔗 {Label}` на каждый **не отозванный** конфиг + «🔙 В меню» внизу. Если
конфигов нет — «У вас пока нет конфигов.» с той же кнопкой «В меню».
4. Нажатие `🔗 {Label}` → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же,
что эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение**
(`EditMessageText`): дописывает ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и
убирает **именно эту** кнопку из клавиатуры (`InlineKeyboardMarkup.InlineKeyboard`, фильтр по
`CallbackData`) — остальные конфиги и «В меню» остаются кликабельными. Ссылка не раскрывается нигде
до явного нажатия, отдельных сообщений не плодится. QR-картинки нет — только текст.
5. «🔙 В меню» (`menu:back`) — редактирует сообщение обратно в главное меню (`BuildMainMenu`).
## Флоу 5 — Обработка активации админом в боте
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`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да |
| `/unlink`, «🔓 Отвязать Telegram» (`menu:unlink`) | Отвязать Telegram от аккаунта | да |
| «🔙 В меню» (`menu:back`) | Вернуться из списка конфигов к главному меню (edit-in-place) | нет |
| «🌐 Сайт панели» | Открыть сайт (`InlineKeyboardButton.WithUrl`, только если задан `Telegram__PublicSiteUrl`) | нет |
| `/help` | Справка (то же сообщение, что `/start`) | нет |
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
Любой другой текст → «Не понимаю эту команду. /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; пусто = без прокси
"PublicSiteUrl": "https://dashboard.example.com", // кнопка «🌐 Сайт панели» в меню; пусто = кнопки нет
"AdminTelegramUserIds": "123456789,987654321" // через запятую
}
```
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__PublicSiteUrl`,
`Telegram__AdminTelegramUserIds` (см. [`.env.example`](../.env.example)). `AdminTelegramUserIds`
авторизует админ-кнопки в боте и определяет, кому слать уведомления о запросах активации — **не**
сидируется в БД и не связан с учёткой сид-админа (`AdminSeed:*`), это независимый список.
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом
не реализована.