Refactor Telegram bot configuration and deep link handling
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s

- 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.
This commit is contained in:
Leonid Pershin
2026-07-02 15:44:40 +03:00
parent f731249e78
commit 0d05ff52e9
8 changed files with 79 additions and 18 deletions
@@ -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<IResult> CreateLinkToken(
ISender sender, IOptions<TelegramOptions> 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<IResult> CreateLoginRequest(
HttpRequest request, ISender sender, IOptions<TelegramOptions> 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));
}
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
});
// Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
builder.Services.AddSingleton<PnvBotUpdateHandler>();
builder.Services.AddHostedService<TelegramBotHostedService>();
@@ -0,0 +1,42 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
namespace PnvPanel.Api.Telegram;
/// <summary>Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен).</summary>
internal sealed class TelegramBotInfo(ITelegramBotClient botClient, IOptions<TelegramOptions> options) : ITelegramBotInfo
{
private readonly SemaphoreSlim _lock = new(1, 1);
private string? _cachedUsername;
public async Task<string?> 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();
}
}
}
@@ -0,0 +1,15 @@
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>
/// Метаданные самого бота (не пользователя). Реализация — в Api (нужен ITelegramBotClient),
/// аналогично ITelegramNotifier.
/// </summary>
public interface ITelegramBotInfo
{
/// <summary>
/// Username бота для диплинков (https://t.me/{username}?start=...), получен через Bot API
/// (getMe), не из ручной конфигурации — так деплинк не сломается из-за опечатки/пробела в env.
/// Null, если BotToken не задан или Telegram недоступен.
/// </summary>
Task<string?> GetUsernameAsync(CancellationToken cancellationToken);
}
@@ -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;
/// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary>