# Discord wizard adapter — issue #112 ## Summary 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. ## Files Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`): - `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". DI / tests: - `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]`. ## Custom-id wire format | 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) | 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. ## Acceptance - ✅ `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`). 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)`.