Files
GmRelayBot/deliverable.md

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