namespace GmRelay.DiscordBot.Features.Sessions; using Dapper; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Npgsql; public sealed class DiscordRescheduleVotingDeadlineService( NpgsqlDataSource dataSource, RescheduleVotingFinalizer finalizer, IPlatformMessenger messenger, 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; await TryUpdateDiscordVoteMessage(result, ct); 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 g.external_group_id AS ExternalGroupId, COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId, pm.external_message_id AS ExternalMessageId FROM platform_messages pm JOIN game_groups g ON g.id = pm.group_id WHERE pm.session_id = @SessionId AND pm.purpose = 'reschedule_vote' AND pm.platform = 'Discord' ORDER BY pm.created_at DESC LIMIT 1 """, new { result.SessionId }); if (msgRef is null) return; var group = CreateDiscordGroup(msgRef); await messenger.UpdateRescheduleVoteAsync( new PlatformRescheduleVoteUpdate( group, new PlatformMessageRef( PlatformKind.Discord, msgRef.ExternalGroupId, null, msgRef.ExternalMessageId), result.ProposalId, result.SessionId, result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt, result.Decision, result.SelectedOption, result.Options, result.Votes, result.Participants), ct); } 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 { await using var connection = await dataSource.OpenConnectionAsync(ct); var batchRef = await connection.QuerySingleOrDefaultAsync( """ SELECT g.external_group_id AS ExternalGroupId, COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId, pm.external_message_id AS ExternalMessageId FROM platform_messages pm JOIN game_groups g ON g.id = pm.group_id WHERE pm.batch_id = @BatchId AND pm.purpose = 'schedule' AND pm.platform = 'Discord' ORDER BY pm.created_at DESC LIMIT 1 """, new { result.BatchId }); if (batchRef is null) return; 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 group = CreateDiscordGroup(batchRef); await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( group, view, new PlatformMessageRef( PlatformKind.Discord, batchRef.ExternalGroupId, null, batchRef.ExternalMessageId)), ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId); } } private static PlatformGroup CreateDiscordGroup(PlatformMessageRefDto message) => new( PlatformKind.Discord, message.ExternalGroupId, message.ExternalGroupId, message.ExternalChannelId); internal sealed record PlatformMessageRefDto( string ExternalGroupId, string ExternalChannelId, string ExternalMessageId); }