From 0d05ff52e9586886ce1b06cef91f8916fe2b24a6 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 2 Jul 2026 15:44:40 +0300 Subject: [PATCH] Refactor Telegram bot configuration and deep link handling - Removed the `BotUsername` property from `TelegramOptions` and updated the `.env.example` to reflect this change, as the bot's username is now dynamically retrieved via the Bot API. - Introduced `ITelegramBotInfo` to cache the bot's username, improving the handling of deep links in `TelegramEndpoints`. - Updated API documentation to clarify that the deep link is now dependent on the bot's token and its availability through the Bot API, enhancing clarity for developers. --- .env.example | 5 ++- .../Endpoints/TelegramEndpoints.cs | 19 ++++----- backend/src/PnvPanel.Api/Program.cs | 2 + .../PnvPanel.Api/Telegram/TelegramBotInfo.cs | 42 +++++++++++++++++++ .../Common/Interfaces/ITelegramBotInfo.cs | 15 +++++++ .../Telegram/TelegramOptions.cs | 1 - docs/api-design.md | 3 +- docs/telegram-bot.md | 10 ++++- 8 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 backend/src/PnvPanel.Api/Telegram/TelegramBotInfo.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/ITelegramBotInfo.cs diff --git a/.env.example b/.env.example index 20e3ce9..1ab0275 100644 --- a/.env.example +++ b/.env.example @@ -36,9 +36,10 @@ Roles__DefaultUserMaxConfigs=3 # ── Telegram-бот ────────────────────────────────────────────────────────── # Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling -# (webhook не реализован, отдельного режима/URL для него нет). +# (webhook не реализован, отдельного режима/URL для него нет). Username бота для диплинков +# (кнопка «Привязать Telegram»/QR) панель узнаёт сама через Bot API (getMe) — задавать его отдельно +# не нужно и негде (раньше был Telegram__BotUsername — убран, чтобы не ломать диплинк опечаткой/пробелом). Telegram__BotToken= -Telegram__BotUsername=PnvPanelBot # Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации) # и получают уведомления о запросах на активацию. Узнать id: @userinfobot. # Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI. diff --git a/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs index 8bcf274..11d7ea9 100644 --- a/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs @@ -1,10 +1,9 @@ -using Microsoft.Extensions.Options; using PnvPanel.Api.Common; using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Telegram; using PnvPanel.Domain.Telegram; -using PnvPanel.Infrastructure.Telegram; namespace PnvPanel.Api.Endpoints; @@ -25,16 +24,14 @@ public static class TelegramEndpoints } private static async Task CreateLinkToken( - ISender sender, IOptions options, CancellationToken cancellationToken) + ISender sender, ITelegramBotInfo botInfo, CancellationToken cancellationToken) { var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken); if (!result.IsSuccess) return result.ToHttpResult(); - var botUsername = options.Value.BotUsername; - var deepLink = string.IsNullOrWhiteSpace(botUsername) - ? null - : $"https://t.me/{botUsername}?start=link_{result.Value.Token}"; + var botUsername = await botInfo.GetUsernameAsync(cancellationToken); + var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=link_{result.Value.Token}"; return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt)); } @@ -46,17 +43,15 @@ public static class TelegramEndpoints } private static async Task CreateLoginRequest( - HttpRequest request, ISender sender, IOptions options, CancellationToken cancellationToken) + HttpRequest request, ISender sender, ITelegramBotInfo botInfo, CancellationToken cancellationToken) { var context = request.HttpContext.Connection.RemoteIpAddress?.ToString(); var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken); if (!result.IsSuccess) return result.ToHttpResult(); - var botUsername = options.Value.BotUsername; - var deepLink = string.IsNullOrWhiteSpace(botUsername) - ? null - : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}"; + var botUsername = await botInfo.GetUsernameAsync(cancellationToken); + var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}"; return Results.Ok(new TelegramLoginRequestResponseDto(result.Value.RequestId, deepLink, result.Value.ExpiresAt)); } diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index b845f45..abee04e 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -85,6 +85,8 @@ builder.Services.AddSingleton(sp => }); // Scoped — зависит от IIdentityService (scoped), не Singleton. builder.Services.AddScoped(); +// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo). +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramBotInfo.cs b/backend/src/PnvPanel.Api/Telegram/TelegramBotInfo.cs new file mode 100644 index 0000000..f155f12 --- /dev/null +++ b/backend/src/PnvPanel.Api/Telegram/TelegramBotInfo.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Options; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Infrastructure.Telegram; +using Telegram.Bot; + +namespace PnvPanel.Api.Telegram; + +/// Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен). +internal sealed class TelegramBotInfo(ITelegramBotClient botClient, IOptions options) : ITelegramBotInfo +{ + private readonly SemaphoreSlim _lock = new(1, 1); + private string? _cachedUsername; + + public async Task GetUsernameAsync(CancellationToken cancellationToken) + { + if (_cachedUsername is not null) + return _cachedUsername; + + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + return null; + + await _lock.WaitAsync(cancellationToken); + try + { + if (_cachedUsername is not null) + return _cachedUsername; + + var me = await botClient.GetMe(cancellationToken); + _cachedUsername = me.Username; + return _cachedUsername; + } + catch + { + // Telegram недоступен/бот не отвечает — деплинк просто не покажем вызывающей стороне. + return null; + } + finally + { + _lock.Release(); + } + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramBotInfo.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramBotInfo.cs new file mode 100644 index 0000000..58de98a --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramBotInfo.cs @@ -0,0 +1,15 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Метаданные самого бота (не пользователя). Реализация — в Api (нужен ITelegramBotClient), +/// аналогично ITelegramNotifier. +/// +public interface ITelegramBotInfo +{ + /// + /// Username бота для диплинков (https://t.me/{username}?start=...), получен через Bot API + /// (getMe), не из ручной конфигурации — так деплинк не сломается из-за опечатки/пробела в env. + /// Null, если BotToken не задан или Telegram недоступен. + /// + Task GetUsernameAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs b/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs index 3f8efd8..ae40cae 100644 --- a/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs +++ b/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs @@ -5,7 +5,6 @@ public sealed class TelegramOptions public const string SectionName = "Telegram"; public string? BotToken { get; init; } - public string? BotUsername { get; init; } public string PublicSiteUrl { get; init; } = string.Empty; /// Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси. diff --git a/docs/api-design.md b/docs/api-design.md index 36ace04..4d4083d 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -39,7 +39,8 @@ rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 за | POST | `/api/auth/telegram/login-request` | — | `{ requestId, deepLink, expiresAt }` | | GET | `/api/auth/telegram/login-request/{id}` | — | см. ниже | -`deepLink` — `null`, если `Telegram:BotUsername` не настроен (бот не привязан к инстансу), иначе +`deepLink` — `null`, если `Telegram:BotToken` не настроен или Bot API недоступен (username бота +панель получает сама через `getMe`, см. [telegram-bot.md](telegram-bot.md#конфигурация)), иначе `https://t.me/?start=link_` / `?start=login_`. **QR backend не рендерит** — фронт строит QR из `deepLink` сам (`qrcode.react`). diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index bb161c0..dfacee2 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -157,14 +157,20 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl ```jsonc "Telegram": { "BotToken": "…", // секрет; пусто = бот не стартует - "BotUsername": "PnvPanelBot", // для deepLink; null/пусто -> deepLink в ответах API тоже null + "ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси "AdminTelegramUserIds": "123456789,987654321" // через запятую // "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код, // не задавай его — эффекта не будет) } ``` -Переменные окружения — `Telegram__BotToken`, `Telegram__BotUsername`, `Telegram__AdminTelegramUserIds` +Username бота для deepLink (кнопка «Привязать Telegram»/QR, `?start=link_`/`?start=login_`) +панель получает сама через Bot API (`getMe`) и кэширует на время жизни процесса (`ITelegramBotInfo`, +`Api/Telegram/TelegramBotInfo.cs`) — отдельного поля конфигурации для него больше нет (было +`BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в +самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`. + +Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__AdminTelegramUserIds` (см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют, кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой сид-админа (`AdminSeed:*`), это независимый список.