# 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**~~ — **FIXED in commit `7cfb196`.** `WizardClubLookup.LoadClubsAsync` now queries `group_managers` directly via injected `NpgsqlDataSource` with the same `Owner | CoGm` role filter the messenger uses. The dispatcher reads the owner's real club list and renders them in the StringSelectMenu. Build green, 12 source-level smoke tests still pass. - ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.** The dispatcher now runs the wizard first (which edits the draft embed), then sends 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 is NOT to call `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)`. ## Final commit history on `feat/issue-112-wizard-refactor` - `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types) - `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command) - `f095209` — Task 2: interaction module + modal popup fix - `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor