// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using GmRelay.Bot.Features.Confirmation.HandleRsvp; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.ListSessions; using GmRelay.Bot.Features.Sessions.ExportCalendar; using GmRelay.Bot.Features.Sessions.RescheduleSession; using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Infrastructure.Telegram; /// /// Routes incoming Telegram updates to the appropriate feature handler. /// No reflection — all routing is explicit (AOT-safe). /// public sealed class UpdateRouter( HandleRsvpHandler rsvpHandler, CreateSessionHandler createSessionHandler, JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler, CancelSessionHandler cancelSessionHandler, DeleteSessionHandler deleteSessionHandler, ListSessionsHandler listSessionsHandler, ExportCalendarHandler exportCalendarHandler, InitiateRescheduleHandler initiateRescheduleHandler, HandleRescheduleTimeInputHandler rescheduleTimeInputHandler, HandleRescheduleVoteHandler rescheduleVoteHandler, ITelegramBotClient bot, IConfiguration configuration, ILogger logger) : ITelegramUpdateHandler { public async Task RouteAsync(Update update, CancellationToken ct) { switch (update) { case { CallbackQuery: { } query }: await HandleCallbackQueryAsync(query, ct); break; case { Message: { Text: { } text } message } when text.StartsWith('/'): await HandleCommandAsync(message, text, ct); break; // Non-command text messages — check for reschedule time input case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'): await rescheduleTimeInputHandler.TryHandleAsync(message, ct); break; } } private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct) { if (query.Data is not { } data || query.Message is not { } message) return; var parts = data.Split(':', 3); var action = parts[0]; if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId)) { var command = new JoinSessionCommand( SessionId: joinSessionId, TelegramUserId: query.From.Id, DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"), TelegramUsername: query.From.Username, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await joinSessionHandler.HandleAsync(command, ct); return; } if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId)) { var command = new LeaveSessionCommand( SessionId: leaveSessionId, TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await leaveSessionHandler.HandleAsync(command, ct); return; } if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId)) { var command = new CancelSessionCommand( SessionId: cancelSessionId, TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await cancelSessionHandler.HandleAsync(command, ct); return; } if (action == "promote_waitlist" && parts.Length >= 2 && Guid.TryParse(parts[1], out var promoteSessionId)) { var command = new PromoteWaitlistedPlayerCommand( SessionId: promoteSessionId, TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await promoteWaitlistedPlayerHandler.HandleAsync(command, ct); return; } if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId)) { var command = new DeleteSessionCommand( SessionId: deleteSessionId, TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await deleteSessionHandler.HandleAsync(command, ct); return; } if (action == "reschedule_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var rescheduleSessionId)) { var command = new InitiateRescheduleCommand( SessionId: rescheduleSessionId, TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await initiateRescheduleHandler.HandleAsync(command, ct); return; } if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId)) { var command = new HandleRescheduleVoteCommand( OptionId: optionId, TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await rescheduleVoteHandler.HandleAsync(command, ct); return; } if (action == "rsvp") { if (parts.Length < 3 || !Guid.TryParse(parts[2], out var sessionId)) return; var status = parts[1] switch { "confirm" => RsvpStatus.Confirmed, "decline" => RsvpStatus.Declined, _ => (string?)null }; if (status is null) return; var command = new HandleRsvpCommand( SessionId: sessionId, TelegramUserId: query.From.Id, Status: status, CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); await rsvpHandler.HandleAsync(command, ct); } } private async Task HandleCommandAsync(Message message, string text, CancellationToken ct) { // Извлекаем команду: берём первую строку, первое слово, убираем @BotUsername var firstLine = text.Split('\n')[0].Trim(); var command = firstLine.Split(' ')[0].Split('@')[0].ToLowerInvariant(); switch (command) { case "/start": await SendStartMessageAsync(message, ct); break; case "/newsession": await createSessionHandler.HandleAsync(message, ct); break; case "/listsessions": await listSessionsHandler.HandleAsync(message, ct); break; case "/exportcalendar": await exportCalendarHandler.HandleAsync(message, ct); break; case "/help": await bot.SendMessage( chatId: message.Chat.Id, text: """ GM-Relay — бот для управления игровыми сессиями. /newsession Название: My Game Время: 15.05.2026 19:30 Мест: 4 Ссылка: https://link Для регулярного расписания можно указать одну дату: Игр: 4 Интервал: 7 /listsessions — список предстоящих сессий Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования. /help — эта справка """, cancellationToken: ct); break; // TODO: /listsessions — will be implemented as features default: logger.LogDebug("Unknown command: {Command}", command); break; } } private async Task SendStartMessageAsync(Message message, CancellationToken ct) { var miniAppUrl = configuration["Telegram:MiniAppUrl"]; if (string.IsNullOrWhiteSpace(miniAppUrl)) { await bot.SendMessage( chatId: message.Chat.Id, text: "GM-Relay Bot ready. Use /help for commands.", cancellationToken: ct); return; } await bot.SendMessage( chatId: message.Chat.Id, text: "GM-Relay Bot ready. Откройте dashboard внутри Telegram или используйте /help для команд.", replyMarkup: new InlineKeyboardMarkup( InlineKeyboardButton.WithWebApp("Открыть dashboard", new WebAppInfo(miniAppUrl))), cancellationToken: ct); } }