8.4 KiB
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—IWizardContextStoreinterface + thread-safe in-memory store. Keyed bydraft.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<IMessageComponentProperties>(mixesActionRowbuttons withStringMenuselect menus) and exposesBuildModalfor the 8 modal-collecting steps.DiscordWizardMessenger.cs—IWizardMessengerimpl. UsesRestClient.SendMessageAsync/ModifyMessageAsync(edit falls back to re-send on 401/403/404). Toast replies are stashed in the existingDiscordInteractionReplyCache.DiscordWizardSubmitter.cs— 3-retry finalize loop. Builds the sharedCreateSessionCommandand callsCreateSessionHandler; on success edits the draft to "✅ Создано: N сессий", on failure shows retry/cancel buttons.DiscordWizardCommand.cs—/newsession-wizardslash command. Owner/co-GM check viagroup_managers(DiscordPermissionLookup).DiscordPermissionLookup.cs— small DB helper that loadsgroup_managersrows for a guild.DiscordWizardInteractionModule.cs— inbound handlers (this commit). Three NetCordComponentInteractionModule<TContext>shells (button / StringSelectMenu / modal) share oneWizardInteractionDispatcherthat:- parses the custom-id tail (
btn:choice:<step>:<value>,btn:cancel,btn:back,btn:create,btn:resume:continue,btn:resume:restart,select:<step>,modal:<step>); - loads the active draft via
IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct); - routes the callback through the shared
GameCreationWizard.HandleInteractionAsync(orDiscordWizardSubmitter.SubmitAsyncfor thecreatebutton); - 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".
- parses the custom-id tail (
DI / tests:
src/GmRelay.DiscordBot/Program.cs— 7 singleton registrations (IWizardDraftRepository,IWizardContextStore,IWizardMessenger,GameCreationWizard,DiscordWizardSubmitter,WizardInteractionDispatcher,DiscordWizardButtonModule,DiscordWizardStringMenuModule,DiscordWizardModalModule) plus 3AddComponentInteractions<TInteraction, TContext>calls (Button, StringMenu, Modal).tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs— 12 source-level structural smoke tests: handler classes exist, all 3 derive fromComponentInteractionModule<TContext>, all 3 register[ComponentInteraction("wizard")], the dispatcher exposesHandleButtonAsync/HandleStringMenuAsync/HandleModalAsync, all 5 callback kinds (choice/back/cancel/create/resume) are routed, the dispatcher invokesGameCreationWizard.HandleInteractionAsyncandDiscordWizardSubmitter.SubmitAsynconcreate, Program.cs registers all 3AddComponentInteractionsand all 4 module classes, draft lookup is byGetActiveAsync("Discord", …), modal walksComponents[0] → TextInput → .Value, string menu readsSelectedValues[0].
Custom-id wire format
| Interaction | Custom-id | Handler |
|---|---|---|
| Choice button | wizard:btn:choice:<step>:<value> |
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:<step> |
Wizard's ApplyChoice (step, SelectedValues[0]) |
| Modal submit | wizard:modal:<step> |
Wizard's ApplyText (Text = Component[0].Value) |
The wizard renderer (DiscordWizardStep) owns the prefix generation:
wizard:btn:<step>:<value>, wizard:select:<step>,
wizard:modal:<step>. 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(commitsb81d865,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 commit7cfb196.WizardClubLookup.LoadClubsAsyncnow queriesgroup_managersdirectly via injectedNpgsqlDataSourcewith the sameOwner | CoGmrole 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 commitf095209. The dispatcher now runs the wizard first (which edits the draft embed), then sends the response as eitherInteractionCallback.Modal(modalProperties)(when the new step needs text input) orInteractionCallback.DeferredMessage()(otherwise). NetCord locks the response type after the firstSendResponseAsynccall, so the fix is NOT to callDeferredMessageupfront.- Modal handler's free-text mapping is a hack. Modal steps like
SystemFreeText,DurationFreeText,PoolSystemDurationFreeTextare mapped to the canonical wizard step (System,Duration,PoolSystemDuration) inMapModalStepToWizardStep. This works because the wizard'sApplyTextdispatches on the canonical step name, but a future refactor ofApplyTextto know about the free-text step names would break this. The clean fix is to add dedicated "free text" steps toWizardStepNames. - 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 fix7cfb196— 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