- Added new inline button features to the `/configs` command, allowing users to view their configurations in a single message with active links and a back button. - Implemented a menu for unlinking Telegram accounts, providing a clearer user experience when managing account connections. - Updated the `.env.example` file to include a new `Telegram__PublicSiteUrl` setting, enabling a button for accessing the panel's website directly from the bot. - Enhanced documentation to reflect the new features and configuration options available in the Telegram bot.
23 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).
Не реализовано / 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,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.
Команды и клавиатуры
| Команда / кнопка | Действие | Требует привязки |
|---|---|---|
/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 переключаются). Синхронизация языка бота с вебом — не реализована, backlog.