diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 1eafc8c..9cc2af0 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -2,8 +2,10 @@ 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; @@ -34,12 +36,44 @@ public sealed class UpdateRouter( InitiateRescheduleHandler initiateRescheduleHandler, BotRescheduleTimeInputHandler rescheduleTimeInputHandler, BotRescheduleVoteHandler rescheduleVoteHandler, + GameCreationWizard wizard, + WizardDraftRepository 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 var chatId, out var threadId, out var ownerId)) + { + var draft = await drafts.GetActiveAsync(chatId, threadId, 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; + } + + await wizard.HandleUpdateAsync(update, 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 }: @@ -63,9 +97,106 @@ public sealed class UpdateRouter( } } + /// + /// 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) => + GameCreationWizard.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 long ownerId) + { + chatId = 0; + messageThreadId = null; + ownerId = 0; + + switch (update) + { + case { Message: { From: not null, Chat: { } chat } msg }: + chatId = chat.Id; + messageThreadId = msg.MessageThreadId; + ownerId = msg.From!.Id; + return true; + + case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }: + chatId = cbmChat.Id; + messageThreadId = cb.Message?.MessageThreadId; + ownerId = cb.From!.Id; + 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; + return false; + + default: + return false; + } + } + private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct) { if (query.Data is not { } data || query.Message is not { } message) @@ -213,9 +344,7 @@ public sealed class UpdateRouter( break; case "/newsession": - // TODO (Task 13): if a non-expired draft already exists, render a - // "Continue / Start over / Cancel" menu instead of starting a new one. - await createSessionHandler.StartWizardAsync(message, ct); + await HandleNewSessionCommandAsync(message, ct); break; case "/listsessions": @@ -258,6 +387,45 @@ public sealed class UpdateRouter( } } + 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"]; @@ -278,3 +446,14 @@ public sealed class UpdateRouter( 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"; +}