Refactor project files for improved readability and structure
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- Cleaned up whitespace in Directory.Build.props and Directory.Packages.props for consistency.
- Reformatted project file references in PnvPanel.Api.csproj for better clarity.
- Enhanced code readability in various endpoint files by adjusting line breaks and indentation.
- Standardized method signatures and improved formatting in ResultExtensions and multiple endpoint classes for better maintainability.
This commit is contained in:
Leonid Pershin
2026-07-14 07:24:13 +03:00
parent 9d5424bb9c
commit df137ca5a7
285 changed files with 6911 additions and 2063 deletions
@@ -4,11 +4,11 @@ namespace PnvPanel.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result)
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result)
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error)
{
@@ -27,40 +27,69 @@ public static class ActivationEndpoints
return app;
}
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RequestActivation(
RequestActivationCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListRequests(
[AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken)
[AsParameters] ListActivationRequestsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize);
var query = new ListActivationRequestsQuery(
request.StatusFilter,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Approve(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Reject(
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken)
Guid id,
RejectActivationBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken);
var result = await sender.Send(
new RejectActivationCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20);
public sealed record ListActivationRequestsRequest(
ActivationStatus? StatusFilter,
int Page = 1,
int PageSize = 20
);
public sealed record RejectActivationBody(string? Reason);
@@ -28,21 +28,42 @@ public static class AdminAppEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken)
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)
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);
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)
private static async Task<IResult> DeleteApp(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
return result.ToHttpResult();
@@ -50,5 +71,11 @@ public static class AdminAppEndpoints
}
public sealed record UpdateAppBody(
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl,
int SortOrder, bool IsEnabled);
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
);
@@ -23,26 +23,44 @@ public static class AdminNewsEndpoints
return app;
}
private static async Task<IResult> ListAdminNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListAdminNews(
int page,
int pageSize,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreatePost(CreatePostCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> CreatePost(
CreatePostCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdatePost(Guid id, UpdatePostBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> UpdatePost(
Guid id,
UpdatePostBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdatePostCommand(id, body.Title, body.Body);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeletePost(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeletePost(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeletePostCommand(id), cancellationToken);
return result.ToHttpResult();
@@ -27,7 +27,12 @@ public static class AdminStatsEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
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);
@@ -19,59 +19,104 @@ public static class AdminSupportEndpoints
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
admin.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
admin.MapPost("/tickets/{id:guid}/resolve", Resolve).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/comments", AddComment)
.DisableAntiforgery()
.Produces<TicketCommentDto>();
admin
.MapPost("/tickets/{id:guid}/resolve", Resolve)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListTickets(
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
[AsParameters] ListTicketsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListAllTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
var query = new ListAllTicketsQuery(
request.Type,
request.Status,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetTicket(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddComment(
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
Guid id,
[FromForm] string body,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files));
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Resolve(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Resolve(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Close(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Close(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CloseTicketCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ApproveRoleRequest(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ApproveRoleRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RejectRoleRequest(
Guid id, RejectRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
Guid id,
RejectRoleRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RejectRoleRequestCommand(id, body.Reason), cancellationToken);
var result = await sender.Send(
new RejectRoleRequestCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult();
}
}
@@ -19,64 +19,118 @@ public static class AdminUserEndpoints
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/users", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPatch("/users/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/block", BlockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/unblock", UnblockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/users/{id:guid}/reset-password", ResetPassword)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces<IReadOnlyList<VpnConfigDto>>();
admin
.MapGet("/users/{id:guid}/configs", GetUserConfigs)
.Produces<IReadOnlyList<VpnConfigDto>>();
admin.MapGet("/configs", ListAllConfigs).Produces<PagedList<AdminVpnConfigDto>>();
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/configs/{id:guid}", ForceRevokeConfig)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListUsers(
int page, int pageSize, string? search, ISender sender, CancellationToken cancellationToken)
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)
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)
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)
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);
var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
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> ListAllConfigs(
int page, int pageSize, string? search, ConfigStatus? status, ISender sender, CancellationToken cancellationToken)
int page,
int pageSize,
string? search,
ConfigStatus? status,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListAllConfigsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, status);
var query = new ListAllConfigsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
search,
status
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ForceRevokeConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
return result.ToHttpResult();
@@ -25,34 +25,69 @@ public static class AuthEndpoints
group.MapPost("/register", Register).Produces<RegisterResult>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group.MapPost("/logout", Logout).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group.MapPost("/change-password", ChangePassword).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group.MapPost("/change-username", ChangeUserName).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group
.MapPost("/logout", Logout)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpRequest request, HttpResponse response, CancellationToken cancellationToken)
private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken))
if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
@@ -62,29 +97,50 @@ public static class AuthEndpoints
return result.ToHttpResult();
}
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Logout(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken))
if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(ChangeUserNameCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
@@ -96,14 +152,24 @@ public static class AuthEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
private static void SetRefreshCookie(HttpRequest request, HttpResponse response, string rawToken, DateTimeOffset expiresAt)
private static void SetRefreshCookie(
HttpRequest request,
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt
)
{
var options = BuildCookieOptions(request);
options.Expires = expiresAt;
@@ -112,13 +178,18 @@ public static class AuthEndpoints
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
private static CookieOptions BuildCookieOptions(HttpRequest request) => new()
{
HttpOnly = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
new()
{
HttpOnly = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
}
public sealed record AuthResponseDto(string AccessToken, DateTimeOffset ExpiresAt, CurrentUserDto User);
public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
@@ -18,73 +18,113 @@ public static class ConfigEndpoints
{
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
group.MapGet("/inbounds/available", ListAvailableInbounds).Produces<IReadOnlyList<AvailableInboundDto>>();
group
.MapGet("/inbounds/available", ListAvailableInbounds)
.Produces<IReadOnlyList<AvailableInboundDto>>();
group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>();
group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>();
group.MapPatch("/configs/{id:guid}", EditConfig).Produces<VpnConfigDto>();
group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces<VpnConfigDto>();
group.MapDelete("/configs/{id:guid}", RevokeConfig).Produces(StatusCodes.Status204NoContent);
group
.MapDelete("/configs/{id:guid}", RevokeConfig)
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces<ConfigLinkResponseDto>();
group.MapGet("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>();
return app;
}
private static async Task<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListAvailableInbounds(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetMyConfigs(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> CreateConfig(
CreateConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new CreateVpnConfigCommand(body.InboundId, body.Label);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> EditConfig(
Guid id,
EditConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new EditVpnConfigCommand(id, body.Label);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RotateConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RevokeConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetConfigLink(
Guid id,
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl));
var subscriptionUrl =
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(
new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)
);
}
private static async Task<IResult> GetMySubscription(HttpRequest request, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetMySubscription(
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
var subscriptionUrl =
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new MySubscriptionResponseDto(subscriptionUrl));
}
}
@@ -19,20 +19,38 @@ public static class InboundEndpoints
return app;
}
private static async Task<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListInbounds(
Guid? nodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PublishInbound(
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken)
Guid id,
PublishInboundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new PublishInboundCommand(
id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients);
id,
body.IsPublished,
body.DisplayName,
body.AllowedRoleIds ?? [],
body.MaxClients
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
}
public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList<Guid>? AllowedRoleIds, int? MaxClients);
public sealed record PublishInboundBody(
bool IsPublished,
string? DisplayName,
IReadOnlyList<Guid>? AllowedRoleIds,
int? MaxClients
);
@@ -16,7 +16,12 @@ public static class NewsEndpoints
return app;
}
private static async Task<IResult> ListNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListNews(
int page,
int pageSize,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult();
@@ -23,42 +23,79 @@ public static class NodeEndpoints
return app;
}
private static async Task<IResult> ListNodes(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListNodes(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListNodesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RegisterNode(
RegisterNodeCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> UpdateNode(
Guid id,
UpdateNodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password);
var command = new UpdateNodeCommand(
id,
body.Name,
body.Location,
body.IsEnabled,
body.Username,
body.Password
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> SyncNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ProbeNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password);
public sealed record UpdateNodeBody(
string Name,
string? Location,
bool IsEnabled,
string? Username,
string? Password
);
@@ -19,38 +19,67 @@ public static class RoleEndpoints
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin.MapPatch("/users/{id:guid}/role", ChangeUserRole).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListRoles(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListRolesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit), cancellationToken);
var result = await sender.Send(
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken);
var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
}
@@ -19,12 +19,19 @@ public static class SubscriptionEndpoints
return app;
}
private static async Task<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetSubscription(
string token,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
// (один конфиг). Пробуем пользовательский токен первым.
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess
? userResult
: await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
@@ -36,7 +43,8 @@ public static class SubscriptionEndpoints
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
response.Headers.Append(
"Subscription-Userinfo",
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}");
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"
);
response.Headers.Append("Profile-Update-Interval", "12");
return Results.Text(base64Body, "text/plain; charset=utf-8");
@@ -23,25 +23,38 @@ public static class SupportEndpoints
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
group.MapPost("/tickets/bug-reports", CreateBugReport).DisableAntiforgery().Produces<TicketDetailDto>();
group
.MapPost("/tickets/bug-reports", CreateBugReport)
.DisableAntiforgery()
.Produces<TicketDetailDto>();
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
group.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
group
.MapPost("/tickets/{id:guid}/comments", AddComment)
.DisableAntiforgery()
.Produces<TicketCommentDto>();
group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent);
group.MapGet("/attachments/{id:guid}", GetAttachment);
return app;
}
private static async Task<IResult> ListSelectableRoles(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListSelectableRoles(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateBugReport(
[FromForm] string message, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
[FromForm] string message,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new CreateBugReportTicketCommand(message, ToUploads(files));
var result = await sender.Send(command, cancellationToken);
@@ -49,43 +62,76 @@ public static class SupportEndpoints
}
private static async Task<IResult> CreateRoleRequest(
CreateRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
CreateRoleRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new CreateRoleRequestTicketCommand(
body.ExistingRoleId, body.NewRoleName, body.NewRoleMaxConfigs, body.NewRoleMaxIpLimit, body.Justification);
body.ExistingRoleId,
body.NewRoleName,
body.NewRoleMaxConfigs,
body.NewRoleMaxIpLimit,
body.Justification
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListMyTickets(
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
[AsParameters] ListTicketsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListMyTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
var query = new ListMyTicketsQuery(
request.Type,
request.Status,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetTicket(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTicketQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddComment(
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
Guid id,
[FromForm] string body,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new AddTicketCommentCommand(id, body, ToUploads(files));
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Reopen(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Reopen(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAttachment(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetAttachment(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken);
if (!result.IsSuccess)
@@ -100,12 +146,27 @@ public static class SupportEndpoints
return [];
return files
.Select(f => new TicketAttachmentUpload(f.OpenReadStream(), f.FileName, f.ContentType, f.Length))
.Select(f => new TicketAttachmentUpload(
f.OpenReadStream(),
f.FileName,
f.ContentType,
f.Length
))
.ToList();
}
}
public sealed record CreateRoleRequestBody(
Guid? ExistingRoleId, string? NewRoleName, int? NewRoleMaxConfigs, int? NewRoleMaxIpLimit, string Justification);
Guid? ExistingRoleId,
string? NewRoleName,
int? NewRoleMaxConfigs,
int? NewRoleMaxIpLimit,
string Justification
);
public sealed record ListTicketsRequest(TicketType? Type, TicketStatus? Status, int Page = 1, int PageSize = 20);
public sealed record ListTicketsRequest(
TicketType? Type,
TicketStatus? Status,
int Page = 1,
int PageSize = 20
);
@@ -15,23 +15,38 @@ public static class TelegramEndpoints
.WithTags("Auth.Telegram")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/link-token", CreateLinkToken).RequireAuthorization().Produces<LinkTokenResponseDto>();
group.MapPost("/unlink", Unlink).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group.MapPost("/login-request", CreateLoginRequest).Produces<TelegramLoginRequestResponseDto>();
group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus).Produces<TelegramLoginStatusResponseDto>();
group
.MapPost("/link-token", CreateLinkToken)
.RequireAuthorization()
.Produces<LinkTokenResponseDto>();
group
.MapPost("/unlink", Unlink)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/login-request", CreateLoginRequest)
.Produces<TelegramLoginRequestResponseDto>();
group
.MapGet("/login-request/{id:guid}", GetLoginRequestStatus)
.Produces<TelegramLoginStatusResponseDto>();
return app;
}
private static async Task<IResult> CreateLinkToken(
ISender sender, ITelegramBotInfo botInfo, CancellationToken cancellationToken)
ISender sender,
ITelegramBotInfo botInfo,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
var deepLink = botUsername is null
? null
: $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
}
@@ -43,7 +58,11 @@ public static class TelegramEndpoints
}
private static async Task<IResult> CreateLoginRequest(
HttpRequest request, ISender sender, ITelegramBotInfo botInfo, 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);
@@ -51,13 +70,26 @@ public static class TelegramEndpoints
return result.ToHttpResult();
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
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));
return Results.Ok(
new TelegramLoginRequestResponseDto(
result.Value.RequestId,
deepLink,
result.Value.ExpiresAt
)
);
}
private static async Task<IResult> GetLoginRequestStatus(
Guid id, HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
Guid id,
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
if (!result.IsSuccess)
@@ -77,17 +109,33 @@ public static class TelegramEndpoints
};
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
return Results.Ok(new TelegramLoginStatusResponseDto(
result.Value.Status, auth.AccessToken, auth.AccessTokenExpiresAt, auth.User));
return Results.Ok(
new TelegramLoginStatusResponseDto(
result.Value.Status,
auth.AccessToken,
auth.AccessTokenExpiresAt,
auth.User
)
);
}
return Results.Ok(new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null));
return Results.Ok(
new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null)
);
}
}
public sealed record LinkTokenResponseDto(string? DeepLink, DateTimeOffset ExpiresAt);
public sealed record TelegramLoginRequestResponseDto(Guid RequestId, string? DeepLink, DateTimeOffset ExpiresAt);
public sealed record TelegramLoginRequestResponseDto(
Guid RequestId,
string? DeepLink,
DateTimeOffset ExpiresAt
);
public sealed record TelegramLoginStatusResponseDto(
TelegramLoginStatus Status, string? AccessToken, DateTimeOffset? ExpiresAt, CurrentUserDto? User);
TelegramLoginStatus Status,
string? AccessToken,
DateTimeOffset? ExpiresAt,
CurrentUserDto? User
);
@@ -9,68 +9,146 @@ 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)
Guid userId,
Guid configId,
long usedUpBytes,
long usedDownBytes,
CancellationToken cancellationToken
)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
"configTrafficUpdated",
new { configId, usedUpBytes, usedDownBytes },
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)
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);
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)
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);
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)
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);
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);
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("userActivated", new { userId }, cancellationToken);
}
public Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken)
public Task NotifyNewsPublishedAsync(
Guid postId,
string title,
DateTimeOffset createdAt,
CancellationToken cancellationToken
)
{
return hubContext.Clients.All.SendAsync(
"newsPublished",
new { id = postId, title, createdAt },
cancellationToken);
new
{
id = postId,
title,
createdAt,
},
cancellationToken
);
}
public Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken)
public Task NotifyTicketCreatedAsync(
Guid ticketId,
Guid userId,
string userName,
TicketType type,
CancellationToken cancellationToken
)
{
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
"ticketCreated",
new { ticketId, userId, userName, type = type.ToString() },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"ticketCreated",
new
{
ticketId,
userId,
userName,
type = type.ToString(),
},
cancellationToken
);
}
public Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken)
public Task NotifyTicketUpdatedAsync(
Guid ticketId,
Guid userId,
CancellationToken cancellationToken
)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
"ticketUpdated",
new { ticketId },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("ticketUpdated", new { ticketId }, cancellationToken);
}
}
+5 -3
View File
@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" />
<Content
Include="..\..\..\seed\client-apps.json"
Link="seed\client-apps.json"
CopyToOutputDirectory="PreserveNewest"
/>
</ItemGroup>
<ItemGroup>
@@ -27,5 +30,4 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+56 -23
View File
@@ -20,10 +20,13 @@ using Telegram.Bot;
var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
@@ -34,13 +37,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var proxy in builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? [])
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (var network in builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [])
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
@@ -49,6 +64,7 @@ builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddSignalR();
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
@@ -74,17 +90,27 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
if (!string.IsNullOrEmpty(proxyUri.UserInfo))
{
var credentials = proxyUri.UserInfo.Split(':', 2);
proxy.Credentials = new NetworkCredential(credentials[0], credentials.Length > 1 ? credentials[1] : string.Empty);
proxy.Credentials = new NetworkCredential(
credentials[0],
credentials.Length > 1 ? credentials[1] : string.Empty
);
}
sp.GetRequiredService<ILogger<Program>>().LogInformation(
"Telegram bot using proxy {Scheme}://{Host}:{Port}", proxyUri.Scheme, proxyUri.Host, proxyUri.Port);
sp.GetRequiredService<ILogger<Program>>()
.LogInformation(
"Telegram bot using proxy {Scheme}://{Host}:{Port}",
proxyUri.Scheme,
proxyUri.Host,
proxyUri.Port
);
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
return new TelegramBotClient(token, new HttpClient(handler));
});
// Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
builder.Services.AddSingleton<PnvBotUpdateHandler>();
@@ -92,26 +118,32 @@ builder.Services.AddHostedService<TelegramBotHostedService>();
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
{
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.PermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
options.AddFixedWindowLimiter(
RateLimiting.AuthPolicy,
limiterOptions =>
{
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.PermitLimit = builder.Configuration.GetValue(
"RateLimiting:AuthPermitLimit",
20
);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
}
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
@@ -120,8 +152,9 @@ var app = builder.Build();
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
{
app.Logger.LogWarning(
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent " +
"and will be lost when the container is recreated. Mount a volume and set the path in production.");
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent "
+ "and will be lost when the container is recreated. Mount a volume and set the path in production."
);
}
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
@@ -23,24 +23,41 @@ namespace PnvPanel.Api.Telegram;
/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
/// </summary>
public sealed class PnvBotUpdateHandler(
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger)
: IUpdateHandler
IServiceScopeFactory scopeFactory,
IOptions<TelegramOptions> options,
ILogger<PnvBotUpdateHandler> logger
) : IUpdateHandler
{
// Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага,
// иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте.
private const string NotLinkedMessage =
"Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram: Настройки → «Привязать Telegram».";
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
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);
await HandleMessageAsync(
botClient,
scope.ServiceProvider,
message,
text,
cancellationToken
);
else if (update.CallbackQuery is { } callback)
await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken);
await HandleCallbackAsync(
botClient,
scope.ServiceProvider,
callback,
cancellationToken
);
}
catch (Exception ex)
{
@@ -48,14 +65,24 @@ public sealed class PnvBotUpdateHandler(
}
}
public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
public Task HandleErrorAsync(
ITelegramBotClient botClient,
Exception exception,
HandleErrorSource source,
CancellationToken cancellationToken
)
{
logger.LogError(exception, "Telegram bot error (source {Source})", source);
return Task.CompletedTask;
}
private async Task HandleMessageAsync(
ITelegramBotClient botClient, IServiceProvider services, Message message, string text, CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
Message message,
string text,
CancellationToken cancellationToken
)
{
var chatId = message.Chat.Id;
var fromId = message.From?.Id;
@@ -67,11 +94,32 @@ public sealed class PnvBotUpdateHandler(
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);
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);
await HandleLoginPromptAsync(
botClient,
services,
chatId,
fromId.Value,
payload[6..],
cancellationToken
);
else
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
return;
}
@@ -79,25 +127,57 @@ public sealed class PnvBotUpdateHandler(
switch (text)
{
case "/configs":
await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await HandleConfigsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/unlink":
await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await HandleUnlinkAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/requests":
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await HandleRequestsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/help":
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
default:
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
"Не понимаю эту команду. /help — список команд.",
cancellationToken: cancellationToken
);
break;
}
}
private async Task HandleCallbackAsync(
ITelegramBotClient botClient, IServiceProvider services, CallbackQuery callback, CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
CallbackQuery callback,
CancellationToken cancellationToken
)
{
var data = callback.Data;
var chatId = callback.Message?.Chat.Id;
@@ -107,7 +187,15 @@ public sealed class PnvBotUpdateHandler(
if (data == "reg:new")
{
await HandleRegisterCallbackAsync(botClient, services, chatId.Value, fromId, callback.From.Username, callback.Id, cancellationToken);
await HandleRegisterCallbackAsync(
botClient,
services,
chatId.Value,
fromId,
callback.From.Username,
callback.Id,
cancellationToken
);
return;
}
@@ -115,15 +203,28 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(
services,
cancellationToken
);
if (callback.Message is not null)
await botClient.EditMessageText(chatId.Value, callback.Message.Id, configsText, replyMarkup: configsKeyboard, cancellationToken: cancellationToken);
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
configsText,
replyMarkup: configsKeyboard,
cancellationToken: cancellationToken
);
return;
}
@@ -135,9 +236,18 @@ public sealed class PnvBotUpdateHandler(
return;
var identityService = services.GetRequiredService<IIdentityService>();
var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null);
await botClient.EditMessageText(chatId.Value, callback.Message.Id, menuText, replyMarkup: menuKeyboard, cancellationToken: cancellationToken);
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
menuText,
replyMarkup: menuKeyboard,
cancellationToken: cancellationToken
);
return;
}
@@ -146,12 +256,19 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
var unlinkSender = services.GetRequiredService<ISender>();
var unlinkResult = await unlinkSender.Send(new UnlinkTelegramCommand(), cancellationToken);
var unlinkResult = await unlinkSender.Send(
new UnlinkTelegramCommand(),
cancellationToken
);
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
if (callback.Message is null)
@@ -160,15 +277,23 @@ public sealed class PnvBotUpdateHandler(
if (!unlinkResult.IsSuccess)
{
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, $"❌ Ошибка: {unlinkResult.Error.Message}",
replyMarkup: BackToMenuKeyboard(), cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
$"❌ Ошибка: {unlinkResult.Error.Message}",
replyMarkup: BackToMenuKeyboard(),
cancellationToken: cancellationToken
);
return;
}
var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false);
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, "✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
replyMarkup: unlinkedKeyboard, cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
"✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
replyMarkup: unlinkedKeyboard,
cancellationToken: cancellationToken
);
return;
}
@@ -183,11 +308,22 @@ public sealed class PnvBotUpdateHandler(
{
case "login":
{
var result = parts[1] == "approve"
? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken)
: await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken);
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);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
@@ -195,7 +331,12 @@ public sealed class PnvBotUpdateHandler(
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
@@ -204,26 +345,50 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: 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);
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);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
// если его обработали в другом месте — на сайте или из другого чата).
var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.")
? (
parts[1] == "approve"
? "✅ Пользователь активирован."
: "❌ Запрос отклонён."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
@@ -232,23 +397,47 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result = parts[1] == "approve"
? await sender.Send(new ApproveRoleRequestCommand(requestId), cancellationToken)
: await sender.Send(new RejectRoleRequestCommand(requestId, Reason: null), cancellationToken);
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveRoleRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectRoleRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Заявка одобрена, роль выдана." : "❌ Заявка отклонена.")
? (
parts[1] == "approve"
? "✅ Заявка одобрена, роль выдана."
: "❌ Заявка отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
@@ -257,16 +446,30 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken);
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
var linkResult = await sender.Send(
new GetConfigLinkQuery(requestId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
cancellationToken: cancellationToken
);
if (!linkResult.IsSuccess)
{
await botClient.SendMessage(chatId.Value, $"Не удалось получить ссылку: {linkResult.Error.Message}", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId.Value,
$"Не удалось получить ссылку: {linkResult.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
@@ -276,15 +479,22 @@ public sealed class PnvBotUpdateHandler(
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
var text = $"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
var text =
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
.Where(row => row.All(b => b.CallbackData != data))
.ToArray();
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null,
cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0
? new InlineKeyboardMarkup(remainingRows)
: null,
cancellationToken: cancellationToken
);
break;
}
@@ -292,11 +502,20 @@ public sealed class PnvBotUpdateHandler(
}
private async Task HandleLinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token,
CancellationToken cancellationToken)
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 result = await sender.Send(
new LinkTelegramCommand(token, fromId, username),
cancellationToken
);
var text = result.IsSuccess
? "✅ Telegram успешно привязан к вашему аккаунту."
@@ -306,56 +525,103 @@ public sealed class PnvBotUpdateHandler(
}
private async Task HandleLoginPromptAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string requestIdRaw, CancellationToken cancellationToken)
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);
await botClient.SendMessage(
chatId,
"Некорректная ссылка входа.",
cancellationToken: cancellationToken
);
return;
}
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
if (userId is null)
{
await botClient.SendMessage(
chatId, NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
cancellationToken: cancellationToken);
chatId,
NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
return;
}
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
});
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData(
"✅ Подтвердить вход",
$"login:approve:{requestId}"
),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
}
);
await botClient.SendMessage(
chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
replyMarkup: keyboard, cancellationToken: cancellationToken);
chatId,
"Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
private async Task HandleConfigsAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(
chatId, NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
cancellationToken: cancellationToken);
chatId,
NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
return;
}
var (text, keyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
text,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
/// <summary>Список конфигов одним сообщением: строка на конфиг + кнопка «🔗 {Label}» на каждый
/// не отозванный, плюс «🔙 В меню» внизу.</summary>
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync(
IServiceProvider services, CancellationToken cancellationToken)
IServiceProvider services,
CancellationToken cancellationToken
)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
@@ -363,29 +629,53 @@ public sealed class PnvBotUpdateHandler(
if (!result.IsSuccess || result.Value.Configs.Count == 0)
return ("У вас пока нет конфигов.", BackToMenuKeyboard());
var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"));
var text =
"Ваши конфиги:\n"
+ string.Join(
'\n',
result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
)
);
// Отозванному конфигу нечего показывать — кнопку не даём.
var rows = result.Value.Configs
.Where(c => c.Status != ConfigStatus.Revoked)
.Select(c => new[] { InlineKeyboardButton.WithCallbackData($"🔗 {c.Label ?? c.Location}", $"cfg:link:{c.Id}") })
var rows = result
.Value.Configs.Where(c => c.Status != ConfigStatus.Revoked)
.Select(c =>
new[]
{
InlineKeyboardButton.WithCallbackData(
$"🔗 {c.Label ?? c.Location}",
$"cfg:link:{c.Id}"
),
}
)
.Append(BackToMenuRow())
.ToArray();
return (text, new InlineKeyboardMarkup(rows));
}
private static InlineKeyboardButton[] BackToMenuRow() => new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
private static InlineKeyboardButton[] BackToMenuRow() =>
new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
private static InlineKeyboardMarkup BackToMenuKeyboard() => new(new[] { BackToMenuRow() });
private async Task HandleUnlinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
@@ -393,105 +683,191 @@ public sealed class PnvBotUpdateHandler(
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
await botClient.SendMessage(
chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
cancellationToken: cancellationToken);
chatId,
result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
cancellationToken: cancellationToken
);
}
private async Task HandleRequestsAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: 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);
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);
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);
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 async Task SendWelcomeAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
var (text, keyboard) = BuildMainMenu(isLinked: userId is not null);
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
text,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
{
const string text = "Привет! Это бот PnvPanel.\n\n"
const string text =
"Привет! Это бот PnvPanel.\n\n"
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.";
var rows = new List<InlineKeyboardButton[]>();
if (isLinked)
{
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") });
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink") });
rows.Add(
new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
);
rows.Add(
new[]
{
InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink"),
}
);
}
else
{
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") });
rows.Add(
new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }
);
}
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
rows.Add(new[] { InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl) });
rows.Add(
new[]
{
InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl),
}
);
return (text, new InlineKeyboardMarkup(rows));
}
private static async Task HandleRegisterCallbackAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
string? username,
string callbackId,
CancellationToken cancellationToken
)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new RegisterViaTelegramCommand(fromId, username), cancellationToken);
var result = await sender.Send(
new RegisterViaTelegramCommand(fromId, username),
cancellationToken
);
await botClient.AnswerCallbackQuery(callbackId, cancellationToken: cancellationToken);
if (!result.IsSuccess)
{
await botClient.SendMessage(chatId, $"Не удалось зарегистрироваться: {result.Error.Message}", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
$"Не удалось зарегистрироваться: {result.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
var text = "✅ Аккаунт создан.\n\n"
var text =
"✅ Аккаунт создан.\n\n"
+ $"Логин: <code>{result.Value.UserName}</code>\n"
+ $"Пароль: <code>{result.Value.Password}</code>\n\n"
+ "Сохраните пароль — он присылается только один раз. Логин можно сменить в Настройках на сайте.\n\n"
+ "Дальше нужно дождаться активации администратором — после неё будут доступны конфиги. "
+ "Входить можно как по паролю, так и кнопкой «Войти через Telegram».";
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
private static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static string Escape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, 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);
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
telegramUserId,
cancellationToken
);
if (userId is null)
return false;
@@ -503,7 +879,11 @@ public sealed class PnvBotUpdateHandler(
return true;
}
private async Task<bool> TrySetAdminCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
private async Task<bool> TrySetAdminCurrentUserAsync(
IServiceProvider services,
long telegramUserId,
CancellationToken cancellationToken
)
{
if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId))
return false;
@@ -13,9 +13,11 @@ namespace PnvPanel.Api.Telegram;
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
/// </summary>
public sealed class TelegramBotHostedService(
ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger)
: BackgroundService
ITelegramBotClient botClient,
PnvBotUpdateHandler updateHandler,
IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger
) : BackgroundService
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30);
@@ -6,7 +6,10 @@ using Telegram.Bot;
namespace PnvPanel.Api.Telegram;
/// <summary>Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен).</summary>
internal sealed class TelegramBotInfo(ITelegramBotClient botClient, IOptions<TelegramOptions> options) : ITelegramBotInfo
internal sealed class TelegramBotInfo(
ITelegramBotClient botClient,
IOptions<TelegramOptions> options
) : ITelegramBotInfo
{
private readonly SemaphoreSlim _lock = new(1, 1);
private string? _cachedUsername;
@@ -8,30 +8,49 @@ using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram;
internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions<TelegramOptions> options)
: ITelegramNotifier
internal sealed class TelegramNotifier(
ITelegramBotClient botClient,
IIdentityService identityService,
IOptions<TelegramOptions> options
) : ITelegramNotifier
{
public async Task NotifyAdminsActivationRequestedAsync(
Guid requestId, string userName, string? comment, CancellationToken cancellationToken)
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 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}"),
});
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);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -40,20 +59,28 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
}
public async Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken)
public async Task NotifyAdminsBugReportCreatedAsync(
Guid ticketId,
string userName,
string message,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var preview = message.Length > 300 ? message[..300] + "…" : message;
var text = $"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
var text =
$"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
@@ -61,7 +88,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -71,25 +103,38 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
public async Task NotifyAdminsRoleRequestCreatedAsync(
Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken)
Guid ticketId,
string userName,
string roleDescription,
string justification,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text = $"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
var text =
$"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
InlineKeyboardButton.WithCallbackData(" Отклонить", $"rrq:reject:{ticketId}"),
});
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData(" Одобрить", $"rrq:approve:{ticketId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -98,7 +143,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
}
public async Task NotifyAdminsTicketReopenedAsync(Guid ticketId, string userName, TicketType type, CancellationToken cancellationToken)
public async Task NotifyAdminsTicketReopenedAsync(
Guid ticketId,
string userName,
TicketType type,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
@@ -110,7 +160,9 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
@@ -118,7 +170,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -127,7 +184,10 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
}
public async Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken)
public async Task NotifyUsersNewsPublishedAsync(
string title,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
@@ -138,16 +198,25 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/news";
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(cancellationToken);
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(
cancellationToken
);
foreach (var telegramUserId in telegramUserIds)
{
try
{
await botClient.SendMessage(
telegramUserId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
telegramUserId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -156,7 +225,11 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
}
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
public async Task NotifyUserAsync(
Guid userId,
string message,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
@@ -167,11 +240,24 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", options.Value.PublicSiteUrl) });
keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithUrl(
"🌐 Открыть на сайте",
options.Value.PublicSiteUrl
),
}
);
try
{
await botClient.SendMessage(telegramUserId, message, replyMarkup: keyboard, cancellationToken: cancellationToken);
await botClient.SendMessage(
telegramUserId,
message,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -179,5 +265,6 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
}
private static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static string Escape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}