diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs new file mode 100644 index 0000000..2f1b4be --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs @@ -0,0 +1,183 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using GmRelay.DiscordBot.Rendering; +using GmRelay.Shared.Rendering; +using NetCord; +using NetCord.Rest; +using Npgsql; + +public sealed class DiscordRescheduleVotingDeadlineService( + NpgsqlDataSource dataSource, + RescheduleVotingFinalizer finalizer, + RestClient restClient, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await ProcessDueProposals(stoppingToken); + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await ProcessDueProposals(stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + } + + private async Task ProcessDueProposals(CancellationToken ct) + { + try + { + var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); + foreach (var id in proposalIds) + { + await TryFinalizeAsync(id, ct); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process Discord reschedule proposals"); + } + } + + private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct) + { + try + { + var result = await finalizer.FinalizeAsync(proposalId, ct); + if (result is null) + return; + + if (result.SourcePlatform != "Discord") + return; + + // Update Discord vote message + await TryUpdateDiscordVoteMessage(result, ct); + + // If approved, update batch schedule + if (result.SelectedOption is not null) + { + await TryUpdateBatchScheduleAsync(result, ct); + } + + logger.LogInformation( + "Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", + proposalId, result.SessionId, result.Decision.Outcome); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId); + } + } + + private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct) + { + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + var msgRef = await connection.QuerySingleOrDefaultAsync( + """ + SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId + FROM platform_messages + WHERE session_id = @SessionId AND purpose = 'reschedule_vote' AND platform = 'Discord' + ORDER BY created_at DESC + LIMIT 1 + """, + new { result.SessionId }); + + if (msgRef is null) + return; + + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( + result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt, + result.Options, result.Participants, result.Votes); + + var channelId = ulong.Parse(msgRef.ExternalChannelId); + var messageId = ulong.Parse(msgRef.ExternalMessageId); + + // Disable buttons after finalization + var disabledRow = new ActionRowProperties(); + foreach (var btn in actionRow.OfType()) + { + disabledRow.Add(new ButtonProperties(btn.CustomId, btn.Label ?? string.Empty, ButtonStyle.Secondary) { Disabled = true }); + } + + var resultText = result.SelectedOption is not null + ? $"Голосование завершено. Победил вариант {result.SelectedOption.DisplayOrder}: **{result.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)." + : $"Голосование завершено. {result.Decision.Reason}"; + + var updatedEmbed = embed.WithDescription($"{embed.Description}\n\n{resultText}"); + + await restClient.ModifyMessageAsync(channelId, messageId, options => + { + options.Embeds = new[] { updatedEmbed }; + options.Components = new[] { disabledRow }; + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId); + } + } + + private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct) + { + try + { + // Query batch schedule message ref + await using var connection = await dataSource.OpenConnectionAsync(ct); + var batchRef = await connection.QuerySingleOrDefaultAsync( + """ + SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId + FROM platform_messages + WHERE batch_id = @BatchId AND purpose = 'schedule' AND platform = 'Discord' + ORDER BY created_at DESC + LIMIT 1 + """, + new { result.BatchId }); + + if (batchRef is null) + return; + + // Rebuild schedule view and update Discord message + var sessions = (await connection.QueryAsync( + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + new { result.BatchId })).ToList(); + + var participants = (await connection.QueryAsync( + """ + SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, sp.registration_status AS RegistrationStatus + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + JOIN sessions s ON sp.session_id = s.id + WHERE s.batch_id = @BatchId AND sp.is_gm = false + ORDER BY sp.registration_status ASC, sp.created_at ASC + """, + new { result.BatchId })).ToList(); + + var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants); + var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); + + var channelId = ulong.Parse(batchRef.ExternalChannelId); + var messageId = ulong.Parse(batchRef.ExternalMessageId); + + await restClient.ModifyMessageAsync(channelId, messageId, options => + { + options.Embeds = embeds; + options.Components = actionRows; + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId); + } + } + + internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId); +} diff --git a/src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs b/src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs new file mode 100644 index 0000000..4dc286a --- /dev/null +++ b/src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs @@ -0,0 +1,6 @@ +namespace GmRelay.DiscordBot.Infrastructure; + +public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 14817c8..88e07ed 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -1,8 +1,10 @@ using GmRelay.DiscordBot; using GmRelay.DiscordBot.Features.Sessions; +using GmRelay.DiscordBot.Infrastructure; using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Logging; using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -51,6 +53,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services .AddDiscordGateway(options =>