// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment using System.Globalization; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Rendering; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Bot.Features.Sessions.ListSessions; using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler; using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler; 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; using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard; 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, BotCreateSessionHandler createSessionHandler, JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler, CancelSessionHandler cancelSessionHandler, DeleteSessionHandler deleteSessionHandler, ListSessionsHandler listSessionsHandler, ExportCalendarHandler exportCalendarHandler, InitiateRescheduleHandler initiateRescheduleHandler, BotRescheduleTimeInputHandler rescheduleTimeInputHandler, BotRescheduleVoteHandler rescheduleVoteHandler, SharedWizard wizard, IWizardDraftRepository drafts, ITelegramBotClient bot, IConfiguration configuration, ILogger logger) : ITelegramUpdateHandler { public async Task RouteAsync(Update update, CancellationToken ct) { // 1) Wizard delegation. If the GM has an active (non-expired) draft for this // (chat, thread, owner), every update routes to the wizard. The wizard is // responsible for both text input and callback handling. if (TryGetWizardContext(update, out _, out _, out var ownerId)) { var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct); if (draft is not null) { // Resume / Reset / Cancel menu callbacks live in the router because // they cross draft boundaries (reset deletes + recreates a fresh // draft, which the wizard instance doesn't know how to do). if (await TryHandleDraftControlCallbackAsync(update, draft, ct)) { return; } if (WizardInteractionMapper.TryMap(update, out var interaction)) { await wizard.HandleInteractionAsync(interaction, draft, ct); } // The "✅ Создать" / "✅ Создать пул" button — the wizard only // acknowledges the callback; the actual session creation lives in // CreateSessionHandler. if (update.CallbackQuery?.Data is { } data && data == WizardCallbackData.Create()) { await createSessionHandler.SubmitDraftAsync(draft, ct); } return; } } switch (update) { case { CallbackQuery: { } query }: await HandleCallbackQueryAsync(query, ct); break; case { Message: { } message }: var commandText = GetCommandText(message); if (commandText.StartsWith("/", StringComparison.Ordinal)) { await HandleCommandAsync(message, commandText, ct); break; } if (message.Text is not null) { await rescheduleTimeInputHandler.TryHandleAsync(message, ct); } break; } } /// /// Handles router-level draft-control callbacks ("resume", "reset"). Returns true /// if the update was consumed and the wizard should be skipped. The wizard still /// owns "cancel" and "create". /// private async Task TryHandleDraftControlCallbackAsync( Update update, WizardDraft draft, CancellationToken ct) { if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom }) return false; switch (data) { case WizardControlCallbacks.Resume: // Re-render the current step of the existing draft. We answer the // callback here because the wizard will not be called. var (text, kb) = WizardStep.Render(draft, LoadPayload(draft)); await bot.EditMessageText( chatId: cbMessage.Chat.Id, messageId: cbMessage.MessageId, text: text, replyMarkup: kb, cancellationToken: ct); await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct); return true; case WizardControlCallbacks.Reset: // Delete the existing draft and start a fresh one. The wizard is // bypassed entirely because the active draft is now gone. await drafts.DeleteAsync(draft.Id, ct); await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct); var newDraft = await createSessionHandler.StartWizardAsync( SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct); if (newDraft is null) { // Race: another wizard just started for the same owner. The // user can simply re-run /newsession. We don't loop. await bot.SendMessage( chatId: cbMessage.Chat.Id, text: "Не удалось начать заново — попробуйте ещё раз через /newsession.", cancellationToken: ct); } return true; } return false; } /// /// Build a synthetic carrying just the fields /// reads (chat, thread, from). /// private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new() { Chat = new Chat { Id = chatId }, MessageThreadId = messageThreadId, From = new User { Id = fromId }, }; private static WizardPayload LoadPayload(WizardDraft draft) => SharedWizard.LoadPayload(draft); internal static string GetCommandText(Message message) => (message.Text ?? message.Caption ?? string.Empty).TrimStart(); /// /// Extracts the (chat, thread, owner) triple from an update for wizard lookups. /// Returns false for updates that carry no usable origin (e.g. inline queries). /// private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId) { chatId = 0; messageThreadId = null; ownerId = string.Empty; switch (update) { case { Message: { From: not null, Chat: { } chat } msg }: chatId = chat.Id; messageThreadId = msg.MessageThreadId; ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture); return true; case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }: chatId = cbmChat.Id; messageThreadId = cb.Message?.MessageThreadId; ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture); return true; case { CallbackQuery: { From: not null } cb2 }: // Callback arrived without a message (e.g. from a Mini App). No chat // context → wizard cannot run on this update. ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture); return false; default: return false; } } 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]; var user = TelegramPlatformIds.User( query.From.Id, query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"), query.From.Username); var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title); var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId); if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId)) { var command = new JoinSessionCommand( SessionId: joinSessionId, User: user, InteractionId: query.Id, Group: group, ScheduleMessage: scheduleMessage); 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, User: user, InteractionId: query.Id, Group: group, ScheduleMessage: scheduleMessage); 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, MessageThreadId: message.MessageThreadId, 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, MessageThreadId: message.MessageThreadId, 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, User: user, Status: status, InteractionId: query.Id, Group: group, ConfirmationMessage: scheduleMessage); 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 HandleNewSessionCommandAsync(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 Картинка: https://cover Для регулярного расписания можно указать одну дату: Игр: 4 Интервал: 7 /listsessions — список предстоящих сессий Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания. Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». /help — эта справка """, cancellationToken: ct); break; // TODO: /listsessions — will be implemented as features default: logger.LogDebug("Unknown command: {Command}", command); break; } } private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct) { // Try to start a fresh wizard. StartWizardAsync returns null when a // non-expired draft already exists for this (chat, thread, owner). var draft = await createSessionHandler.StartWizardAsync(message, ct); if (draft is not null) { // New draft was created and its first step has been rendered. return; } // Existing draft. Look it up so we can describe the current step and offer // a Continue / Start over / Cancel menu. var existing = await createSessionHandler.TryResumeAsync(message, ct); if (existing is null) { // Race: the draft expired between the two calls (or the user lacks // ownership metadata). Fall back to silently starting a new wizard. await createSessionHandler.StartWizardAsync(message, ct); return; } await bot.SendMessage( chatId: message.Chat.Id, text: "У вас уже есть незавершённый мастер. Продолжить?", replyMarkup: ContinueResetCancelKeyboard(), cancellationToken: ct); } private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[] { // "Продолжить" re-renders the existing draft's current step (router-level). // "Начать заново" deletes the existing draft and creates a fresh one (router-level). // "Отмена" delegates to the wizard's normal cancel handler. new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) }, new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) }, new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, }); 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); } } /// /// Router-level callback data for the Continue / Start over / Cancel menu shown /// when /newsession detects an existing wizard draft. Distinct from /// which is parsed and consumed by the wizard itself. /// internal static class WizardControlCallbacks { public const string Resume = "wizard:resume"; public const string Reset = "wizard:reset"; }