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

17 KiB
Raw Blame History

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.

Флоу 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. Сайт по следующему поллингу получает результат: при ApprovedaccessToken в теле, 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 — точный TTL не вынесен в отдельное конфигурируемое значение, см. 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):

"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). Именно они авторизуют админ-кнопки в боте и определяют, кому слать уведомления о запросах активации — не сидируются в БД и не связаны с учёткой сид-админа (AdminSeed:*), это независимый список.

Сообщения бота не локализованы по языку пользователя — все тексты на русском независимо от языка интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом — не реализована, backlog.