From 1452e5c4af3ded670992bcc0f4372fbbbeb7ee63 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 2 Jul 2026 18:29:32 +0300 Subject: [PATCH] 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. --- .../Telegram/PnvBotUpdateHandler.cs | 45 ++++++++++++++++--- docs/roadmap.md | 8 ++-- docs/telegram-bot.md | 23 ++++++---- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index 270623b..5adfdc5 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -2,10 +2,12 @@ using Microsoft.Extensions.Options; using PnvPanel.Application.Admin.Activation; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Configs.GetConfigLink; using PnvPanel.Application.Configs.GetMyConfigs; using PnvPanel.Application.Telegram; using PnvPanel.Application.Telegram.Bot; using PnvPanel.Domain.Activation; +using PnvPanel.Domain.Configs; using PnvPanel.Infrastructure.Telegram; using Telegram.Bot; using Telegram.Bot.Polling; @@ -23,6 +25,11 @@ public sealed class PnvBotUpdateHandler( IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) : IUpdateHandler { + // Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага, + // иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте. + private const string NotLinkedMessage = + "Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram: Настройки → «Привязать Telegram»."; + public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) { await using var scope = scopeFactory.CreateAsyncScope(); @@ -139,6 +146,24 @@ public sealed class PnvBotUpdateHandler( await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken); } + break; + } + case "cfg": + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken); + return; + } + + var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken); + await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); + + await botClient.SendMessage( + chatId.Value, + linkResult.IsSuccess ? linkResult.Value.ConnectionString : $"Не удалось получить ссылку: {linkResult.Error.Message}", + cancellationToken: cancellationToken); + break; } } @@ -171,7 +196,7 @@ public sealed class PnvBotUpdateHandler( var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); if (userId is null) { - await botClient.SendMessage(chatId, "Сначала привяжите Telegram к аккаунту на сайте.", cancellationToken: cancellationToken); + await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken); return; } @@ -191,9 +216,7 @@ public sealed class PnvBotUpdateHandler( { if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) { - await botClient.SendMessage( - chatId, "Сначала привяжите Telegram к аккаунту на сайте (Настройки → Привязать Telegram).", - cancellationToken: cancellationToken); + await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken); return; } @@ -206,9 +229,17 @@ public sealed class PnvBotUpdateHandler( return; } - var lines = result.Value.Configs.Select(c => - $"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"); - await botClient.SendMessage(chatId, "Ваши конфиги:\n" + string.Join('\n', lines), cancellationToken: cancellationToken); + foreach (var config in result.Value.Configs) + { + var text = $"• {config.Label ?? config.Location} ({config.Protocol}) — {config.Status}"; + + // Отозванному конфигу нечего показывать — кнопку не даём. + InlineKeyboardMarkup? keyboard = config.Status == ConfigStatus.Revoked + ? null + : new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("🔗 Показать ссылку", $"cfg:link:{config.Id}") }); + + await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); + } } private async Task HandleUnlinkAsync( diff --git a/docs/roadmap.md b/docs/roadmap.md index 20b2f21..24925a5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -92,11 +92,13 @@ - Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`. - Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`). - Команды бота: `/start` (+ `link_`/`login_` deep-link payload), «Мои конфиги» - (`/configs`, текстовый список, без ссылок/QR), `/unlink`, `/requests`, `/help`. + (`/configs` — по сообщению на конфиг, с inline-кнопкой «🔗 Показать ссылку», раскрывающей connection + string по запросу через тот же `GetConfigLinkQuery`, что и веб; ссылка не выводится сразу в списке, + чтобы не светиться в истории чата без явного действия юзера), `/unlink`, `/requests`, `/help`. - **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env). -- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам. +- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам (только просмотр/показ ссылки, без создания/ротации/отзыва). - **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто. -- **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR прямо в сообщениях бота. +- **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR-картинкой в сообщениях бота (пока только текстовая ссылка). Фронтовые кнопки «Войти через Telegram»/«Привязать Telegram» реализованы в отдельной итерации (см. M0 фронт). ## M8 — Закалка (hardening) ✅ diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index dfacee2..265ca93 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -6,9 +6,10 @@ Telegram-бот — **второй канал доставки** (presentation- ## Возможности (реализовано) -1. **Мои конфиги** — `/configs` присылает текстовый список (метка/локация, протокол, статус) — - **без ссылок и QR** в самом боте; за ссылкой/QR пользователь идёт на сайт. Доступно только - привязанному аккаунту. +1. **Мои конфиги** — `/configs` присылает по сообщению на конфиг (метка/локация, протокол, статус) с + inline-кнопкой **«🔗 Показать ссылку»**; connection string приходит отдельным сообщением только по + нажатию (не светится в списке/истории чата без явного действия пользователя). Только текстовая + ссылка, без QR-картинки — за QR пользователь идёт на сайт. Доступно только привязанному аккаунту. 2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте, подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram. 3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`) @@ -19,12 +20,13 @@ Telegram-бот — **второй канал доставки** (presentation- 5. **Отвязка** — `/unlink`. **Не реализовано / backlog:** -- Ссылки/QR/подписка в самом боте (только текстовый список конфигов). +- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке). - Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт только через обычный passwordless-вход (`/start login_`) + смену пароля в настройках на сайте. - Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет. - Регистрация нового аккаунта из бота (только привязка существующего). -- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам. +- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только + просмотр списка и показ существующей ссылки по кнопке). ## Размещение в архитектуре @@ -97,9 +99,12 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl 1. Привязанный пользователь: `/configs`. 2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`. -3. Ответ — обычное текстовое сообщение, по строке на конфиг: - `• {Label ?? Location} ({Protocol}) — {Status}`. Если конфигов нет — «У вас пока нет конфигов.» - **Ссылок, QR и кнопок здесь нет** — за подключением пользователь идёт на сайт. +3. Ответ — отдельное сообщение на каждый конфиг: `• {Label ?? Location} ({Protocol}) — {Status}` + + inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет — + «У вас пока нет конфигов.» +4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что + эндпоинт `/api/configs/{id}/link`) от текущего пользователя и присылает connection string отдельным + сообщением. Ссылка не дублируется никуда до явного нажатия. QR-картинки нет — только текст. ## Флоу 4 — Обработка активации админом в боте @@ -125,7 +130,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl | `/start` | Приветствие + справка по командам | нет | | `/start link_` | Привязка аккаунта по токену | нет | | `/start login_` | Подтверждение passwordless-входа (deep-link с сайта) | да | -| `/configs` | Текстовый список конфигов | да | +| `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да | | `/unlink` | Отвязать Telegram от аккаунта | да | | `/help` | Справка (то же сообщение, что `/start`) | нет | | «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |