Add Telegram bot integration and enhance user management features

- Introduced Telegram.Bot package for bot functionality.
- Updated user management to include Telegram linking and blocking features.
- Enhanced activation request handling with notifications via Telegram.
- Added new database entities for Telegram link tokens and login requests.
- Implemented traffic synchronization for client stats in the XuiPanelGateway.
- Updated application structure to support new test projects and improved dependency injection for Telegram services.
This commit is contained in:
Leonid Pershin
2026-07-02 01:01:03 +03:00
parent 1a8d33efa3
commit 7b6fe9ad78
142 changed files with 7570 additions and 22 deletions
@@ -0,0 +1,54 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Apps;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Domain.Apps;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminAppEndpoints
{
public static IEndpointRouteBuilder MapAdminAppEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/apps")
.WithTags("Admin.Apps")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListApps);
admin.MapPost("", CreateApp);
admin.MapPut("/{id:guid}", UpdateApp);
admin.MapDelete("/{id:guid}", DeleteApp);
return app;
}
private static async Task<IResult> ListApps(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAdminAppsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateApp(Guid id, UpdateAppBody body, ISender sender, CancellationToken cancellationToken)
{
var command = new UpdateAppCommand(
id, body.Name, body.DownloadUrl, body.OperatingSystem, body.Description, body.IconUrl, body.SortOrder, body.IsEnabled);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteApp(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateAppBody(
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl,
int SortOrder, bool IsEnabled);
@@ -0,0 +1,35 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Audit;
using PnvPanel.Application.Admin.Stats;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminStatsEndpoints
{
public static IEndpointRouteBuilder MapAdminStatsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Stats")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/stats", GetStats);
admin.MapGet("/audit", GetAudit);
return app;
}
private static async Task<IResult> GetStats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetStatsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
{
var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,65 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/users", ListUsers);
admin.MapPatch("/users/{id:guid}/block", BlockUser);
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser);
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword);
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs);
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig);
return app;
}
private static async Task<IResult> ListUsers(
int page, int pageSize, string? search, ISender sender, CancellationToken cancellationToken)
{
var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BlockUser(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetPassword(Guid id, ResetPasswordBody body, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ResetUserPasswordCommand(id, body.NewPassword), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ResetPasswordBody(string NewPassword);
@@ -0,0 +1,91 @@
using Microsoft.Extensions.Options;
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Telegram;
using PnvPanel.Infrastructure.Telegram;
namespace PnvPanel.Api.Endpoints;
public static class TelegramEndpoints
{
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth/telegram")
.WithTags("Auth.Telegram")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/link-token", CreateLinkToken).RequireAuthorization();
group.MapPost("/unlink", Unlink).RequireAuthorization();
group.MapPost("/login-request", CreateLoginRequest);
group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus);
return app;
}
private static async Task<IResult> CreateLinkToken(
ISender sender, IOptions<TelegramOptions> options, 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}";
return Results.Ok(new { deepLink, expiresAt = result.Value.ExpiresAt });
}
private static async Task<IResult> Unlink(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLoginRequest(
HttpRequest request, ISender sender, IOptions<TelegramOptions> options, 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}";
return Results.Ok(new { requestId = result.Value.RequestId, deepLink, expiresAt = result.Value.ExpiresAt });
}
private static async Task<IResult> GetLoginRequestStatus(Guid id, HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Auth is { } auth)
{
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
Expires = auth.RefreshTokenExpiresAt,
};
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
return Results.Ok(new
{
status = result.Value.Status.ToString(),
accessToken = auth.AccessToken,
expiresAt = auth.AccessTokenExpiresAt,
user = auth.User,
});
}
return Results.Ok(new { status = result.Value.Status.ToString() });
}
}
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Hubs;
/// <summary>
/// Группы: user:{userId} (личные события — трафик/статус конфига), admins (статусы нод,
/// запросы активации). UserIdentifier берётся из claim NameIdentifier — того же, что кладём в JWT.
/// </summary>
[Authorize]
public sealed class PanelHub : Hub
{
public override async Task OnConnectedAsync()
{
if (Context.UserIdentifier is { } userId)
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.User(userId));
if (Context.User?.IsInRole(RoleNames.Admin) == true)
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.Admins);
await base.OnConnectedAsync();
}
}
public static class GroupNames
{
public const string Admins = "admins";
public static string User(string userId) => $"user:{userId}";
public static string User(Guid userId) => User(userId.ToString());
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.SignalR;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Api.Hubs;
internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext) : IRealtimeNotifier
{
public Task NotifyConfigTrafficUpdatedAsync(
Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
"configTrafficUpdated",
new { configId, usedUpBytes, usedDownBytes },
cancellationToken);
}
public Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
"configStatusChanged",
new { configId, status = status.ToString() },
cancellationToken);
}
public Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken)
{
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
"nodeStatusChanged",
new { nodeId, status = status.ToString(), lastSyncAt },
cancellationToken);
}
public Task NotifyActivationRequestedAsync(
Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken)
{
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
"activationRequested",
new { requestId, userId, userName, comment, createdAt },
cancellationToken);
}
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
"userActivated",
new { userId },
cancellationToken);
}
}
@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Telegram.Bot" />
</ItemGroup>
<PropertyGroup>
+31
View File
@@ -1,13 +1,19 @@
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using PnvPanel.Api.Common;
using PnvPanel.Api.Endpoints;
using PnvPanel.Api.Hubs;
using PnvPanel.Api.Telegram;
using PnvPanel.Application;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Infrastructure;
using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Persistence;
using PnvPanel.Infrastructure.Telegram;
using Scalar.AspNetCore;
using Serilog;
using Telegram.Bot;
var builder = WebApplication.CreateBuilder(args);
@@ -29,6 +35,22 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddSignalR();
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена.
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<TelegramOptions>>();
return new TelegramBotClient(options.Value.BotToken ?? string.Empty);
});
// Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
builder.Services.AddSingleton<PnvBotUpdateHandler>();
builder.Services.AddHostedService<TelegramBotHostedService>();
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
@@ -74,6 +96,12 @@ app.MapInboundEndpoints();
app.MapConfigEndpoints();
app.MapSubscriptionEndpoints();
app.MapAppEndpoints();
app.MapAdminUserEndpoints();
app.MapAdminStatsEndpoints();
app.MapAdminAppEndpoints();
app.MapTelegramEndpoints();
app.MapHub<PanelHub>("/hubs/panel");
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -81,3 +109,6 @@ app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary>
public partial class Program;
@@ -0,0 +1,293 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Admin.Activation;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Telegram;
using PnvPanel.Application.Telegram.Bot;
using PnvPanel.Domain.Activation;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram;
/// <summary>
/// Обрабатывает апдейты бота. Каждый апдейт — свой DI-scope (как HTTP-запрос), чтобы получить
/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
/// </summary>
public sealed class PnvBotUpdateHandler(
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger)
: IUpdateHandler
{
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
try
{
if (update.Message is { Text: { } text } message)
await HandleMessageAsync(botClient, scope.ServiceProvider, message, text, cancellationToken);
else if (update.CallbackQuery is { } callback)
await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка обработки Telegram-апдейта {UpdateId}", update.Id);
}
}
public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
{
logger.LogError(exception, "Ошибка Telegram-бота (источник {Source})", source);
return Task.CompletedTask;
}
private async Task HandleMessageAsync(
ITelegramBotClient botClient, IServiceProvider services, Message message, string text, CancellationToken cancellationToken)
{
var chatId = message.Chat.Id;
var fromId = message.From?.Id;
if (fromId is null)
return;
if (text.StartsWith("/start", StringComparison.Ordinal))
{
var payload = text.Length > 7 ? text[7..].Trim() : string.Empty;
if (payload.StartsWith("link_", StringComparison.Ordinal))
await HandleLinkAsync(botClient, services, chatId, fromId.Value, message.From?.Username, payload[5..], cancellationToken);
else if (payload.StartsWith("login_", StringComparison.Ordinal))
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
else
await SendWelcomeAsync(botClient, chatId, cancellationToken);
return;
}
switch (text)
{
case "/configs":
await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
break;
case "/unlink":
await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken);
break;
case "/requests":
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
break;
case "/help":
await SendWelcomeAsync(botClient, chatId, cancellationToken);
break;
default:
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
break;
}
}
private async Task HandleCallbackAsync(
ITelegramBotClient botClient, IServiceProvider services, CallbackQuery callback, CancellationToken cancellationToken)
{
var data = callback.Data;
var chatId = callback.Message?.Chat.Id;
var fromId = callback.From.Id;
if (data is null || chatId is null)
return;
var parts = data.Split(':');
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
return;
var sender = services.GetRequiredService<ISender>();
switch (parts[0])
{
case "login":
{
var result = parts[1] == "approve"
? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken)
: await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken);
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
if (result.IsSuccess)
{
var text = parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.";
await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
}
break;
}
case "act":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
return;
}
var result = parts[1] == "approve"
? await sender.Send(new ApproveActivationCommand(requestId), cancellationToken)
: await sender.Send(new RejectActivationCommand(requestId, Reason: null), cancellationToken);
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
if (result.IsSuccess)
{
var text = parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.";
await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
}
break;
}
}
}
private async Task HandleLinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token,
CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new LinkTelegramCommand(token, fromId, username), cancellationToken);
var text = result.IsSuccess
? "✅ Telegram успешно привязан к вашему аккаунту."
: $"❌ Не удалось привязать: {result.Error.Message}";
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
}
private async Task HandleLoginPromptAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string requestIdRaw, CancellationToken cancellationToken)
{
if (!Guid.TryParse(requestIdRaw, out var requestId))
{
await botClient.SendMessage(chatId, "Некорректная ссылка входа.", cancellationToken: cancellationToken);
return;
}
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
if (userId is null)
{
await botClient.SendMessage(chatId, "Сначала привяжите Telegram к аккаунту на сайте.", cancellationToken: cancellationToken);
return;
}
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
});
await botClient.SendMessage(
chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
replyMarkup: keyboard, cancellationToken: cancellationToken);
}
private async Task HandleConfigsAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(
chatId, "Сначала привяжите Telegram к аккаунту на сайте (Настройки → Привязать Telegram).",
cancellationToken: cancellationToken);
return;
}
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
if (!result.IsSuccess || result.Value.Configs.Count == 0)
{
await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken);
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);
}
private async Task HandleUnlinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken);
return;
}
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
await botClient.SendMessage(
chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
cancellationToken: cancellationToken);
}
private async Task HandleRequestsAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: cancellationToken);
return;
}
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10), cancellationToken);
if (!result.IsSuccess || result.Value.Items.Count == 0)
{
await botClient.SendMessage(chatId, "Нет ожидающих запросов на активацию.", cancellationToken: cancellationToken);
return;
}
foreach (var item in result.Value.Items)
{
var text = $"Запрос от <b>{item.UserName}</b>" + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{item.Id}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
});
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
}
}
private static async Task SendWelcomeAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
{
const string text = "Привет! Это бот PnvPanel.\n\n"
+ "/configs — мои конфиги\n"
+ "/login — войти на сайт без пароля\n"
+ "/unlink — отвязать Telegram\n"
+ "/help — эта справка";
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
}
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
{
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, cancellationToken);
if (userId is null)
return false;
var profile = await identityService.GetProfileAsync(userId.Value, cancellationToken);
if (profile is null)
return false;
services.GetRequiredService<ICurrentUserSetter>().SetUser(profile.Id, profile.UserName);
return true;
}
private async Task<bool> TrySetAdminCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
{
if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId))
return false;
return await TrySetCurrentUserAsync(services, telegramUserId, cancellationToken);
}
}
@@ -0,0 +1,45 @@
using Microsoft.Extensions.Options;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace PnvPanel.Api.Telegram;
/// <summary>
/// Бот — presentation-адаптер, хостится в процессе Api (long polling). Если BotToken не задан,
/// не стартует — панель работает без бота. Апдейты обрабатывает PnvBotUpdateHandler, который
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
/// </summary>
public sealed class TelegramBotHostedService(
ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
{
logger.LogWarning("Telegram__BotToken не задан — бот не стартует.");
return;
}
var receiverOptions = new ReceiverOptions
{
AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery],
DropPendingUpdates = true,
};
logger.LogInformation("Telegram-бот запускается (long polling)...");
try
{
await botClient.ReceiveAsync(updateHandler, receiverOptions, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Штатная остановка вместе с приложением.
}
}
}
@@ -0,0 +1,62 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram;
internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions<TelegramOptions> options)
: ITelegramNotifier
{
public async Task NotifyAdminsActivationRequestedAsync(
Guid requestId, string userName, string? comment, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text = $"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
+ (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}");
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
});
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var link = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
if (!link.IsLinked || link.TelegramUserId is not { } telegramUserId)
return;
try
{
await botClient.SendMessage(telegramUserId, message, cancellationToken: cancellationToken);
}
catch
{
// Пользователь мог заблокировать бота — не критично для основной операции.
}
}
private static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}