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; using Telegram.Bot.Types.ReplyMarkups; using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; /// /// Telegram adapter for reschedule time input. /// Delegates core logic to the shared handler, then performs Telegram-specific /// message sending, DM notifications, vote_message_id storage, and cleanup. /// public sealed class HandleRescheduleTimeInputHandler( GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler, NpgsqlDataSource dataSource, ITelegramBotClient bot, IPlatformMessenger messenger, DirectSessionNotificationSender directSender, ILogger logger) { public async Task TryHandleAsync(Message message, CancellationToken ct) { if (message.From is null || string.IsNullOrWhiteSpace(message.Text)) return false; var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand( new PlatformUser( PlatformKind.Telegram, message.From.Id.ToString(), message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"), message.From.Username), TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title), message.Text.Trim()); var result = await sharedHandler.HandleAsync(command, ct); if (!result.Handled) return false; if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately) { await messenger.SendGroupMessageAsync( command.Group, $"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00""", ct); return true; } if (result.IsRescheduledImmediately) { if (result.UpdatedView is not null && result.BatchMessageId.HasValue) { await TryUpdateBatchMessage( command.Group, result.UpdatedView, TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value), ct); } await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct); await TryDeleteMessage(message.Chat.Id, message.MessageId, ct); return true; } // Voting mode var voteText = BuildVotingMessage( result.Title!, result.CurrentScheduledAt, result.VotingDeadlineAt!.Value, result.Options, result.Participants, []); var keyboard = BuildVotingKeyboard(result.Options); var voteMsg = await bot.SendMessage( chatId: message.Chat.Id, messageThreadId: message.MessageThreadId, text: voteText, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: keyboard, cancellationToken: ct); var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct); if (mode.ShouldSendDirectMessages()) { var optionsText = string.Join( "\n", result.Options.Select(option => $"{option.DisplayOrder}. {option.ProposedAt.FormatMoscow()} (МСК)")); var directText = $""" 🔄 Голосование за перенос сессии 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} 📅 Текущее время: {result.CurrentScheduledAt.FormatMoscow()} (МСК) 🗳 Варианты: {optionsText} ⏳ Дедлайн: {result.VotingDeadlineAt.Value.FormatMoscow()} (МСК) Проголосуйте кнопкой в групповом сообщении. """; await directSender.SendAsync( result.Participants.Select(p => new DirectNotificationRecipient( p.TelegramId, p.DisplayName)), directText, "reschedule-vote", result.ProposalId.Value, ct); } await using var connection = await dataSource.OpenConnectionAsync(ct); await connection.ExecuteAsync( "UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id", new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value }); logger.LogInformation( "Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}", result.ProposalId.Value, result.ProposalId.Value, result.Options.Count, result.VotingDeadlineAt.Value); await TryDeleteMessage(message.Chat.Id, message.MessageId, ct); return true; } private async Task GetNotificationModeAsync(Guid proposalId, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); var raw = await connection.QuerySingleOrDefaultAsync( """ SELECT s.notification_mode FROM sessions s JOIN reschedule_proposals rp ON rp.session_id = s.id WHERE rp.id = @Id """, new { Id = proposalId }); return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty); } private async Task TryUpdateBatchMessage( PlatformGroup group, SessionBatchViewModel view, PlatformMessageRef scheduleMessage, CancellationToken ct) { try { await messenger.UpdateScheduleAsync( new PlatformScheduleMessage(group, view, scheduleMessage), ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update batch message after immediate reschedule"); } } 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 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); } } }