using Dapper; 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); internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername); // ── Handler ────────────────────────────────────────────────────────── /// /// Handles text input from the GM who has an AwaitingTime proposal. /// Parses the new time, creates a voting message, and tags all participants. /// If no participants are registered, reschedules immediately. /// public sealed class HandleRescheduleTimeInputHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, 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 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 ORDER BY rp.created_at DESC LIMIT 1 """, new { GmId = gmTelegramId, ChatId = chatId }); if (proposal is null) return false; // 2. Parse the new time if (!MoscowTime.TryParseMoscow(text, out var newTime)) { await bot.SendMessage( chatId: chatId, text: "⚠️ Не удалось распознать время. Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ\nНапример: 25.04.2026 19:30", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); return true; } if (newTime <= DateTimeOffset.UtcNow) { await bot.SendMessage( chatId: chatId, text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.", 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 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, newTime, chatId, ct); await TryDeleteMessage(chatId, message.MessageId, ct); return true; } // 5. Create voting message await using var transaction = await connection.BeginTransactionAsync(ct); // Update proposal with proposed time and Voting status await connection.ExecuteAsync( """ UPDATE reschedule_proposals SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId WHERE id = @Id """, new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id }, transaction); await transaction.CommitAsync(ct); // Build voting message text var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []); var keyboard = new InlineKeyboardMarkup([ [ InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"), InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}") ] ]); var voteMsg = await bot.SendMessage( chatId: chatId, text: voteText, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: keyboard, cancellationToken: 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}", proposal.SessionId, proposal.Id); // 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, 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 newTime, IReadOnlyList participants, IReadOnlyCollection approvedPlayerIds) { var lines = new List { $"🔄 Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»", "", $"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)", $"📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)", "", "Для переноса нужно согласие всех участников:" }; foreach (var p in participants) { var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳"; lines.Add($" {icon} {name}"); } lines.Add(""); lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count} ✅"); return string.Join("\n", lines); } 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); } } }