Files
PnvPanel/docs/telegram-bot.md
T
Leonid Pershin 24cee9bb78
CI / Backend (build + test) (push) Successful in 1m27s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
Implement extension request and gift functionalities in billing system
- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions.
- Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection.
- Introduced a gifting feature for admins to grant additional billing days directly to users without a request.
- Updated the support ticket model to accommodate extension requests and their associated properties.
- Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection.
- Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features.
- Revised API documentation to reflect the new endpoints and their usage in the billing context.
2026-07-19 05:30:11 +03:00

32 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).
  8. Админ: обработка заявок на роль поддержки — при новой заявке (SupportTicket.Type == RoleRequest) админ получает сообщение с описанием (существующая роль либо параметры новой) и обоснованием, жмёт « Одобрить / Отклонить» прямо в Telegram, без захода на сайт — одобрение создаёт роль (если новая) и назначает её пользователю той же командой, что и на сайте. Баг-репорты/ предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и вложения удобнее там).

Не реализовано:

  • QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
  • Отдельная команда /resetpassword с одноразовой ссылкой — восстановление пароля сейчас идёт только через обычный passwordless-вход (/start login_<n>) + смену пароля в настройках на сайте.
  • Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
  • Полное самообслуживание (создание/ротация/отзыв конфигов) — бот read-only по конфигам (только просмотр списка и показ существующей ссылки по кнопке).
  • Баг-репорты/предложения тикетов поддержки не решаются из бота (только уведомление-ссылка) — ответы, вложения, resolve/close только на сайте.

Размещение в архитектуре

  • Бот работает в том же процессе, что и 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.

Флоу 6 — Обращения в поддержку

Два разных сценария в зависимости от типа тикета (SupportTicket.Type):

Баг-репорт/предложение — только уведомление, без действий в боте:

  1. CreateBugReportTicketCommandHandler вызывает ITelegramNotifier.NotifyAdminsBugReportCreatedAsync(ticketId, userName, message, ct).
  2. Каждому админу уходит сообщение с превью текста (обрезано до ~300 символов) и, если задан Telegram__PublicSiteUrl, кнопкой-ссылкой 🌐 Открыть на сайте на /admin/support/{ticketId} (InlineKeyboardButton.WithUrl, не callback) — тап открывает страницу тикета в браузере.
  3. Дальше — только на сайте: переписка, вложения, resolve/close.

Заявка на роль — полностью решается в Telegram:

  1. CreateRoleRequestTicketCommandHandler вызывает ITelegramNotifier.NotifyAdminsRoleRequestCreatedAsync(ticketId, userName, roleDescription, justification, ct)roleDescription уже готовая строка (имя существующей роли либо «новая роль «X» (конфигов: N, IP: M)»).
  2. Сообщение с кнопками « Одобрить» / « Отклонить» (callback rrq:approve:{id}/rrq:reject:{id} — тот же 3-частный формат prefix:action:guid, что и act:* для активации).
  3. Нажатие → проверка прав (TrySetAdminCurrentUserAsync, тот же, что для активации) → ApproveRoleRequestCommand/RejectRoleRequestCommand (те же команды, что дёргает POST /api/admin/support/tickets/{id}/approve|reject на сайте). При одобрении — если роль новая, сперва создаётся AppRole, затем в любом случае назначается пользователю; тикет переходит в Resolved/Closed. Нажавшему админу — короткое подтверждение, исходное сообщение редактируется (дописывается статус), как и у act:*.
  4. Пользователю (если Telegram привязан) — DM « Ваша заявка на роль одобрена.» / « Ваша заявка на роль отклонена.».

Заявка на продление (ExtensionRequest, только для billing-ролей) — тот же паттерн, что и заявка на роль, инлайн-кнопки с префиксом erq::

  1. CreateExtensionRequestTicketCommandHandler вызывает ITelegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(ticketId, userName, requestedDays, justification, ct).
  2. Кнопки « Одобрить» / « Отклонить» (callback erq:approve:{id}/erq:reject:{id}).
  3. Нажатие → TrySetAdminCurrentUserAsyncApproveExtensionRequestCommand/RejectExtensionRequestCommand (те же команды, что POST /api/admin/support/tickets/{id}/approve-extension|reject-extension). Одобрение продлевает AppUser.BillingPaidUntil на RequestedDays и возвращает приостановленные конфиги в Active (BillingConfigResumer).
  4. Пользователю — DM « Заявка на продление одобрена. Доступ продлён до {дата}.» / « Ваша заявка на продление отклонена.».

Флоу 7 — Оплата подписки (биллинг)

Только для ролей с AppRole.BillingEnabled (см. domain-model.md). Заявка (PaymentRequest) заводится и решается частично на сайте, частично в Telegram — симметрично заявке на роль:

  1. Пользователь создаёт заявку и жмёт «Я оплатил» на сайте (/billing) — бот в это не вовлечён, кроме опциональной кнопки «Отправить реквизиты в Telegram» (DM самому себе для удобства, статус заявки не меняет).
  2. MarkPaymentSentCommandHandler вызывает ITelegramNotifier.NotifyAdminsPaymentRequestedAsync(requestId, userName, period, amount, ct).
  3. Сообщение с кнопками « Подтвердить» / « Отклонить» (callback pay:approve:{id}/pay:reject:{id} — тот же 3-частный формат, что и rrq:*/act:*).
  4. Нажатие → проверка прав (TrySetAdminCurrentUserAsync) → ConfirmPaymentRequestCommand/ RejectPaymentRequestCommand (те же команды, что дёргает POST /api/admin/billing/requests/{id}/confirm|reject на сайте). Подтверждение продлевает AppUser.BillingPaidUntil и возвращает приостановленные конфиги в Active. Исходное сообщение редактируется (дописывается статус), как у rrq:*/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). Кнопка скрыта для не-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
« Одобрить»/« Отклонить» (rrq:*) (admin) решение по заявке на роль — создаёт/назначает роль админ по env
« Одобрить»/« Отклонить» (erq:*) (admin) решение по заявке на продление — продлевает BillingPaidUntil админ по env
« Подтвердить»/« Отклонить» (pay:*) (admin) решение по заявке на оплату — продлевает BillingPaidUntil админ по env
«🌐 Открыть на сайте» Ссылка на баг-репорт на сайте (только если задан Telegram__PublicSiteUrl) админ по 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 переключаются). Синхронизация языка бота с вебом не реализована.