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

23 KiB
Raw Blame History

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.

Флоу 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 не привязан — подтвердить вход невозможно; бот присылает NotLinkedMessage («Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram...») с кнопкой «📝 Зарегистрироваться» — см. Флоу 3.

Флоу 3 — Регистрация прямо из бота

Показывается кнопкой «📝 Зарегистрироваться» везде, где боту нужен привязанный аккаунт, а его нет (/start, /help, /configs, запрос passwordless-входа для непривязанного Telegram).

  1. Нажатие → callback reg:newRegisterViaTelegramCommand(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 — точный 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; пусто = без прокси
  "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). AdminTelegramUserIds авторизует админ-кнопки в боте и определяет, кому слать уведомления о запросах активации — не сидируется в БД и не связан с учёткой сид-админа (AdminSeed:*), это независимый список.

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