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("Discord", 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); }