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.
This commit is contained in:
+3
-2
@@ -36,9 +36,10 @@ Roles__DefaultUserMaxConfigs=3
|
|||||||
|
|
||||||
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
||||||
# Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling
|
# Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling
|
||||||
# (webhook не реализован, отдельного режима/URL для него нет).
|
# (webhook не реализован, отдельного режима/URL для него нет). Username бота для диплинков
|
||||||
|
# (кнопка «Привязать Telegram»/QR) панель узнаёт сама через Bot API (getMe) — задавать его отдельно
|
||||||
|
# не нужно и негде (раньше был Telegram__BotUsername — убран, чтобы не ломать диплинк опечаткой/пробелом).
|
||||||
Telegram__BotToken=
|
Telegram__BotToken=
|
||||||
Telegram__BotUsername=PnvPanelBot
|
|
||||||
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации)
|
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации)
|
||||||
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
|
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
|
||||||
# Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI.
|
# Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI.
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using Microsoft.Extensions.Options;
|
|
||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Telegram;
|
using PnvPanel.Application.Telegram;
|
||||||
using PnvPanel.Domain.Telegram;
|
using PnvPanel.Domain.Telegram;
|
||||||
using PnvPanel.Infrastructure.Telegram;
|
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
@@ -25,16 +24,14 @@ public static class TelegramEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateLinkToken(
|
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);
|
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
|
|
||||||
var botUsername = options.Value.BotUsername;
|
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
|
||||||
var deepLink = string.IsNullOrWhiteSpace(botUsername)
|
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
|
||||||
? null
|
|
||||||
: $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
|
|
||||||
|
|
||||||
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
|
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
|
||||||
}
|
}
|
||||||
@@ -46,17 +43,15 @@ public static class TelegramEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateLoginRequest(
|
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 context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
|
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
|
|
||||||
var botUsername = options.Value.BotUsername;
|
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
|
||||||
var deepLink = string.IsNullOrWhiteSpace(botUsername)
|
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
|
||||||
? null
|
|
||||||
: $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
|
|
||||||
|
|
||||||
return Results.Ok(new TelegramLoginRequestResponseDto(result.Value.RequestId, deepLink, result.Value.ExpiresAt));
|
return Results.Ok(new TelegramLoginRequestResponseDto(result.Value.RequestId, deepLink, result.Value.ExpiresAt));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
|||||||
});
|
});
|
||||||
// Scoped — зависит от IIdentityService (scoped), не Singleton.
|
// Scoped — зависит от IIdentityService (scoped), не Singleton.
|
||||||
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
||||||
|
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
|
||||||
|
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
|
||||||
builder.Services.AddSingleton<PnvBotUpdateHandler>();
|
builder.Services.AddSingleton<PnvBotUpdateHandler>();
|
||||||
builder.Services.AddHostedService<TelegramBotHostedService>();
|
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 const string SectionName = "Telegram";
|
||||||
|
|
||||||
public string? BotToken { get; init; }
|
public string? BotToken { get; init; }
|
||||||
public string? BotUsername { get; init; }
|
|
||||||
public string PublicSiteUrl { get; init; } = string.Empty;
|
public string PublicSiteUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary>
|
/// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary>
|
||||||
|
|||||||
+2
-1
@@ -39,7 +39,8 @@ rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 за
|
|||||||
| POST | `/api/auth/telegram/login-request` | — | `{ requestId, deepLink, expiresAt }` |
|
| POST | `/api/auth/telegram/login-request` | — | `{ requestId, deepLink, expiresAt }` |
|
||||||
| GET | `/api/auth/telegram/login-request/{id}` | — | см. ниже |
|
| 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/<bot>?start=link_<token>` / `?start=login_<requestId>`. **QR backend не рендерит** —
|
`https://t.me/<bot>?start=link_<token>` / `?start=login_<requestId>`. **QR backend не рендерит** —
|
||||||
фронт строит QR из `deepLink` сам (`qrcode.react`).
|
фронт строит QR из `deepLink` сам (`qrcode.react`).
|
||||||
|
|
||||||
|
|||||||
@@ -157,14 +157,20 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
```jsonc
|
```jsonc
|
||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": "…", // секрет; пусто = бот не стартует
|
"BotToken": "…", // секрет; пусто = бот не стартует
|
||||||
"BotUsername": "PnvPanelBot", // для deepLink; null/пусто -> deepLink в ответах API тоже null
|
"ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси
|
||||||
"AdminTelegramUserIds": "123456789,987654321" // через запятую
|
"AdminTelegramUserIds": "123456789,987654321" // через запятую
|
||||||
// "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
|
// "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
|
||||||
// не задавай его — эффекта не будет)
|
// не задавай его — эффекта не будет)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Переменные окружения — `Telegram__BotToken`, `Telegram__BotUsername`, `Telegram__AdminTelegramUserIds`
|
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__AdminTelegramUserIds`
|
||||||
(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют,
|
(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют,
|
||||||
кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой
|
кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой
|
||||||
сид-админа (`AdminSeed:*`), это независимый список.
|
сид-админа (`AdminSeed:*`), это независимый список.
|
||||||
|
|||||||
Reference in New Issue
Block a user