- 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.
30 KiB
Telegram Bot
Telegram-бот — второй канал доставки (presentation-адаптер) поверх той же Application-логики,
что и REST API. Он не содержит бизнес-правил: обработчики апдейтов вызывают те же CQRS-команды/запросы
(ICommand/IQuery через собственный ISender), что и веб. Бизнес-инварианты живут в домене.
Возможности (реализовано)
- Мои конфиги —
/configs(или кнопка «📋 Мои конфиги» из главного меню) присылает одно сообщение со списком (метка/локация, протокол, статус) и по кнопке🔗 {Label}на каждый активный конфиг + кнопкой «🔙 В меню» внизу. Нажатие на конфиг редактирует то же сообщение: дописывает connection string моноширинным блоком (тап = копирование целиком) и убирает именно эту кнопку — остальные конфиги и «В меню» остаются на месте. Ничего не светится без явного нажатия, отдельных сообщений не плодится. Только текстовая ссылка, без QR-картинки — за QR пользователь идёт на сайт. Доступно только привязанному аккаунту. - Авторизация через Telegram (passwordless) — вход на сайт без пароля: инициируется на сайте, подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
- Админ: обработка запросов активации — админ (по Telegram id из
Telegram__AdminTelegramUserIds) получает сообщение о каждом запросе активации с именем и комментарием заявителя, жмёт «✅ Активировать / ❌ Отклонить» прямо в сообщении./requestsпоказывает все ожидающие запросы по требованию. - DM-уведомления пользователю (если Telegram привязан): активация аккаунта, блокировка, принудительный отзыв конфига админом.
- Отвязка —
/unlinkили кнопка «🔓 Отвязать Telegram» из главного меню (редактирует то же сообщение в подтверждение + меню для непривязанного состояния). - Регистрация прямо из бота — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен
привязанный аккаунт, а Telegram ещё не привязан (
/start,/help,/configs, запрос passwordless- входа). Логин —@usernameиз Telegram; если его нет или он уже занят на сайте — используется Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает рольuserиIsActivated = false— активация нужна как для обычной регистрации на сайте. - Главное меню —
/start//helpпоказывают одно сообщение с кнопками вместо текстового списка команд: привязанному аккаунту — «📋 Мои конфиги» / «🔓 Отвязать Telegram», непривязанному — «📝 Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если заданTelegram__PublicSiteUrl(пусто — кнопки нет). Слэш-команды/configs//unlinkпродолжают работать как раньше — кнопки лишь вызывают те же обработчики через callback (menu:configs/menu:unlink/menu:back). - Админ: обработка заявок на продление поддержки — при новой заявке
(
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.
Флоу 1 — Привязка Telegram к аккаунту
Предусловие: пользователь уже вошёл на сайте.
- На сайте «Привязать Telegram» →
POST /api/auth/telegram/link-token→{ deepLink, expiresAt },deepLinkвидаhttps://t.me/<bot>?start=link_<token>. Фронт рисует QR изdeepLinkсам (qrcode.react) — бэкенд картинку не генерирует. - Пользователь открывает бота по ссылке →
/start link_<token>. - Бот берёт
from.id, вызываетLinkTelegramCommand(token, telegramUserId, telegramUsername)— валидирует токен, проставляетTelegramUserId/TelegramUsername/TelegramLinkedAt, гасит токен. - Бот отвечает «✅ Telegram успешно привязан к вашему аккаунту» (или текст ошибки). Сайт узнаёт об успехе поллингом статуса активации/профиля.
Инварианты: один TelegramUserId ↔ один аккаунт; токен одноразовый и истекает.
Флоу 2 — Passwordless-вход через бота
Предусловие: Telegram уже привязан к аккаунту.
- На сайте «Войти через Telegram» →
POST /api/auth/telegram/login-request→{ requestId, deepLink, expiresAt }. Сайт начинает поллитьGET /api/auth/telegram/login-request/{requestId}. - Пользователь открывает
https://t.me/<bot>?start=login_<requestId>→ бот поfrom.idнаходит привязанный аккаунт (если не найден — просит сначала привязать) и показывает сообщение «Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?» с инлайн-кнопками «✅ Подтвердить вход» / «❌ Отклонить». - Подтверждение →
ApproveTelegramLoginCommand/RejectTelegramLoginCommand→ запрос переходит вApproved/Rejected. - Сайт по следующему поллингу получает результат: при
Approved—accessTokenв теле,refreshуже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включаяSecure = request.IsHttps). Запрос помечаетсяConsumed.
Если Telegram не привязан — подтвердить вход невозможно; бот присылает NotLinkedMessage
(«Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram...») с кнопкой
«📝 Зарегистрироваться» — см. Флоу 3.
Флоу 3 — Регистрация прямо из бота
Показывается кнопкой «📝 Зарегистрироваться» везде, где боту нужен привязанный аккаунт, а его нет
(/start, /help, /configs, запрос passwordless-входа для непривязанного Telegram).
- Нажатие → callback
reg:new→RegisterViaTelegramCommand(telegramUserId, telegramUsername). - Если
TelegramUserIdуже привязан к какому-то аккаунту —TelegramErrors.AlreadyLinked, регистрация не создаёт второй аккаунт. - Логин: пробуем
@usernameиз Telegram (identityService.CreateUserAsync); если username пуст или занят на сайте — используемTelegramUserId.ToString()(гарантированно уникален). Пароль генерируется (RandomNumberGenerator, 12 символов, гарантированы заглавная/строчная буква и цифра под текущую политику пароля) и присылается в чат один раз, отдельным HTML-сообщением (<code>). - Сразу после создания —
identityService.LinkTelegramAsync(...), аккаунт уже привязан, без отдельного шага как во Флоу 1. - Новый аккаунт — роль
user,IsActivated = false: активация нужна как для обычной регистрации на сайте,/configsбудет недоступен до неё. - Логин можно сменить в Настройках на сайте (
ChangeUserNameCommand,POST /api/auth/change-username) — актуально, если логином стал Telegram id.
Флоу 4 — Просмотр конфигов в боте
- Привязанный пользователь:
/configs(текстовая команда → новое сообщение) или «📋 Мои конфиги» из главного меню (callbackmenu:configs→ редактирует текущее сообщение, см.BuildConfigsMenuAsync). - Бот вызывает
GetMyConfigsQuery(тот же, что и веб) от пользователя, найденного поTelegramUserId. - Текст — одно сообщение:
Ваши конфиги:+ по строке• {Label ?? Location} ({Protocol}) — {Status}. Клавиатура — по кнопке🔗 {Label}на каждый не отозванный конфиг + «🔙 В меню» внизу. Если конфигов нет — «У вас пока нет конфигов.» с той же кнопкой «В меню». - Нажатие
🔗 {Label}→ callbackcfg:link:{configId}→ бот вызываетGetConfigLinkQuery(тот же, что эндпоинт/api/configs/{id}/link) от текущего пользователя и редактирует то же сообщение (EditMessageText): дописывает ссылку моноширинным блоком (<code>, тап = копирование целиком) и убирает именно эту кнопку из клавиатуры (InlineKeyboardMarkup.InlineKeyboard, фильтр поCallbackData) — остальные конфиги и «В меню» остаются кликабельными. Ссылка не раскрывается нигде до явного нажатия, отдельных сообщений не плодится. QR-картинки нет — только текст. - «🔙 В меню» (
menu:back) — редактирует сообщение обратно в главное меню (BuildMainMenu).
Флоу 5 — Обработка активации админом в боте
- Пользователь отправляет запрос активации (сайт:
POST /api/activation/request { comment }) →RequestActivationCommandHandlerшлёт SignalRactivationRequestedгруппеadminsи вызываетITelegramNotifier.NotifyAdminsActivationRequestedAsync(прямой вызов из хендлера, без диспетчера событий). - Каждому админу (по
Telegram__AdminTelegramUserIds) уходит сообщение с именем и комментарием заявителя и кнопками «✅ Активировать / ❌ Отклонить». - Нажатие →
ApproveActivationCommand/RejectActivationCommand(те же, что на сайте) → пользователь активируется/отклоняется, ему уходит realtimeuserActivated(только при одобрении) + Telegram-DM, если привязан; нажавшему админу приходит короткое подтверждение («✅ Пользователь активирован.»/«❌ Запрос отклонён.») — само сообщение с кнопками не редактируется. - Проверка прав — на стороне бота (
TrySetAdminCurrentUserAsync) перед вызовом команды: Telegram id должен быть вTelegram__AdminTelegramUserIds.
/requests — тот же список запросов по требованию (до 10 штук, Pending), с теми же кнопками; для
кого он доступен — та же проверка админ-id.
Флоу 6 — Обращения в поддержку
Два разных сценария в зависимости от типа тикета (SupportTicket.Type):
Баг-репорт/предложение — только уведомление, без действий в боте:
CreateBugReportTicketCommandHandlerвызываетITelegramNotifier.NotifyAdminsBugReportCreatedAsync(ticketId, userName, message, ct).- Каждому админу уходит сообщение с превью текста (обрезано до ~300 символов) и, если задан
Telegram__PublicSiteUrl, кнопкой-ссылкой🌐 Открыть на сайтена/admin/support/{ticketId}(InlineKeyboardButton.WithUrl, не callback) — тап открывает страницу тикета в браузере. - Дальше — только на сайте: переписка, вложения, resolve/close.
Заявка на продление (ExtensionRequest, только для billing-ролей) — полностью решается в
Telegram, инлайн-кнопки с префиксом erq::
CreateExtensionRequestTicketCommandHandlerвызываетITelegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(ticketId, userName, requestedDays, justification, ct).- Кнопки «✅ Одобрить» / «❌ Отклонить» (callback
erq:approve:{id}/erq:reject:{id}). - Нажатие →
TrySetAdminCurrentUserAsync→ApproveExtensionRequestCommand/RejectExtensionRequestCommand(те же команды, чтоPOST /api/admin/support/tickets/{id}/approve-extension|reject-extension). Одобрение продлеваетAppUser.BillingPaidUntilнаRequestedDaysи возвращает приостановленные конфиги вActive(BillingConfigResumer). - Пользователю — DM «✅ Заявка на продление одобрена. Доступ продлён до {дата}.» / «❌ Ваша заявка на продление отклонена.».
Флоу 7 — Оплата подписки (биллинг)
Только для ролей с AppRole.BillingEnabled (см. domain-model.md).
Заявка (PaymentRequest) заводится и решается частично на сайте, частично в Telegram — симметрично
заявке на продление:
- Пользователь создаёт заявку и жмёт «Я оплатил» на сайте (
/billing) — бот в это не вовлечён, кроме опциональной кнопки «Отправить реквизиты в Telegram» (DM самому себе для удобства, статус заявки не меняет). MarkPaymentSentCommandHandlerвызываетITelegramNotifier.NotifyAdminsPaymentRequestedAsync(requestId, userName, period, amount, ct).- Сообщение с кнопками «✅ Подтвердить» / «❌ Отклонить» (callback
pay:approve:{id}/pay:reject:{id}— тот же 3-частный формат, что иerq:*/act:*). - Нажатие → проверка прав (
TrySetAdminCurrentUserAsync) →ConfirmPaymentRequestCommand/RejectPaymentRequestCommand(те же команды, что дёргаетPOST /api/admin/billing/requests/{id}/confirm|rejectна сайте). Подтверждение продлеваетAppUser.BillingPaidUntilи возвращает приостановленные конфиги вActive. Исходное сообщение редактируется (дописывается статус), как уerq:*/act:*. - Пользователю (если 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 |
«✅ Одобрить»/«❌ Отклонить» (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 переключаются). Синхронизация языка бота с вебом не реализована.