using Dapper; using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Notifications; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Npgsql; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; internal sealed record TelegramProposalFieldsDto( int? VoteMessageId, int? BatchMessageId, long TelegramChatId, int? ThreadId); public sealed class RescheduleVotingDeadlineService( NpgsqlDataSource dataSource, IPlatformMessenger messenger, PlatformDirectNotificationSender directSender, RescheduleVotingFinalizer finalizer, 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("Telegram", ct); foreach (var proposalId in proposalIds) { await FinalizeProposal(proposalId, ct); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { } catch (Exception ex) { logger.LogError(ex, "Failed to process due reschedule voting proposals"); } } private async Task FinalizeProposal(Guid proposalId, CancellationToken ct) { var result = await finalizer.FinalizeAsync(proposalId, ct); if (result is null) return; if (result.SourcePlatform != "Telegram") { logger.LogInformation( "Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}", proposalId, result.SourcePlatform); return; } await using var connection = await dataSource.OpenConnectionAsync(ct); var telegramFields = await connection.QuerySingleOrDefaultAsync( """ SELECT rp.vote_message_id AS VoteMessageId, s.batch_message_id AS BatchMessageId, g.external_group_id::BIGINT AS TelegramChatId, s.thread_id AS ThreadId FROM reschedule_proposals rp JOIN sessions s ON s.id = rp.session_id JOIN game_groups g ON g.id = s.group_id WHERE rp.id = @ProposalId """, new { ProposalId = proposalId }); if (telegramFields is null) { logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId); return; } var directRecipients = result.Participants .Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName)) .ToList(); await TryUpdateVoteMessage(result, telegramFields, ct); if (result.SelectedOption is not null) { await TryUpdateBatchMessage(result, telegramFields, ct); } var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode); if (mode.ShouldSendDirectMessages()) { await SendDirectResult(result, directRecipients, ct); } logger.LogInformation( "Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}", result.ProposalId, result.SessionId); } private async Task TryUpdateVoteMessage( RescheduleVotingFinalizerResult result, TelegramProposalFieldsDto telegramFields, CancellationToken ct) { if (telegramFields.VoteMessageId is null) return; try { await messenger.UpdateRescheduleVoteAsync( new PlatformRescheduleVoteUpdate( TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), TelegramPlatformIds.Message( telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.VoteMessageId.Value), 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 finalized reschedule vote message for proposal {ProposalId}", result.ProposalId); } } private async Task TryUpdateBatchMessage( RescheduleVotingFinalizerResult result, TelegramProposalFieldsDto telegramFields, CancellationToken ct) { try { await using var connection = await dataSource.OpenConnectionAsync(ct); var batchSessions = (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 batchParticipants = (await connection.QueryAsync( """ SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, sp.registration_status AS RegistrationStatus FROM session_participants sp JOIN players p ON sp.player_id = p.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, sp.responded_at ASC, p.created_at ASC """, new { result.BatchId })).ToList(); if (telegramFields.BatchMessageId.HasValue) { var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants); await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), view, TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)), ct); } else { await messenger.SendGroupMessageAsync( TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), $"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".", ct); } } catch (Exception ex) { logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId); } } private async Task SendDirectResult( RescheduleVotingFinalizerResult result, IReadOnlyList recipients, CancellationToken ct) { await directSender.SendAsync( result.SelectedOption is not null ? PlatformDirectSessionNotificationKind.RescheduleApproved : PlatformDirectSessionNotificationKind.RescheduleRejected, recipients, result.SessionId, result.Title, result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt, joinLink: null, actorDisplayName: null, reason: result.SelectedOption is null ? result.Decision.Reason : null, ct); } }