fix(discord): update sessions via interactions
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordDeleteSessionResult(
|
||||
string ReplyText,
|
||||
SessionBatchViewModel? UpdatedView,
|
||||
string? EmptyMessage = null);
|
||||
|
||||
public sealed class DiscordDeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
DiscordListSessionsHandler listSessionsHandler,
|
||||
ILogger<DiscordDeleteSessionHandler> logger)
|
||||
{
|
||||
public async Task<DiscordDeleteSessionResult> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
Guid sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Только owner, администратор или manager могут удалять сессии.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
var deletedRows = await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM sessions s
|
||||
USING game_groups g
|
||||
WHERE s.group_id = g.id
|
||||
AND s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
if (deletedRows == 0)
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Сессия не найдена или уже удалена.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var updatedView = await listSessionsHandler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
userId,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
cancellationToken);
|
||||
|
||||
return updatedView is null
|
||||
? new DiscordDeleteSessionResult(
|
||||
"Сессия удалена.",
|
||||
UpdatedView: null,
|
||||
EmptyMessage: "В этом сервере нет предстоящих игр.")
|
||||
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
@@ -18,8 +19,17 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
|
||||
var guildId = Context.Interaction.GuildId?.ToString()
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
var channelId = Context.Channel.Id.ToString();
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
var guildOwnerId = 0UL;
|
||||
|
||||
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
|
||||
var view = await _handler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
Context.User.Id,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
CancellationToken.None);
|
||||
|
||||
if (view is null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
|
||||
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
||||
int PlayerCount, int WaitlistCount);
|
||||
|
||||
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
public sealed class DiscordListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker)
|
||||
{
|
||||
public Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
CancellationToken cancellationToken) =>
|
||||
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
|
||||
|
||||
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
@@ -44,6 +56,20 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
if (sessionList.Count == 0)
|
||||
return null;
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
var canManage = permissionChecker.CanManageSchedule(
|
||||
guildOwnerId,
|
||||
userId,
|
||||
dbManagerUserIds,
|
||||
resolvedPermissions);
|
||||
|
||||
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
||||
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id as SessionId,
|
||||
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
||||
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
||||
|
||||
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||
return canManage ? AddManagerActions(view) : view;
|
||||
}
|
||||
|
||||
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
|
||||
view with
|
||||
{
|
||||
Sessions = view.Sessions
|
||||
.Select(session =>
|
||||
{
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
return session;
|
||||
|
||||
var actions = session.AvailableActions
|
||||
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
|
||||
.ToList();
|
||||
|
||||
return session with { AvailableActions = actions };
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
HandleRsvpHandler rsvpHandler,
|
||||
DiscordDeleteSessionHandler deleteSessionHandler,
|
||||
DiscordRescheduleVoteHandler voteHandler,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
await joinSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateJoinCommand(input),
|
||||
result = await joinSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("leave_session")]
|
||||
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
await leaveSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
|
||||
result = await leaveSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("delete_session")]
|
||||
public async Task DeleteAsync(string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
try
|
||||
{
|
||||
var result = await deleteSessionHandler.HandleAsync(
|
||||
guildId: input.GuildId,
|
||||
channelId: input.ChannelId,
|
||||
userId: input.UserId,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: 0,
|
||||
sessionId: parsedSessionId,
|
||||
CancellationToken.None);
|
||||
|
||||
await CompleteDeleteResponseAsync(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
|
||||
await FollowupEphemeralAsync("Не удалось удалить сессию.");
|
||||
}
|
||||
}
|
||||
|
||||
[ComponentInteraction("rsvp")]
|
||||
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
|
||||
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
||||
}
|
||||
|
||||
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
|
||||
{
|
||||
var updatedView = result.UpdatedView;
|
||||
if (updatedView is not null && SourceMessageHasDeleteAction())
|
||||
{
|
||||
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
|
||||
}
|
||||
|
||||
if (updatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
|
||||
var reply = interactionReplies.Take(interactionId);
|
||||
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
|
||||
}
|
||||
|
||||
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
|
||||
{
|
||||
if (result.UpdatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
else if (result.EmptyMessage is not null)
|
||||
{
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Content = result.EmptyMessage;
|
||||
options.Embeds = [];
|
||||
options.Components = [];
|
||||
});
|
||||
}
|
||||
|
||||
await FollowupEphemeralAsync(result.ReplyText);
|
||||
}
|
||||
|
||||
private Task CompleteResponseAsync(string text) =>
|
||||
ModifyResponseAsync(options => options.Content = text);
|
||||
|
||||
private Task FollowupEphemeralAsync(string text) =>
|
||||
FollowupAsync(new InteractionMessageProperties()
|
||||
.WithContent(text)
|
||||
.WithFlags(MessageFlags.Ephemeral));
|
||||
|
||||
private bool SourceMessageHasDeleteAction() =>
|
||||
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
|
||||
|
||||
private static bool ComponentContainsDeleteAction(object? component)
|
||||
{
|
||||
if (component is null)
|
||||
return false;
|
||||
|
||||
if (component is IInteractiveComponent interactive
|
||||
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
|
||||
if (nestedComponents is null)
|
||||
return false;
|
||||
|
||||
foreach (var nestedComponent in nestedComponents)
|
||||
{
|
||||
if (ComponentContainsDeleteAction(nestedComponent))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
||||
InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
|
||||
@@ -56,6 +56,7 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
|
||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||
|
||||
Reference in New Issue
Block a user