Refactor project files for improved readability and structure
- 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
private static string Escape(string text) =>
|
||||
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
private static string Escape(string text) =>
|
||||
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user