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; using NetCord.Services.ComponentInteractions; namespace GmRelay.DiscordBot.Features.Sessions; public sealed class DiscordSessionInteractionModule( JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, HandleRsvpHandler rsvpHandler, DiscordDeleteSessionHandler deleteSessionHandler, DiscordRescheduleVoteHandler voteHandler, DiscordInteractionReplyCache interactionReplies, ILogger logger) : ComponentInteractionModule { [ComponentInteraction("join_session")] public async Task JoinAsync(string sessionId) { if (!Guid.TryParse(sessionId, out var parsedSessionId)) { await RespondAsync(CreateEphemeralReply("Session button is outdated.")); return; } var input = CreateInput(parsedSessionId); await RespondAsync(InteractionCallback.DeferredModifyMessage); SessionInteractionResult result; try { 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 FollowupEphemeralAsync("Не удалось обработать кнопку."); return; } await CompleteScheduleUpdateResponseAsync(input.InteractionId, result); } [ComponentInteraction("leave_session")] public async Task LeaveAsync(string sessionId) { if (!Guid.TryParse(sessionId, out var parsedSessionId)) { await RespondAsync(CreateEphemeralReply("Session button is outdated.")); return; } var input = CreateInput(parsedSessionId); await RespondAsync(InteractionCallback.DeferredModifyMessage); SessionInteractionResult result; try { 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 FollowupEphemeralAsync("Не удалось обработать кнопку."); return; } 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")] public async Task RsvpAsync(string status, string sessionId) { if (!Guid.TryParse(sessionId, out var parsedSessionId)) { await RespondAsync(CreateEphemeralReply("Session button is outdated.")); return; } var rsvpStatus = status switch { "confirm" => RsvpStatus.Confirmed, "decline" => RsvpStatus.Declined, _ => null }; if (rsvpStatus is null) { await RespondAsync(CreateEphemeralReply("Session button is outdated.")); return; } var input = CreateInput(parsedSessionId); await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); try { await rsvpHandler.HandleAsync( new HandleRsvpCommand( parsedSessionId, new PlatformUser( PlatformKind.Discord, Context.User.Id.ToString(CultureInfo.InvariantCulture), string.IsNullOrWhiteSpace(Context.User.GlobalName) ? Context.User.Username : Context.User.GlobalName, Context.User.Username), rsvpStatus, input.InteractionId, new PlatformGroup( PlatformKind.Discord, input.GuildId, input.GuildId, input.ChannelId), new PlatformMessageRef( PlatformKind.Discord, input.GuildId, null, input.MessageId)), CancellationToken.None); } catch (Exception ex) { logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId); await CompleteResponseAsync("Не удалось обработать кнопку."); return; } await CompleteWithStoredReplyAsync(input.InteractionId); } [ComponentInteraction("reschedule_vote")] public async Task RescheduleVoteAsync(string optionId) { if (!Guid.TryParse(optionId, out var parsedOptionId)) { await RespondAsync(CreateEphemeralReply("Vote button is outdated.")); return; } var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing var voteInput = new DiscordRescheduleVoteInput( parsedOptionId, Context.User.Id, Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), input.GuildId, input.ChannelId, input.MessageId); await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); string replyText; try { replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None); } catch (Exception ex) { logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId); await CompleteResponseAsync("Не удалось обработать голос."); return; } await CompleteResponseAsync(replyText); } private DiscordSessionInteractionInput CreateInput(Guid sessionId) { var guildId = Context.Interaction.GuildId?.ToString(CultureInfo.InvariantCulture) ?? throw new InvalidOperationException("Session buttons can only be used in a guild."); var message = Context.Interaction.Message ?? throw new InvalidOperationException("Session button interaction must include a message."); return new DiscordSessionInteractionInput( SessionId: sessionId, InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), GuildId: guildId, ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture), MessageId: message.Id.ToString(CultureInfo.InvariantCulture), UserId: Context.User.Id, Username: Context.User.Username, DisplayName: Context.User.GlobalName); } private async Task CompleteWithStoredReplyAsync(string interactionId) { var reply = interactionReplies.Take(interactionId); 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() .WithContent(text) .WithFlags(MessageFlags.Ephemeral)); }