diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index a57ae68..1e5d356 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -14,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand( long ChatId, int MessageId); -internal sealed record VoteProposalDto( - Guid Id, - Guid SessionId, - DateTimeOffset VotingDeadlineAt, - string Title, - DateTime CurrentScheduledAt); - public sealed class HandleRescheduleVoteHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs index 9ace0e2..c31de08 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs @@ -149,7 +149,8 @@ public sealed class DiscordRescheduleHandler( return new DiscordRescheduleResult(proposalId, optionDtos, deadline); } - private static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( + // internal for now — temporary duplication until DiscordRescheduleVotingRenderer is extracted in Task 6 + internal static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( string title, DateTime currentTime, DateTimeOffset deadline, IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes) { diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs new file mode 100644 index 0000000..95390ee --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs @@ -0,0 +1,130 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using Npgsql; +using NetCord.Rest; + +public sealed record DiscordRescheduleVoteInput( + Guid OptionId, ulong UserId, string InteractionId, + string GuildId, string ChannelId, string MessageId); + +public sealed class DiscordRescheduleVoteHandler( + NpgsqlDataSource dataSource, + RestClient restClient, + ILogger logger) +{ + public async Task HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + // 1. Load proposal + option + var proposal = await connection.QuerySingleOrDefaultAsync( + """ + SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt, + s.title AS Title, s.scheduled_at AS CurrentScheduledAt + FROM reschedule_options ro + JOIN reschedule_proposals rp ON rp.id = ro.proposal_id + JOIN sessions s ON s.id = rp.session_id + WHERE ro.id = @OptionId AND rp.status = 'Voting' + """, + new { input.OptionId }, + transaction); + + if (proposal is null) + return "Голосование уже завершено или не найдено."; + + if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) + return "Дедлайн уже прошёл. Результаты скоро будут применены."; + + // 2. Verify participant (Discord platform) + var playerId = await connection.ExecuteScalarAsync( + """ + SELECT p.id + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND p.platform = 'Discord' + AND p.external_user_id = @UserId + AND sp.is_gm = false + AND sp.registration_status = @Active + """, + new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active }, + transaction); + + if (playerId is null) + return "Вы не являетесь участником этой сессии."; + + // 3. Upsert vote + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id) + VALUES (@ProposalId, @PlayerId, @OptionId) + ON CONFLICT (proposal_id, player_id) DO UPDATE + SET option_id = EXCLUDED.option_id, voted_at = now() + """, + new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId }, + transaction); + + // 4. Reload participants, options, votes for re-rendering + var participants = (await connection.QueryAsync( + """ + SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active + ORDER BY p.display_name + """, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, + transaction)).ToList(); + + var options = (await connection.QueryAsync( + """ + SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt + FROM reschedule_options + WHERE proposal_id = @ProposalId + ORDER BY display_order + """, + new { ProposalId = proposal.Id }, + transaction)).ToList(); + + var votes = (await connection.QueryAsync( + """ + SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername + FROM reschedule_option_votes rov + JOIN players p ON p.id = rov.player_id + WHERE rov.proposal_id = @ProposalId + ORDER BY rov.voted_at, p.display_name + """, + new { ProposalId = proposal.Id }, + transaction)).ToList(); + + await transaction.CommitAsync(ct); + + // 5. Re-render and update Discord vote message + var (embed, actionRow) = DiscordRescheduleHandler.BuildVoteMessage( + proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, + options, participants, votes); + + var channelIdUlong = ulong.Parse(input.ChannelId); + var messageIdUlong = ulong.Parse(input.MessageId); + + try + { + await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options => + { + options.Embeds = new[] { embed }; + options.Components = new[] { actionRow }; + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id); + } + + return "Ваш голос учтён. До дедлайна его можно изменить."; + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs index 11f46d1..0d15f18 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs @@ -9,6 +9,7 @@ namespace GmRelay.DiscordBot.Features.Sessions; public sealed class DiscordSessionInteractionModule( JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, + DiscordRescheduleVoteHandler voteHandler, DiscordInteractionReplyCache interactionReplies, ILogger logger) : ComponentInteractionModule { @@ -66,6 +67,41 @@ public sealed class DiscordSessionInteractionModule( 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 guild = Context.Guild diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 1e7f15a..14817c8 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs index 1e31194..711608f 100644 --- a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs @@ -5,6 +5,13 @@ public sealed record RescheduleOptionDto( int DisplayOrder, DateTimeOffset ProposedAt); +public sealed record VoteProposalDto( + Guid Id, + Guid SessionId, + DateTimeOffset VotingDeadlineAt, + string Title, + DateTime CurrentScheduledAt); + public sealed record RescheduleOptionVoteDto( Guid OptionId, Guid PlayerId,