using Dapper; using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.Enums; using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; internal sealed record TelegramProposalFieldsDto( int? VoteMessageId, int? BatchMessageId, long TelegramChatId, int? ThreadId); public sealed class RescheduleVotingDeadlineService( NpgsqlDataSource dataSource, ITelegramBotClient bot, IPlatformMessenger messenger, DirectSessionNotificationSender 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.telegram_chat_id 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 => new DirectNotificationRecipient(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 { var resultText = result.SelectedOption is not null ? $"✅ Голосование завершено.\nПобедил вариант {result.SelectedOption.DisplayOrder}: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК)." : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}"; var text = $""" {HandleRescheduleTimeInputHandler.BuildVotingMessage( result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt, result.Options, result.Participants, result.Votes)} {resultText} """; await bot.EditMessageText( chatId: telegramFields.TelegramChatId, messageId: telegramFields.VoteMessageId.Value, text: text, parseMode: ParseMode.Html, cancellationToken: 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.telegram_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) { var htmlText = result.SelectedOption is not null ? $""" ✅ Сессия перенесена по итогам голосования 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} 📅 Новое время: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК) """ : $""" ❌ Перенос сессии отклонён по итогам голосования 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} 📅 Время остаётся прежним: {result.CurrentScheduledAt.FormatMoscow()} (МСК) Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)} """; await directSender.SendAsync( recipients, htmlText, result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", result.SessionId, ct); } }