using Dapper; using GmRelay.Shared.Domain; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; public sealed record HandleRsvpCommand( Guid SessionId, long TelegramUserId, string Status, string CallbackQueryId, long ChatId, int MessageId); internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); internal sealed record SessionContext( string Title, DateTime ScheduledAt, string Status, long GmTelegramId, long TelegramChatId); internal sealed record ParticipantRsvp( long TelegramId, string DisplayName, string? TelegramUsername, string RsvpStatus); public sealed class HandleRsvpHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, ILogger logger) { public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); var participantExists = await connection.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND p.telegram_id = @TelegramUserId AND sp.is_gm = false AND sp.registration_status = @Active ) """, new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active }, transaction); if (!participantExists) { await bot.AnswerCallbackQuery( callbackQueryId: command.CallbackQueryId, text: "Вы не являетесь участником этой сессии.", cancellationToken: ct); return; } var updated = await connection.ExecuteAsync( """ UPDATE session_participants SET rsvp_status = @Status, responded_at = now() WHERE session_id = @SessionId AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId) AND registration_status = @Active AND rsvp_status != @Status """, new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active }, transaction); if (updated == 0) { var alreadyText = command.Status == RsvpStatus.Confirmed ? "Вы уже подтвердили участие." : "Вы уже отказались от участия."; await bot.AnswerCallbackQuery( callbackQueryId: command.CallbackQueryId, text: alreadyText, cancellationToken: ct); return; } var session = await connection.QuerySingleAsync( """ SELECT s.title, s.scheduled_at AS ScheduledAt, s.status AS Status, g.gm_telegram_id AS GmTelegramId, g.telegram_chat_id AS TelegramChatId FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId """, new { command.SessionId }, transaction); if (command.Status == RsvpStatus.Declined) { var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0); if (decision.ShouldRevertSessionToConfirmationSent) { await connection.ExecuteAsync( """ UPDATE sessions SET status = @ConfirmationSent, updated_at = now() WHERE id = @SessionId AND status = @Confirmed """, new { command.SessionId, ConfirmationSent = SessionStatus.ConfirmationSent, Confirmed = SessionStatus.Confirmed }, transaction); } var declinedPlayer = await connection.QuerySingleAsync( "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", new { command.TelegramUserId }, transaction); await transaction.CommitAsync(ct); try { await bot.SendMessage( chatId: session.GmTelegramId, text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».", cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId); } await bot.AnswerCallbackQuery( callbackQueryId: command.CallbackQueryId, text: decision.CallbackText, cancellationToken: ct); } else { var counts = await connection.QuerySingleAsync( """ SELECT count(*) AS Total, count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active """, new { command.SessionId, Confirmed = RsvpStatus.Confirmed, Declined = RsvpStatus.Declined, Active = ParticipantRegistrationStatus.Active }, transaction); var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed); if (decision.ShouldMarkSessionConfirmed) { await connection.ExecuteAsync( """ UPDATE sessions SET status = @Confirmed, updated_at = now() WHERE id = @SessionId """, new { command.SessionId, Confirmed = SessionStatus.Confirmed }, transaction); } await transaction.CommitAsync(ct); if (decision.ShouldNotifyGroup) { try { await bot.SendMessage( chatId: session.TelegramChatId, text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.", cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId); } } if (decision.ShouldNotifyGm) { try { await bot.SendMessage( chatId: session.GmTelegramId, text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).", cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId); } } await bot.AnswerCallbackQuery( callbackQueryId: command.CallbackQueryId, text: decision.CallbackText, cancellationToken: ct); } await UpdateConfirmationMessage(command, session, ct); } private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct) { try { await using var connection = await dataSource.OpenConnectionAsync(ct); var participants = (await connection.QueryAsync( """ SELECT p.telegram_id AS TelegramId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, sp.rsvp_status AS RsvpStatus 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 ORDER BY sp.responded_at NULLS LAST """, new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList(); var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList(); var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList(); var lines = new List { $"🎲 Подтвердите участие в «{session.Title}»", $"📅 {session.ScheduledAt.FormatMoscow()} (МСК)", string.Empty }; foreach (var participant in confirmed) { lines.Add($" ✅ {FormatName(participant)}"); } foreach (var participant in declined) { lines.Add($" ❌ ~~{FormatName(participant)}~~"); } foreach (var participant in pending) { lines.Add($" ⏳ {FormatName(participant)}"); } lines.Add(string.Empty); if (confirmed.Count == participants.Count) { lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})"); } else if (declined.Count > 0) { lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)"); } else { lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})"); } var text = string.Join("\n", lines); var replyMarkup = confirmed.Count == participants.Count ? null : new InlineKeyboardMarkup([ [ InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"), InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}") ] ]); await bot.EditMessageText( chatId: command.ChatId, messageId: command.MessageId, text: text, replyMarkup: replyMarkup, cancellationToken: ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId); } } private static string FormatName(ParticipantRsvp participant) => participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName; }