From 5b398f9c59c90546a6203e277ae755ef6fe72782 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 2 Jul 2026 19:39:14 +0300 Subject: [PATCH] Enhance Telegram bot functionality and configuration options - 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. --- .env.example | 2 + .../Telegram/PnvBotUpdateHandler.cs | 152 ++++++++++++++---- .../Telegram/TelegramOptions.cs | 2 + docs/telegram-bot.md | 61 ++++--- 4 files changed, 165 insertions(+), 52 deletions(-) diff --git a/.env.example b/.env.example index 1ab0275..7d042d7 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,8 @@ Telegram__AdminTelegramUserIds=123456789 # Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram # заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение. # Telegram__ProxyUrl=socks5://127.0.0.1:39372 +# Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки не будет. +# Telegram__PublicSiteUrl=https://dashboard.example.com # ── ASP.NET Core ────────────────────────────────────────────────────────── ASPNETCORE_ENVIRONMENT=Production diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index c015a63..1d9c444 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -110,6 +110,68 @@ public sealed class PnvBotUpdateHandler( return; } + if (data == "menu:configs") + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken); + return; + } + + await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); + + var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(services, cancellationToken); + if (callback.Message is not null) + await botClient.EditMessageText(chatId.Value, callback.Message.Id, configsText, replyMarkup: configsKeyboard, cancellationToken: cancellationToken); + + return; + } + + if (data == "menu:back") + { + await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); + if (callback.Message is null) + return; + + var identityService = services.GetRequiredService(); + var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); + var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null); + await botClient.EditMessageText(chatId.Value, callback.Message.Id, menuText, replyMarkup: menuKeyboard, cancellationToken: cancellationToken); + + return; + } + + if (data == "menu:unlink") + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken); + return; + } + + var unlinkSender = services.GetRequiredService(); + var unlinkResult = await unlinkSender.Send(new UnlinkTelegramCommand(), cancellationToken); + await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); + + if (callback.Message is null) + return; + + if (!unlinkResult.IsSuccess) + { + await botClient.EditMessageText( + chatId.Value, callback.Message.Id, $"❌ Ошибка: {unlinkResult.Error.Message}", + replyMarkup: BackToMenuKeyboard(), cancellationToken: cancellationToken); + return; + } + + var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false); + await botClient.EditMessageText( + chatId.Value, callback.Message.Id, "✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText, + replyMarkup: unlinkedKeyboard, cancellationToken: cancellationToken); + + return; + } + var parts = data.Split(':'); if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId)) return; @@ -171,13 +233,21 @@ public sealed class PnvBotUpdateHandler( return; } + if (callback.Message is null) + break; + // Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным // блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением. - var originalText = callback.Message?.Text ?? ""; - var text = $"{Escape(originalText)}\n\n{Escape(linkResult.Value.ConnectionString)}"; + // Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте. + var text = $"{Escape(callback.Message.Text ?? "")}\n\n{Escape(linkResult.Value.ConnectionString)}"; + var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? []) + .Where(row => row.All(b => b.CallbackData != data)) + .ToArray(); - if (callback.Message is not null) - await botClient.EditMessageText(chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken); + await botClient.EditMessageText( + chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, + replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null, + cancellationToken: cancellationToken); break; } @@ -241,28 +311,38 @@ public sealed class PnvBotUpdateHandler( return; } + var (text, keyboard) = await BuildConfigsMenuAsync(services, cancellationToken); + await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); + } + + /// Список конфигов одним сообщением: строка на конфиг + кнопка «🔗 {Label}» на каждый + /// не отозванный, плюс «🔙 В меню» внизу. + private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync( + IServiceProvider services, CancellationToken cancellationToken) + { var sender = services.GetRequiredService(); var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); if (!result.IsSuccess || result.Value.Configs.Count == 0) - { - await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken); - return; - } + return ("У вас пока нет конфигов.", BackToMenuKeyboard()); - foreach (var config in result.Value.Configs) - { - var text = $"• {config.Label ?? config.Location} ({config.Protocol}) — {config.Status}"; + var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c => + $"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}")); - // Отозванному конфигу нечего показывать — кнопку не даём. - InlineKeyboardMarkup? keyboard = config.Status == ConfigStatus.Revoked - ? null - : new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("🔗 Показать ссылку", $"cfg:link:{config.Id}") }); + // Отозванному конфигу нечего показывать — кнопку не даём. + var rows = result.Value.Configs + .Where(c => c.Status != ConfigStatus.Revoked) + .Select(c => new[] { InlineKeyboardButton.WithCallbackData($"🔗 {c.Label ?? c.Location}", $"cfg:link:{c.Id}") }) + .Append(BackToMenuRow()) + .ToArray(); - await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); - } + return (text, new InlineKeyboardMarkup(rows)); } + private static InlineKeyboardButton[] BackToMenuRow() => new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") }; + + private static InlineKeyboardMarkup BackToMenuKeyboard() => new(new[] { BackToMenuRow() }); + private async Task HandleUnlinkAsync( ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) { @@ -310,26 +390,40 @@ public sealed class PnvBotUpdateHandler( } } - private static async Task SendWelcomeAsync( + private async Task SendWelcomeAsync( ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) { - const string text = "Привет! Это бот PnvPanel.\n\n" - + "/configs — мои конфиги\n" - + "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n" - + "/unlink — отвязать Telegram\n" - + "/help — эта справка"; - var identityService = services.GetRequiredService(); var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); - // Уже привязанным аккаунту предлагать регистрацию незачем. - var keyboard = userId is null - ? new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }) - : null; - + var (text, keyboard) = BuildMainMenu(isLinked: userId is not null); await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); } + /// Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному — + /// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl. + private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked) + { + const string text = "Привет! Это бот PnvPanel.\n\n" + + "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение."; + + var rows = new List(); + if (isLinked) + { + rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }); + rows.Add(new[] { InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink") }); + } + else + { + rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }); + } + + if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) + rows.Add(new[] { InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl) }); + + return (text, new InlineKeyboardMarkup(rows)); + } + private static async Task HandleRegisterCallbackAsync( ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId, CancellationToken cancellationToken) diff --git a/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs b/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs index ae40cae..db329df 100644 --- a/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs +++ b/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs @@ -5,6 +5,8 @@ public sealed class TelegramOptions public const string SectionName = "Telegram"; public string? BotToken { get; init; } + + /// Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки нет. public string PublicSiteUrl { get; init; } = string.Empty; /// Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси. diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index d305822..bcd84a1 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -6,10 +6,13 @@ Telegram-бот — **второй канал доставки** (presentation- ## Возможности (реализовано) -1. **Мои конфиги** — `/configs` присылает по сообщению на конфиг (метка/локация, протокол, статус) с - inline-кнопкой **«🔗 Показать ссылку»**; connection string приходит отдельным сообщением только по - нажатию (не светится в списке/истории чата без явного действия пользователя). Только текстовая - ссылка, без QR-картинки — за QR пользователь идёт на сайт. Доступно только привязанному аккаунту. +1. **Мои конфиги** — `/configs` (или кнопка «📋 Мои конфиги» из главного меню) присылает **одно** + сообщение со списком (метка/локация, протокол, статус) и по кнопке `🔗 {Label}` на каждый активный + конфиг + кнопкой **«🔙 В меню»** внизу. Нажатие на конфиг **редактирует то же сообщение**: дописывает + connection string моноширинным блоком (тап = копирование целиком) и убирает именно эту кнопку — + остальные конфиги и «В меню» остаются на месте. Ничего не светится без явного нажатия, отдельных + сообщений не плодится. Только текстовая ссылка, без QR-картинки — за QR пользователь идёт на сайт. + Доступно только привязанному аккаунту. 2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте, подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram. 3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`) @@ -17,13 +20,19 @@ Telegram-бот — **второй канал доставки** (presentation- «✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию. 4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка, принудительный отзыв конфига админом. -5. **Отвязка** — `/unlink`. +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`). **Не реализовано / backlog:** - QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке). @@ -39,7 +48,7 @@ Telegram-бот — **второй канал доставки** (presentation- `PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере». - Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`) - содержит только `BotToken`, `BotUsername`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей + содержит только `BotToken`, `ProxyUrl`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей `Mode`/`WebhookUrl`/`WebhookSecret` в коде нет. - Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`, как HTTP-запрос — свежие scoped-сервисы на апдейт). @@ -122,16 +131,19 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl ## Флоу 4 — Просмотр конфигов в боте -1. Привязанный пользователь: `/configs`. +1. Привязанный пользователь: `/configs` (текстовая команда → новое сообщение) или «📋 Мои конфиги» из + главного меню (callback `menu:configs` → редактирует текущее сообщение, см. `BuildConfigsMenuAsync`). 2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`. -3. Ответ — отдельное сообщение на каждый конфиг: `• {Label ?? Location} ({Protocol}) — {Status}` + - inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет — - «У вас пока нет конфигов.» -4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что - эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение** - (`EditMessageText`), дописывая ссылку моноширинным блоком (``, тап = копирование целиком) и - убирая кнопку — не плодит отдельное сообщение с сырым URL. Ссылка не раскрывается нигде до явного - нажатия. QR-картинки нет — только текст. +3. Текст — **одно** сообщение: `Ваши конфиги:` + по строке `• {Label ?? Location} ({Protocol}) — {Status}`. + Клавиатура — по кнопке `🔗 {Label}` на каждый **не отозванный** конфиг + «🔙 В меню» внизу. Если + конфигов нет — «У вас пока нет конфигов.» с той же кнопкой «В меню». +4. Нажатие `🔗 {Label}` → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, + что эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение** + (`EditMessageText`): дописывает ссылку моноширинным блоком (``, тап = копирование целиком) и + убирает **именно эту** кнопку из клавиатуры (`InlineKeyboardMarkup.InlineKeyboard`, фильтр по + `CallbackData`) — остальные конфиги и «В меню» остаются кликабельными. Ссылка не раскрывается нигде + до явного нажатия, отдельных сообщений не плодится. QR-картинки нет — только текст. +5. «🔙 В меню» (`menu:back`) — редактирует сообщение обратно в главное меню (`BuildMainMenu`). ## Флоу 5 — Обработка активации админом в боте @@ -157,13 +169,17 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl | `/start` | Приветствие + справка по командам | нет | | `/start link_` | Привязка аккаунта по токену | нет | | `/start login_` | Подтверждение passwordless-входа (deep-link с сайта) | да | -| `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да | -| `/unlink` | Отвязать Telegram от аккаунта | да | +| `/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 — список команд.» ## Безопасность @@ -191,9 +207,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl "Telegram": { "BotToken": "…", // секрет; пусто = бот не стартует "ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси + "PublicSiteUrl": "https://dashboard.example.com", // кнопка «🌐 Сайт панели» в меню; пусто = кнопки нет "AdminTelegramUserIds": "123456789,987654321" // через запятую - // "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код, - // не задавай его — эффекта не будет) } ``` @@ -203,10 +218,10 @@ Username бота для deepLink (кнопка «Привязать Telegram»/ `BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`. -Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__AdminTelegramUserIds` -(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют, -кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой -сид-админа (`AdminSeed:*`), это независимый список. +Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__PublicSiteUrl`, +`Telegram__AdminTelegramUserIds` (см. [`.env.example`](../.env.example)). `AdminTelegramUserIds` +авторизует админ-кнопки в боте и определяет, кому слать уведомления о запросах активации — **не** +сидируется в БД и не связан с учёткой сид-админа (`AdminSeed:*`), это независимый список. Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —