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.
8.2 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 —
DiscordWizardMessenger.GetOwnerClubsAsyncqueriesgroup_managersfor the user's owned/co-GM groups; the dispatcher'sWizardClubLookupstub currently returns an empty list (DB access goes throughDiscordWizardMessenger, which is a sibling of the dispatcher in DI). The actualDiscordWizardMessenger.SendDraftMessageAsyncpath 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 plumbNpgsqlDataSource(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) orInteractionCallback.DeferredMessage()(otherwise). NetCord locks the response type after the firstSendResponseAsynccall, so the fix requires NOT callingDeferredMessageupfront. - 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).