feat(wizard): delegate updates to wizard when an active draft exists

This commit is contained in:
2026-06-04 09:14:13 +03:00
parent 4a04d7d723
commit af345ba765
@@ -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<UpdateRouter> 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(
}
}
/// <summary>
/// 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".
/// </summary>
private async Task<bool> 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;
}
/// <summary>
/// Build a synthetic <see cref="Message"/> carrying just the fields
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
/// </summary>
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();
/// <summary>
/// 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).
/// </summary>
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);
}
}
/// <summary>
/// Router-level callback data for the Continue / Start over / Cancel menu shown
/// when /newsession detects an existing wizard draft. Distinct from
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
/// </summary>
internal static class WizardControlCallbacks
{
public const string Resume = "wizard:resume";
public const string Reset = "wizard:reset";
}