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:
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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<Program> в интеграционных тестах.</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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
Reference in New Issue
Block a user