Files

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.csIWizardContextStore 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<IMessageComponentProperties> (mixes ActionRow buttons with StringMenu select menus) and exposes BuildModal for the 8 modal-collecting steps.
  • DiscordWizardMessenger.csIWizardMessenger 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.csinbound handlers (this commit). Three NetCord ComponentInteractionModule<TContext> shells (button / StringSelectMenu / modal) share one WizardInteractionDispatcher that:
    1. 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>);
    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<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 from ComponentInteractionModule<TContext>, 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:<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 (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 stepFIXED 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 commitFIXED 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