Files
PnvPanel/docs/telegram-bot.md
T
Leonid Pershin fad03c2834
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Enhance user plan management and update related endpoints
- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`.
- Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes.
- Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users.
- Removed deprecated role request approval endpoints from `AdminSupportEndpoints`.
- Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications.
- Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings.
- Enhanced billing request handling to accommodate plan changes instead of role changes.
- Updated various interfaces and command handlers to support new plan management features.
2026-07-23 22:52:20 +03:00

302 lines
30 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`).
8. **Админ: обработка заявок на продление поддержки** — при новой заявке
(`SupportTicket.Type == ExtensionRequest`, только для billing-ролей) админ получает сообщение с
числом запрошенных дней и обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без
захода на сайт** — одобрение продлевает `BillingPaidUntil` той же командой, что и на сайте.
Баг-репорты/предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий
(переписка и вложения удобнее там).
**Не реализовано:**
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
просмотр списка и показ существующей ссылки по кнопке).
- Баг-репорты/предложения тикетов поддержки **не решаются из бота** (только уведомление-ссылка) —
ответы, вложения, resolve/close только на сайте.
- Смена тарифа (`ChangePlanCommand`) — только на сайте (`/plan`), в боте не решается.
## Размещение в архитектуре
- Бот работает **в том же процессе**, что и 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.
## Флоу 6 — Обращения в поддержку
Два разных сценария в зависимости от типа тикета (`SupportTicket.Type`):
**Баг-репорт/предложение** — только уведомление, без действий в боте:
1. `CreateBugReportTicketCommandHandler` вызывает
`ITelegramNotifier.NotifyAdminsBugReportCreatedAsync(ticketId, userName, message, ct)`.
2. Каждому админу уходит сообщение с превью текста (обрезано до ~300 символов) и, если задан
`Telegram__PublicSiteUrl`, **кнопкой-ссылкой** `🌐 Открыть на сайте` на `/admin/support/{ticketId}`
(`InlineKeyboardButton.WithUrl`, не callback) — тап открывает страницу тикета в браузере.
3. Дальше — только на сайте: переписка, вложения, resolve/close.
**Заявка на продление** (`ExtensionRequest`, только для billing-ролей) — полностью решается в
Telegram, инлайн-кнопки с префиксом `erq:`:
1. `CreateExtensionRequestTicketCommandHandler` вызывает
`ITelegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(ticketId, userName, requestedDays, justification, ct)`.
2. Кнопки **«✅ Одобрить» / «❌ Отклонить»** (callback `erq:approve:{id}`/`erq:reject:{id}`).
3. Нажатие → `TrySetAdminCurrentUserAsync``ApproveExtensionRequestCommand`/`RejectExtensionRequestCommand`
(те же команды, что `POST /api/admin/support/tickets/{id}/approve-extension|reject-extension`).
Одобрение продлевает `AppUser.BillingPaidUntil` на `RequestedDays` и возвращает приостановленные
конфиги в `Active` (`BillingConfigResumer`).
4. Пользователю — DM «✅ Заявка на продление одобрена. Доступ продлён до {дата}.» / «❌ Ваша заявка на
продление отклонена.».
## Флоу 7 — Оплата подписки (биллинг)
Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
Заявка (`PaymentRequest`) заводится и решается частично на сайте, частично в Telegram — симметрично
заявке на продление:
1. Пользователь создаёт заявку и жмёт «Я оплатил» на сайте (`/billing`) — бот в это не вовлечён,
кроме опциональной кнопки «Отправить реквизиты в Telegram» (DM самому себе для удобства, статус
заявки не меняет).
2. `MarkPaymentSentCommandHandler` вызывает
`ITelegramNotifier.NotifyAdminsPaymentRequestedAsync(requestId, userName, period, amount, ct)`.
3. Сообщение с кнопками **«✅ Подтвердить» / «❌ Отклонить»** (callback `pay:approve:{id}`/`pay:reject:{id}`
— тот же 3-частный формат, что и `erq:*`/`act:*`).
4. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`) → `ConfirmPaymentRequestCommand`/
`RejectPaymentRequestCommand` (те же команды, что дёргает `POST /api/admin/billing/requests/{id}/confirm|reject`
на сайте). Подтверждение продлевает `AppUser.BillingPaidUntil` и возвращает приостановленные
конфиги в `Active`. Исходное сообщение редактируется (дописывается статус), как у `erq:*`/`act:*`.
5. Пользователю (если Telegram привязан) — DM «✅ Оплата подтверждена. Доступ продлён до {дата}.» /
«❌ Заявка на оплату отклонена.».
Отдельно, фоновая `BillingService` (не через бота) шлёт DM-предупреждение за 3 дня до истечения
оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без
инлайн-кнопок.
**Гифт от админа** (`POST /api/admin/billing/gift`, только на сайте — в боте не решается) — админ
выдаёт пользователю N дней напрямую, без заявки. Пользователю (если Telegram привязан) — DM
«🎁 Вам подарено N дн. подписки! Доступ продлён до {дата}.».
**Статус оплаты по кнопке**: для пользователей с billing-ролью (`AppRole.BillingEnabled`) в главном
меню бота появляется «💳 Статус оплаты» (`menu:billing`, либо команда `/billing`) — показывает дату,
до которой оплачено, и остаток в человекочитаемом виде: дни, если их ≥ 1 (`N дн.`), иначе часы и
минуты (`N ч M мин` / `M мин`) — то же форматирование, что и бейдж на сайте
(`PaidUntilBadge`/`formatRemaining`, см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
Кнопка скрыта для не-billing ролей — `BuildMainMenu` подставляет `billingEnabled` из
`CurrentUserProfile` при каждом рендере меню.
## Команды и клавиатуры
| Команда / кнопка | Действие | Требует привязки |
| -------------------------- | -------------------------------------------------------------- | ----------------- |
| `/start` | Приветствие + справка по командам | нет |
| `/start link_<token>` | Привязка аккаунта по токену | нет |
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
| `/configs`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да |
| `/billing`, «💳 Статус оплаты» (`menu:billing`) | Дата, до которой оплачено, и остаток (дни/часы/минуты) — только для billing-ролей | да |
| `/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 |
| «✅ Одобрить»/«❌ Отклонить» (`erq:*`) | (admin) решение по заявке на продление — продлевает `BillingPaidUntil` | админ по env |
| «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env |
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по 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 переключаются). Синхронизация языка бота с вебом
не реализована.