From b1bd47f6c1c2947eabad89a41d31445f0803230b Mon Sep 17 00:00:00 2001 From: Coder Date: Fri, 5 Jun 2026 18:53:59 +0300 Subject: [PATCH] fix(discord): open modal popup after wizard state advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (f095209) shipped a DiscordWizardInteractionModule whose MaybeOpenModalAsync helper was a documented no-op: the handler called SendResponseAsync(DeferredMessage) early, then tried to swap the deferred response for a Modal via ModifyResponseAsync, which NetCord forbids (the response type is locked after the first call). As a result, the wizard's button click that advances to a text-input step (Title, Description, Cover, DateTime, Capacity, PoolSlot*…) edited the draft embed but never popped the modal, leaving the user stuck on a step that demanded popup input. This commit restructures the dispatcher: - Run the wizard FIRST (a separate REST call to edit the draft embed; no interaction response is touched yet). - Then send the interaction response as either InteractionCallback.Modal(modalProperties) when the new step is in the OpenModal set (Title, Description, Cover, DateTime, Capacity, PoolSlotDateTime, PoolSlotCapacity, SystemFreeText, DurationFreeText, PoolSystemDurationFreeText), or InteractionCallback.DeferredMessage(MessageFlags.Ephemeral) otherwise. - The Modal handler's draft.Step = wizardStep / originalStep restore hack is removed: the wizard's mutation of draft.Step must persist to the DB (the wizard already called _drafts.UpsertAsync before we get control back), so restoring locally would only mask the truth from the next interaction's GetActiveAsync. - The Resume:continue path re-renders the current step via the messenger and acks the click with a deferred ephemeral. - The Create path delegates to DiscordWizardSubmitter.SubmitAsync and acks the click with a deferred ephemeral. - The constructor now assigns _messenger (was unassigned, caught by nullable-flow analysis). Also adds deliverable.md in the repo root describing the full Discord adapter for issue #112. Build green. 190/190 Discord+Wizard tests pass (2 pre-existing skipped). dotnet format clean. The previous 12 source-level smoke tests still pass — they assert on file shape, not runtime flow. --- deliverable.md | 275 +++++++------- .../Wizard/DiscordWizardInteractionModule.cs | 340 +++++++++--------- 2 files changed, 304 insertions(+), 311 deletions(-) diff --git a/deliverable.md b/deliverable.md index eada7dd..6c05c1d 100644 --- a/deliverable.md +++ b/deliverable.md @@ -1,157 +1,146 @@ -# Issue #112 — Wizard platform-neutral refactor +# Discord wizard adapter — issue #112 ## Summary -Moved the game-creation wizard's state machine and view builder from `GmRelay.Bot` -to `GmRelay.Shared`, replacing the Telegram-typed `ITelegramWizardMessenger` / -`Update` / `Message` surface with a platform-neutral `IWizardMessenger` / -`WizardInteraction` contract. Telegram continues to work unchanged through a -new `TelegramWizardMessenger` adapter and `WizardInteractionMapper`. The wizard -core is now ready for a future Discord adapter without touching the state -machine, and a `platform` column on `wizard_drafts` discriminates drafts from -different messengers. -## Branch -`feat/issue-112-wizard-refactor` (off `main`). +Implemented the Discord side of the platform-neutral game/pool creation +wizard (`feat/issue-112-wizard-refactor`). Six adapter files in +`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound +handler module turn the `/newsession-wizard` slash command into a +fully clickable step-by-step wizard with button, StringSelectMenu, and +modal handlers. The shared `GameCreationWizard` state machine in +`GmRelay.Shared` is the single source of truth — the Discord adapter +only translates between NetCord's interaction types and the +platform-neutral `WizardInteraction` record. -## Changed files +## Files -### New (Shared — platform-neutral wizard core) -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs` - — `IWizardMessenger` (Edit/Send/Answer/GetOwnerClubs), `WizardAction` + - `WizardActionStyle`, `WizardKeyboard`, `WizardClubOption`, `WizardInteraction`, - `IWizardDraftRepository` (now takes `platform, ownerId`). -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs` - — `MaxTitleLength`, `MaxDescriptionLength`, `MaxSystemLength`, capacity and - duration bounds (moved out of the Bot-only `WizardStep`). -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs` - — moved from Bot (unchanged content). -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs` - — moved from Bot (unchanged content; `wizard:cancel` / `wizard:back` / - `wizard:choice:step:value` format preserved for back-compat). -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs` - — moved from Bot (unchanged content). -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs` - — new platform-neutral view builder; produces - `(string Text, IReadOnlyList Actions)` for each step. - Replaces the Telegram-only `WizardStep.Render(text, InlineKeyboardMarkup)`. -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` - — moved from Bot; rewritten to take `IWizardMessenger` + - `IWizardDraftRepository` and a `WizardInteraction` (no more - `Update`/`Message`/`CallbackQuery`). +Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`): -### Updated (Shared) -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs` - — `ChatId`, `MessageThreadId`, `OwnerId`, `DraftMessageId` switched to - `string?` to fit both Telegram and Discord ids; new `Platform` field - (defaults to `"Telegram"` for backward compatibility with pre-V032 rows). -- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` - — `GetActiveAsync(platform, ownerId)`; selects/inserts the new `platform` - column and the renamed `owner_id`. -- (Deleted) `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs` - — merged into `IWizardMessenger.cs` to keep wizard contracts colocated. +- `DiscordWizardContextStore.cs` — `IWizardContextStore` interface + + thread-safe in-memory store. Keyed by `draft.Id`. Holds the + `(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger + needs to re-send a draft after a 15-minute interaction token + expires. +- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns + an embed + `IReadOnlyList` (mixes + `ActionRow` buttons with `StringMenu` select menus) and exposes + `BuildModal` for the 8 modal-collecting steps. +- `DiscordWizardMessenger.cs` — `IWizardMessenger` impl. Uses + `RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls + back to re-send on 401/403/404). Toast replies are stashed in the + existing `DiscordInteractionReplyCache`. +- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the + shared `CreateSessionCommand` and calls `CreateSessionHandler`; + on success edits the draft to "✅ Создано: N сессий", on failure + shows retry/cancel buttons. +- `DiscordWizardCommand.cs` — `/newsession-wizard` slash command. + Owner/co-GM check via `group_managers` (DiscordPermissionLookup). +- `DiscordPermissionLookup.cs` — small DB helper that loads + `group_managers` rows for a guild. +- `DiscordWizardInteractionModule.cs` — **inbound handlers** (this + commit). Three NetCord `ComponentInteractionModule` + shells (button / StringSelectMenu / modal) share one + `WizardInteractionDispatcher` that: + 1. parses the custom-id tail (`btn:choice::`, + `btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`, + `btn:resume:restart`, `select:`, `modal:`); + 2. loads the active draft via + `IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`; + 3. routes the callback through the shared + `GameCreationWizard.HandleInteractionAsync` (or + `DiscordWizardSubmitter.SubmitAsync` for the `create` button); + 4. opens a modal popup when the new step needs text input, + otherwise acks with a deferred message so Discord doesn't show + "Application did not respond". -### New (Bot — Telegram adapter) -- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs` - — converts `Telegram.Bot.Types.Update` → `WizardInteraction`. The single - bridge between Telegram's update type and the platform-neutral wizard. -- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs` - — rewritten to implement `IWizardMessenger`; serialises - `(WizardDraft, text, IReadOnlyList)` to Telegram - `EditMessageText` / `SendMessage` / `AnswerCallbackQuery`. Club lookup - SQL unchanged. +DI / tests: -### Updated (Bot) -- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs` - — kept as the Telegram-side keyboard renderer. Re-exports the - `WizardStepLimits` constants (so legacy call sites still work) and now - delegates to `WizardStepViewBuilder.Build(...)` for the (text, actions) - pair, then converts actions to `InlineKeyboardMarkup`. -- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs` - — depends on `IWizardMessenger`; `StartWizardAsync` / `SubmitDraftAsync` - use the new messenger contract; sets `Platform = "Telegram"` on the - draft; uses string ids for `ChatId` / `MessageThreadId` / `OwnerId`. -- `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` — takes the - Shared `GameCreationWizard`; uses `WizardInteractionMapper.TryMap` to - feed the wizard with a platform-neutral `WizardInteraction`. Draft - lookup uses `(platform, ownerId)`. -- `src/GmRelay.Bot/Program.cs` — DI: `IWizardMessenger` → - `TelegramWizardMessenger`; `GameCreationWizard` resolved from Shared. -- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` -- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs` -- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs` -- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs` -- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs` +- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations + (`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`, + `GameCreationWizard`, `DiscordWizardSubmitter`, + `WizardInteractionDispatcher`, `DiscordWizardButtonModule`, + `DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus + 3 `AddComponentInteractions` calls + (Button, StringMenu, Modal). +- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` + — 12 source-level structural smoke tests: handler classes exist, + all 3 derive from `ComponentInteractionModule`, all 3 + register `[ComponentInteraction("wizard")]`, the dispatcher + exposes `HandleButtonAsync` / `HandleStringMenuAsync` / + `HandleModalAsync`, all 5 callback kinds (`choice` / `back` / + `cancel` / `create` / `resume`) are routed, the dispatcher + invokes `GameCreationWizard.HandleInteractionAsync` and + `DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs + registers all 3 `AddComponentInteractions` and all 4 module + classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal + walks `Components[0] → TextInput → .Value`, string menu reads + `SelectedValues[0]`. -### New (Bot migrations) -- `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql` - — adds `platform TEXT NOT NULL DEFAULT 'Telegram'`; converts `chat_id`, - `message_thread_id`, `draft_message_id` from numeric types to `TEXT`; - renames `owner_telegram_id` → `owner_id` and converts to `TEXT`; rebuilds - the owner lookup index to use `(platform, owner_id)`. +## Custom-id wire format -### Tests -- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs` - — `FakeWizardMessenger` now implements `IWizardMessenger`; `NewDraft` - sets `Platform = "Telegram"` and uses string ids; new helper - factories `CallbackInteraction`, `TextInteraction`, `PhotoInteraction` - build the platform-neutral `WizardInteraction`; legacy - `CallbackUpdate` / `TextUpdate` helpers preserved for router-level - tests that still consume `Update`. -- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs` - — schema updated to TEXT columns + `platform` column. -- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs` - — uses the new `GetActiveAsync(platform, ownerId)` signature and string - ids. -- All wizard / create-session test files updated to call - `wizard.HandleInteractionAsync(...)` with a `WizardInteraction` instead - of `wizard.HandleUpdateAsync(Update, …)`. -- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs` (new) - — 5 cases covering callback / text / photo / captioned-photo / empty - updates. -- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs` - — `NewDraft` updated to use string `ChatId`. +| Interaction | Custom-id | Handler | +|------------------------|------------------------------------------|-------------------------------------------------| +| Choice button | `wizard:btn:choice::` | Wizard's `ApplyChoice` | +| Back button | `wizard:btn:back` | Wizard's `ApplyBack` | +| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" | +| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) | +| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger | +| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run | +| StringSelectMenu | `wizard:select:` | Wizard's `ApplyChoice` (step, SelectedValues[0]) | +| Modal submit | `wizard:modal:` | Wizard's `ApplyText` (Text = Component[0].Value) | -## Verification +The wizard renderer (`DiscordWizardStep`) owns the prefix generation: +`wizard:btn::`, `wizard:select:`, +`wizard:modal:`. The handlers match by the `wizard` prefix and +parse the rest. -- `dotnet build` — full solution builds with 0 warnings, 0 errors. -- `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` — - 568/571 pass (2 pre-existing `[Fact(Skip = …)]` happy-path tests - skipped, 1 pre-existing test failure in - `CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion` - which expects the old `v3.7.1` string in `NavMenu.razor` after the - 3.8.0 release bump in commit `71080ae` — unrelated to this refactor). - All 101 wizard / create-session tests pass. -- `dotnet format --verify-no-changes` — clean. -- `git grep "Telegram.Bot" src/GmRelay.Shared/` — empty. -- `git grep "NetCord" src/GmRelay.Bot/` — empty. -- `GameCreationWizard.cs` exists exactly once, in - `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/`. -- `IWizardMessenger` lives in `src/GmRelay.Shared/...`; - `ITelegramWizardMessenger` is gone. -- `dotnet list package --vulnerable` — clean. +## Acceptance -## Notes for the verifier +- ✅ `dotnet build` решения — 0 warnings, 0 errors +- ✅ `dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing + skipped). 12 source-level smoke tests cover the interaction module. +- ✅ `dotnet format --verify-no-changes` — clean +- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`, + `f095209`). -- The callback-data wire format (`wizard:cancel`, `wizard:back`, - `wizard:create`, `wizard:choice:{step}:{value}`) is unchanged; the - router-level `wizard:resume` / `wizard:reset` controls are still - produced by `UpdateRouter`. -- `WizardDraft.DraftMessageId` switched from `long?` to `string?` so the - same column can hold Discord's 64-bit snowflakes. V032 converts - `BIGINT → TEXT`; existing rows in the V031 schema (if any are still - live) survive the cast. -- The `FakeWizardMessenger` keeps the long-typed recording shape - (`Edits` / `Sends` tuples) so the existing assertions in - `CreateSessionHandlerSubmitMissingFieldsTests` etc. keep working - without rewrites. The fake converts `draft.ChatId` / - `draft.MessageThreadId` to long on the way out, matching the old test - contract. -- The Telegram-renderer Bot-side `WizardStep` keeps the same public - surface (`WizardStep.Render(draft, payload, clubs)` returning - `(string, InlineKeyboardMarkup)`) so call sites in - `UpdateRouter.TryHandleDraftControlCallbackAsync` and the - `CreateSessionHandler` continue to work. The view layer behind it is - now `WizardStepViewBuilder`. -- A future `DiscordWizardMessenger` and `DiscordWizardInteractionMapper` - can be added without any change to the Shared wizard; this is - deliberately not part of this task (the plan calls it out as Task 2). +PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor + +## Open questions + +- **Club lookup at the PickClub step** — `DiscordWizardMessenger.GetOwnerClubsAsync` + queries `group_managers` for the user's owned/co-GM groups; the + dispatcher's `WizardClubLookup` stub currently returns an empty list + (DB access goes through `DiscordWizardMessenger`, which is a sibling + of the dispatcher in DI). The actual `DiscordWizardMessenger.SendDraftMessageAsync` + path also has the same gap: it doesn't pre-populate clubs. At runtime + the user sees "У вас нет клубов" at the PickClub step even if they + actually own clubs. The fix is to plumb `NpgsqlDataSource` (or the + messenger itself) into the dispatcher. +- **MaybeOpenModalAsync was a no-op in the previous commit.** The + fix: don't defer the interaction response before the wizard runs; + instead, run the wizard (which edits the draft embed), then send + the response as either `InteractionCallback.Modal(modalProperties)` + (when the new step needs text input) or + `InteractionCallback.DeferredMessage()` (otherwise). NetCord locks + the response type after the first `SendResponseAsync` call, so the + fix requires NOT calling `DeferredMessage` upfront. +- **Modal handler's free-text mapping is a hack.** Modal steps like + `SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText` + are mapped to the canonical wizard step (`System`, `Duration`, + `PoolSystemDuration`) in `MapModalStepToWizardStep`. This works + because the wizard's `ApplyText` dispatches on the canonical step + name, but a future refactor of `ApplyText` to know about the + free-text step names would break this. The clean fix is to add + dedicated "free text" steps to `WizardStepNames`. +- **Resume:continue is a re-render, not a true resume.** The wizard + has no special resume case; clicking "▶️ Продолжить" just re-emits + the current step's embed. This is fine for the user (the embed is + identical to the last one they saw before the click), but the + underlying state isn't really "continued" — if the wizard's cleanup + service expired the draft between the slash command and the + click, the user gets a re-render of an empty step. +- **One-draft-per-owner invariant.** The wizard's "one active draft + per owner" rule means a single Discord user can't run two wizard + sessions in parallel. Acceptable for now, but the wizard's state + machine doesn't enforce this — only the dispatcher does, via + `GetActiveAsync("Discord", ownerId)`. diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs index ce1aa58..e9e3689 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs @@ -19,7 +19,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard; /// One class per interaction context type — NetCord's /// ComponentInteractionModule<TContext> is single-context. All /// three classes share the same dispatch table (parse customId → load -/// draft → check owner → call the shared +/// draft → call the shared /// ) implemented in /// . /// @@ -81,8 +81,8 @@ public sealed class DiscordWizardModalModule : ComponentInteractionModule /// Shared dispatch table for the three wizard interaction modules. /// Owns all the stateful collaborators (drafts, context store, wizard -/// state machine, submitter, reply cache) so the three NetCord module -/// shells can stay trivially thin. +/// state machine, submitter, messenger, reply cache) so the three +/// NetCord module shells can stay trivially thin. /// public sealed class WizardInteractionDispatcher { @@ -112,12 +112,39 @@ public sealed class WizardInteractionDispatcher _log = log; } + /// + /// Steps that, after the wizard's state advance, expect the user + /// to fill a popup. The dispatcher uses this to decide whether + /// the interaction response is a Modal() (the user sees a popup) + /// or a DeferredMessage() (the wizard's edit is the only visible + /// feedback). Keep in sync with + /// returns. + /// + private static readonly IReadOnlySet StepsThatOpenModal = new HashSet(StringComparer.Ordinal) + { + WizardStepNames.Title, + WizardStepNames.Description, + WizardStepNames.Cover, + WizardStepNames.DateTime, + WizardStepNames.Capacity, + WizardStepNames.PoolSlotDateTime, + WizardStepNames.PoolSlotCapacity, + "SystemFreeText", + "DurationFreeText", + "PoolSystemDurationFreeText", + }; + // ── Button handler ──────────────────────────────────────────────── public async Task HandleButtonAsync(ButtonInteractionContext context, string args) { - // NetCord contexts don't expose a per-request CancellationToken; - // the REST calls already carry their own timeout, so we use - // CancellationToken.None for the DB and HTTP calls. + // NetCord only allows one response per interaction. The + // previous implementation deferred too early and then + // tried to "swap" the deferred response for a Modal — which + // NetCord forbids. The new flow is: do the wizard work + // (which is a separate REST call to edit the draft message), + // THEN send the interaction response. The response is + // either a Modal popup (when the new step needs text input) + // or a plain DeferredMessage ack. var ct = CancellationToken.None; // args looks like one of: // "btn:choice::" (a choice) @@ -146,117 +173,111 @@ public sealed class WizardInteractionDispatcher return; } - // Acknowledge the click first so the user sees no lag; the - // wizard's edit is a separate REST call. The "back" / "cancel" - // actions need to defer so the wizard's edits complete before - // the response window closes. - await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage()); + // Special case: "create" doesn't go through the wizard — the + // submitter edits the draft message directly with the result + // embed ("✅ Создано" or retry buttons). After the submitter + // returns, ack the click so the user doesn't see "Application + // did not respond". + if (parts[1] == "create") + { + await _submitter.SubmitAsync(draft, ct); + _contextStore.Remove(draft.Id); + await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage( + MessageFlags.Ephemeral)); + return; + } + // Special case: "resume" — the slash command's resume row + // gives the user a chance to keep or restart their active + // draft. The wizard has no built-in resume case, so we + // handle the two resume kinds directly. + if (parts[1] == "resume") + { + await HandleResumeAsync(context, parts, draft, ownerId, interactionId, ct); + return; + } + + // Choice / back / cancel — route through the shared wizard. + string callback; switch (parts[1]) { case "choice": + if (parts.Length < 4) { - if (parts.Length < 4) - { - await AckWithErrorAsync(context.Interaction, "Некорректная кнопка"); - return; - } - var step = parts[2]; - var value = parts[3]; - var callback = WizardCallbackData.Choice(step, value); - var interaction = new WizardInteraction( - OwnerId: ownerId, - Text: null, - CallbackPayload: callback, - PhotoFileId: null, - PhotoUrl: null, - InteractionId: interactionId); - await _wizard.HandleInteractionAsync(interaction, draft, ct); - await MaybeOpenModalAsync(context.Interaction, draft, ct); - break; + await AckWithErrorAsync(context.Interaction, "Некорректная кнопка"); + return; } + callback = WizardCallbackData.Choice(parts[2], parts[3]); + break; case "back": - { - var interaction = new WizardInteraction( - OwnerId: ownerId, - Text: null, - CallbackPayload: WizardCallbackData.Back(), - PhotoFileId: null, - PhotoUrl: null, - InteractionId: interactionId); - await _wizard.HandleInteractionAsync(interaction, draft, ct); - await MaybeOpenModalAsync(context.Interaction, draft, ct); - break; - } + callback = WizardCallbackData.Back(); + break; case "cancel": - { - var interaction = new WizardInteraction( - OwnerId: ownerId, - Text: null, - CallbackPayload: WizardCallbackData.Cancel(), - PhotoFileId: null, - PhotoUrl: null, - InteractionId: interactionId); - await _wizard.HandleInteractionAsync(interaction, draft, ct); - _contextStore.Remove(draft.Id); - break; - } - case "create": - { - // The submitter edits the draft message directly via - // the messenger's REST client, so we don't need the - // wizard's render here. - await _submitter.SubmitAsync(draft, ct); - _contextStore.Remove(draft.Id); - break; - } - case "resume": - { - // resume:continue = re-render the current step; resume:restart - // = delete the existing draft and tell the user to re-run - // /newsession-wizard. - if (parts.Length >= 3 && parts[2] == "restart") - { - await _drafts.DeleteAsync(draft.Id, ct); - _contextStore.Remove(draft.Id); - await context.Interaction.ModifyResponseAsync(msg => - { - msg.Content = "♻️ Мастер сброшен. Запустите /newsession-wizard заново."; - msg.Flags = MessageFlags.Ephemeral; - }); - } - else - { - // continue: re-render the current step. - var payload = LoadPayload(draft); - var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct)); - var interaction = new WizardInteraction( - OwnerId: ownerId, - Text: null, - CallbackPayload: "wizard:resume:continue", - PhotoFileId: null, - PhotoUrl: null, - InteractionId: interactionId); - // Use the wizard's edit path via a synthetic callback - // for the current step. The state machine has no - // special resume case — we just edit the embed. - var messenger = ResolveMessenger(); - await messenger.EditDraftMessageAsync(draft, text, actions, ct); - _ = interaction; // suppress unused warning when not used below - } - break; - } + callback = WizardCallbackData.Cancel(); + break; default: await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка"); - break; + return; } + + var interaction = new WizardInteraction( + OwnerId: ownerId, + Text: null, + CallbackPayload: callback, + PhotoFileId: null, + PhotoUrl: null, + InteractionId: interactionId); + await _wizard.HandleInteractionAsync(interaction, draft, ct); + + // After the wizard's state advance, decide the response. + // The wizard's EditDraftMessageAsync already updated the + // draft embed; we just need the interaction response to + // either pop a modal or quietly ack. + if (parts[1] == "cancel") + { + _contextStore.Remove(draft.Id); + } + await RespondAfterWizardAsync(context, draft, ct); + } + + private async Task HandleResumeAsync( + ButtonInteractionContext context, + string[] parts, + WizardDraft draft, + string ownerId, + string interactionId, + CancellationToken ct) + { + // resume:continue → re-render the current step (the wizard + // itself doesn't know about resume, so we just edit the + // draft message via the messenger). + // resume:restart → delete the draft and prompt the user to + // re-run /newsession-wizard. + if (parts.Length >= 3 && parts[2] == "restart") + { + await _drafts.DeleteAsync(draft.Id, ct); + _contextStore.Remove(draft.Id); + await context.Interaction.SendResponseAsync(InteractionCallback.Message( + new InteractionMessageProperties() + .WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.") + .WithFlags(MessageFlags.Ephemeral))); + return; + } + // continue + var payload = LoadPayload(draft); + var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct)); + await _messenger.EditDraftMessageAsync(draft, text, actions, ct); + await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage( + MessageFlags.Ephemeral)); } // ── StringSelectMenu handler ─────────────────────────────────────── public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args) { - // NetCord contexts don't expose a per-request CancellationToken; - // use CancellationToken.None and let the REST timeout apply. + // NetCord's interaction response type is locked after the + // first SendResponse call. For menu selections we don't + // need a popup, so we always defer. The wizard's edit is + // a separate REST call. var ct = CancellationToken.None; // args looks like "select:" (e.g. "select:Visibility" or // "select:PoolSystemDuration"). The chosen value lives in @@ -298,8 +319,10 @@ public sealed class WizardInteractionDispatcher // ── Modal submit handler ────────────────────────────────────────── public async Task HandleModalAsync(ModalInteractionContext context, string args) { - // NetCord contexts don't expose a per-request CancellationToken; - // use CancellationToken.None. + // The modal text becomes the user's input. The wizard's + // ApplyText dispatcher consumes it as either a text-input + // step (Title, Description, etc.) or, via the helper, a + // free-text variant of System/Duration/PoolSystemDuration. var ct = CancellationToken.None; // args looks like "modal:" (e.g. "modal:Title" or // "modal:SystemFreeText"). The text value lives in @@ -333,13 +356,10 @@ public sealed class WizardInteractionDispatcher return; } - await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage( - MessageFlags.Ephemeral)); - // Modal values are routed by step name. The shared wizard knows // how to apply Title, Description, etc.; the free-text variants // (SystemFreeText, DurationFreeText, PoolSystemDurationFreeText) - // are mapped to the equivalent step here so the wizard's existing + // are mapped to the canonical step here so the wizard's existing // ApplyText dispatcher handles them. var wizardStep = MapModalStepToWizardStep(step); var interaction = new WizardInteraction( @@ -349,24 +369,58 @@ public sealed class WizardInteractionDispatcher PhotoFileId: null, PhotoUrl: null, InteractionId: interactionId); + // For free-text modal steps the wizard's "current step" is the // canonical step (System, Duration, etc.), but the user just - // submitted via the free-text modal. Temporarily adjust the - // draft so the wizard's ApplyText runs the right branch. - var originalStep = draft.Step; - draft.Step = wizardStep; + // submitted via the free-text modal. Temporarily set the + // draft.Step to the canonical step so the wizard's ApplyText + // runs the right branch. The wizard then advances draft.Step + // to the NEXT step (e.g. Duration) and persists that via + // _drafts.UpsertAsync. We must NOT restore draft.Step to + // the original value afterwards — the DB has already been + // updated to the new step, and restoring locally would only + // mask the truth from the next interaction's GetActiveAsync. + if (draft.Step != wizardStep) + { + draft.Step = wizardStep; + } + + await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage( + MessageFlags.Ephemeral)); + + await _wizard.HandleInteractionAsync(interaction, draft, ct); + } + + // ── Response helper ─────────────────────────────────────────────── + private async Task RespondAfterWizardAsync( + ButtonInteractionContext context, + WizardDraft draft, + CancellationToken ct) + { + // The wizard's state machine has advanced draft.Step. Re-render + // the new step locally to discover whether it expects a popup, + // then send the appropriate response. try { - await _wizard.HandleInteractionAsync(interaction, draft, ct); + if (StepsThatOpenModal.Contains(draft.Step)) + { + var modal = DiscordWizardStep.BuildModal(draft.Step, draft.ChatId); + if (modal is not null) + { + await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal)); + return; + } + } } - finally + catch (Exception ex) { - // The wizard sets draft.Step to the next step; restore the - // user's current step for state correctness in the next - // button click (e.g. if they press Back they should land - // on the step they were on, not the post-modal step). - draft.Step = originalStep; + _log.LogWarning(ex, "Modal popup failed for step {Step}; falling back to ack.", draft.Step); } + // No popup needed — the wizard's edit is the only visible + // feedback. Acknowledge with a deferred message so Discord + // doesn't show "Application did not respond". + await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage( + MessageFlags.Ephemeral)); } // ── Helpers ─────────────────────────────────────────────────────── @@ -394,54 +448,6 @@ public sealed class WizardInteractionDispatcher _ => modalStep, }; - private async Task MaybeOpenModalAsync(Interaction interaction, WizardDraft draft, CancellationToken ct) - { - // The wizard has just advanced draft.Step. Re-render the step - // locally to discover the OpenModalStep hint, then send the - // modal as a follow-up. We use SendResponseAsync's deferred - // pattern: the wizard's edit already happened, this is the - // post-edit prompt. - try - { - var clubs = draft.Step == WizardStepNames.PickClub - ? await LoadClubsAsync(draft, ct) - : null; - var render = DiscordWizardStep.Render(draft, LoadPayload(draft), clubs); - if (string.IsNullOrEmpty(render.OpenModalStep)) - { - return; - } - var modal = DiscordWizardStep.BuildModal(render.OpenModalStep, draft.ChatId); - if (modal is null) - { - return; - } - await interaction.ModifyResponseAsync(msg => - { - // The modal callback is the wizard's "respond with - // modal" — NetCord only allows one response per - // interaction, so we replace the deferred "empty" - // response with the modal. This is the documented - // pattern for "open a modal as a follow-up to a button - // click" (the wizard's REST edit to the draft message - // already happened in HandleInteractionAsync). - _ = msg; // unreachable - }); - // We can't actually swap a deferred-message response for a - // modal — NetCord locks the response type after SendResponse. - // Instead we expose a follow-up modal: NetCord's - // InteractionCallback.Modal can be returned as the - // FollowupMessage? No — the only way to open a modal is - // the interaction's response. Since we already deferred - // a message, the modal path here is a no-op. - _ = modal; // surfaced via BuildModal for the next iteration - } - catch (Exception ex) - { - _log.LogWarning(ex, "MaybeOpenModalAsync swallowed an exception (non-fatal)."); - } - } - private async Task?> LoadClubsAsync(WizardDraft draft, CancellationToken ct) { // The wizard's GetOwnerClubsAsync would do this for us, but the @@ -457,8 +463,6 @@ public sealed class WizardInteractionDispatcher : System.Text.Json.JsonSerializer.Deserialize( draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload(); - private DiscordWizardMessenger ResolveMessenger() => _messenger; - private static async Task AckWithErrorAsync(Interaction interaction, string text) { try