154 lines
8.4 KiB
Markdown
154 lines
8.4 KiB
Markdown
# 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<IMessageComponentProperties>` (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<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 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
|