using Dapper; using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; // ── DTOs ───────────────────────────────────────────────────────────── internal sealed record AwaitingProposalDto( Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt, Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode); internal sealed record VoteParticipantDto( Guid PlayerId, string DisplayName, string? TelegramUsername, long TelegramId = 0); // ── Handler ────────────────────────────────────────────────────────── /// /// Handles text input from the GM who has an AwaitingTime proposal. /// Parses reschedule options with a voting deadline, creates a voting message, /// and tags all participants. /// If no participants are registered, reschedules immediately. /// public sealed class HandleRescheduleTimeInputHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, DirectSessionNotificationSender directSender, ILogger logger) { /// /// Attempts to handle a text message as reschedule time input. /// Returns true if it was handled (i.e. user had an AwaitingTime proposal). /// public async Task TryHandleAsync(Message message, CancellationToken ct) { if (message.From is null || string.IsNullOrWhiteSpace(message.Text)) return false; var gmTelegramId = message.From.Id; var chatId = message.Chat.Id; var text = message.Text.Trim(); await using var connection = await dataSource.OpenConnectionAsync(ct); // 1. Check if this GM has an AwaitingTime proposal in this chat var proposal = await connection.QuerySingleOrDefaultAsync( """ SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.notification_mode AS NotificationMode 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.proposed_by = @GmId AND rp.status = 'AwaitingTime' AND g.telegram_chat_id = @ChatId AND EXISTS ( SELECT 1 FROM group_managers gm JOIN players manager_player ON manager_player.id = gm.player_id WHERE gm.group_id = s.group_id AND manager_player.telegram_id = @GmId ) ORDER BY rp.created_at DESC LIMIT 1 """, new { GmId = gmTelegramId, ChatId = chatId }); if (proposal is null) return false; // 2. Parse voting input if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError)) { await bot.SendMessage( chatId: chatId, text: $"⚠️ {parseError}\n\nИспользуйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); return true; } // 3. Load participants (non-GM) signed up for this session var participants = (await connection.QueryAsync( """ SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, p.telegram_id AS TelegramId FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active """, new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); // 4. If no participants — reschedule immediately if (participants.Count == 0) { await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct); await TryDeleteMessage(chatId, message.MessageId, ct); return true; } // 5. Create voting message await using var transaction = await connection.BeginTransactionAsync(ct); var options = votingInput.Options .Select((proposedAt, index) => new RescheduleOptionDto( Guid.NewGuid(), index + 1, proposedAt)) .ToList(); await connection.ExecuteAsync( """ UPDATE reschedule_proposals SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId WHERE id = @Id """, new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id }, transaction); foreach (var option in options) { await connection.ExecuteAsync( """ INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) """, new { option.OptionId, ProposalId = proposal.Id, option.ProposedAt, option.DisplayOrder }, transaction); } await transaction.CommitAsync(ct); var voteText = BuildVotingMessage( proposal.Title, proposal.CurrentScheduledAt, votingInput.Deadline, options, participants, []); var keyboard = BuildVotingKeyboard(options); var voteMsg = await bot.SendMessage( chatId: chatId, text: voteText, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: keyboard, cancellationToken: ct); var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); if (mode.ShouldSendDirectMessages()) { var optionsText = string.Join( "\n", options.Select(option => $"{option.DisplayOrder}. {option.ProposedAt.FormatMoscow()} (МСК)")); var directText = $""" 🔄 Голосование за перенос сессии 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} 📅 Текущее время: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) 🗳 Варианты: {optionsText} ⏳ Дедлайн: {votingInput.Deadline.FormatMoscow()} (МСК) Проголосуйте кнопкой в групповом сообщении. """; await directSender.SendAsync( participants.Select(p => new DirectNotificationRecipient( p.TelegramId, p.DisplayName)), directText, "reschedule-vote", proposal.SessionId, ct); } // Store vote message ID await connection.ExecuteAsync( "UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id", new { MsgId = voteMsg.MessageId, Id = proposal.Id }); logger.LogInformation( "Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}", proposal.SessionId, proposal.Id, options.Count, votingInput.Deadline); // Delete GM's time input message await TryDeleteMessage(chatId, message.MessageId, ct); return true; } private async Task RescheduleImmediately( NpgsqlConnection connection, AwaitingProposalDto proposal, DateTimeOffset newTime, long chatId, CancellationToken ct) { await using var transaction = await connection.BeginTransactionAsync(ct); await connection.ExecuteAsync( """ UPDATE sessions SET scheduled_at = @NewTime, status = @Status, one_hour_reminder_processed_at = NULL, updated_at = now() WHERE id = @SessionId """, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned }, transaction); await connection.ExecuteAsync( "UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id", new { NewTime = newTime, Id = proposal.Id }, transaction); await transaction.CommitAsync(ct); await bot.SendMessage( chatId: chatId, text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); // Re-render batch message with updated time await TryUpdateBatchMessage(proposal, ct); logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId); } internal static string BuildVotingMessage( string title, DateTime currentTime, DateTimeOffset deadline, IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes) { var votesByOption = votes .GroupBy(v => v.OptionId) .ToDictionary(g => g.Key, g => g.ToList()); var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet(); var pendingParticipants = participants .Where(p => !votedPlayerIds.Contains(p.PlayerId)) .Select(FormatParticipantName) .ToList(); var lines = new List { $"🔄 Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»", "", $"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)", $"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)", "", "Выберите один из вариантов:" }; foreach (var option in options.OrderBy(x => x.DisplayOrder)) { var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []); lines.Add( $"{option.DisplayOrder}. {option.ProposedAt.FormatMoscow()} (МСК) — {FormatVoteCount(optionVotes.Count)}"); if (optionVotes.Count > 0) { lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}"); } } if (pendingParticipants.Count > 0) { lines.Add(""); lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}"); } lines.Add(""); lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}"); lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется."); return string.Join("\n", lines); } internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList options) { return new InlineKeyboardMarkup( options .OrderBy(option => option.DisplayOrder) .Select(option => new[] { InlineKeyboardButton.WithCallbackData( $"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}", $"reschedule_vote:{option.OptionId}") })); } internal static string FormatParticipantName(VoteParticipantDto participant) { return participant.TelegramUsername is { Length: > 0 } username ? $"@{System.Net.WebUtility.HtmlEncode(username)}" : System.Net.WebUtility.HtmlEncode(participant.DisplayName); } internal static string FormatParticipantName(RescheduleOptionVoteDto vote) { return vote.TelegramUsername is { Length: > 0 } username ? $"@{System.Net.WebUtility.HtmlEncode(username)}" : System.Net.WebUtility.HtmlEncode(vote.DisplayName); } private static string FormatVoteCount(int count) { var modulo100 = count % 100; var modulo10 = count % 10; var word = modulo100 is >= 11 and <= 14 ? "голосов" : modulo10 switch { 1 => "голос", >= 2 and <= 4 => "голоса", _ => "голосов" }; return $"{count} {word}"; } private static string FormatButtonTime(DateTimeOffset utc) => utc.ToOffset(TimeSpan.FromHours(3)).ToString( "dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture); private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct) { try { await using var conn = await dataSource.OpenConnectionAsync(ct); var batchSessions = (await conn.QueryAsync( "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { proposal.BatchId })).ToList(); var batchParticipants = (await conn.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 { proposal.BatchId })).ToList(); if (proposal.BatchMessageId.HasValue) { var renderResult = SessionBatchRenderer.Render( proposal.Title, batchSessions, batchParticipants); await bot.EditMessageText( chatId: proposal.TelegramChatId, messageId: proposal.BatchMessageId.Value, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup, cancellationToken: ct); } else { logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId); } } catch (Exception ex) { logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId); } } private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct) { try { await bot.DeleteMessage(chatId, messageId, cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to delete message {MessageId} in chat {ChatId}", messageId, chatId); } } }