fix(discord): open modal popup after wizard state advance

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.
This commit is contained in:
Coder
2026-06-05 18:53:59 +03:00
parent f0952096f3
commit b1bd47f6c1
2 changed files with 304 additions and 311 deletions
+132 -143
View File
@@ -1,157 +1,146 @@
# Issue #112 — Wizard platform-neutral refactor # Discord wizard adapter — issue #112
## Summary ## Summary
Moved the game-creation wizard's state machine and view builder from `GmRelay.Bot`
to `GmRelay.Shared`, replacing the Telegram-typed `ITelegramWizardMessenger` /
`Update` / `Message` surface with a platform-neutral `IWizardMessenger` /
`WizardInteraction` contract. Telegram continues to work unchanged through a
new `TelegramWizardMessenger` adapter and `WizardInteractionMapper`. The wizard
core is now ready for a future Discord adapter without touching the state
machine, and a `platform` column on `wizard_drafts` discriminates drafts from
different messengers.
## Branch Implemented the Discord side of the platform-neutral game/pool creation
`feat/issue-112-wizard-refactor` (off `main`). 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.
## Changed files ## Files
### New (Shared — platform-neutral wizard core) Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs`
`IWizardMessenger` (Edit/Send/Answer/GetOwnerClubs), `WizardAction` +
`WizardActionStyle`, `WizardKeyboard`, `WizardClubOption`, `WizardInteraction`,
`IWizardDraftRepository` (now takes `platform, ownerId`).
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs`
`MaxTitleLength`, `MaxDescriptionLength`, `MaxSystemLength`, capacity and
duration bounds (moved out of the Bot-only `WizardStep`).
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
— moved from Bot (unchanged content).
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs`
— moved from Bot (unchanged content; `wizard:cancel` / `wizard:back` /
`wizard:choice:step:value` format preserved for back-compat).
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs`
— moved from Bot (unchanged content).
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs`
— new platform-neutral view builder; produces
`(string Text, IReadOnlyList<WizardAction> Actions)` for each step.
Replaces the Telegram-only `WizardStep.Render(text, InlineKeyboardMarkup)`.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
— moved from Bot; rewritten to take `IWizardMessenger` +
`IWizardDraftRepository` and a `WizardInteraction` (no more
`Update`/`Message`/`CallbackQuery`).
### Updated (Shared) - `DiscordWizardContextStore.cs``IWizardContextStore` interface +
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs` thread-safe in-memory store. Keyed by `draft.Id`. Holds the
`ChatId`, `MessageThreadId`, `OwnerId`, `DraftMessageId` switched to `(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
`string?` to fit both Telegram and Discord ids; new `Platform` field needs to re-send a draft after a 15-minute interaction token
(defaults to `"Telegram"` for backward compatibility with pre-V032 rows). expires.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` - `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
`GetActiveAsync(platform, ownerId)`; selects/inserts the new `platform` an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
column and the renamed `owner_id`. `ActionRow` buttons with `StringMenu` select menus) and exposes
- (Deleted) `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs` `BuildModal` for the 8 modal-collecting steps.
— merged into `IWizardMessenger.cs` to keep wizard contracts colocated. - `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".
### New (Bot — Telegram adapter) DI / tests:
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs`
— converts `Telegram.Bot.Types.Update``WizardInteraction`. The single
bridge between Telegram's update type and the platform-neutral wizard.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs`
— rewritten to implement `IWizardMessenger`; serialises
`(WizardDraft, text, IReadOnlyList<WizardAction>)` to Telegram
`EditMessageText` / `SendMessage` / `AnswerCallbackQuery`. Club lookup
SQL unchanged.
### Updated (Bot) - `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs` (`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
— kept as the Telegram-side keyboard renderer. Re-exports the `GameCreationWizard`, `DiscordWizardSubmitter`,
`WizardStepLimits` constants (so legacy call sites still work) and now `WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
delegates to `WizardStepViewBuilder.Build(...)` for the (text, actions) `DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
pair, then converts actions to `InlineKeyboardMarkup`. 3 `AddComponentInteractions<TInteraction, TContext>` calls
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs` (Button, StringMenu, Modal).
— depends on `IWizardMessenger`; `StartWizardAsync` / `SubmitDraftAsync` - `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
use the new messenger contract; sets `Platform = "Telegram"` on the — 12 source-level structural smoke tests: handler classes exist,
draft; uses string ids for `ChatId` / `MessageThreadId` / `OwnerId`. all 3 derive from `ComponentInteractionModule<TContext>`, all 3
- `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` — takes the register `[ComponentInteraction("wizard")]`, the dispatcher
Shared `GameCreationWizard`; uses `WizardInteractionMapper.TryMap` to exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
feed the wizard with a platform-neutral `WizardInteraction`. Draft `HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
lookup uses `(platform, ownerId)`. `cancel` / `create` / `resume`) are routed, the dispatcher
- `src/GmRelay.Bot/Program.cs` — DI: `IWizardMessenger` invokes `GameCreationWizard.HandleInteractionAsync` and
`TelegramWizardMessenger`; `GameCreationWizard` resolved from Shared. `DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` registers all 3 `AddComponentInteractions` and all 4 module
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs` classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs` walks `Components[0] → TextInput → .Value`, string menu reads
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs` `SelectedValues[0]`.
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs`
### New (Bot migrations) ## Custom-id wire format
- `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
— adds `platform TEXT NOT NULL DEFAULT 'Telegram'`; converts `chat_id`,
`message_thread_id`, `draft_message_id` from numeric types to `TEXT`;
renames `owner_telegram_id``owner_id` and converts to `TEXT`; rebuilds
the owner lookup index to use `(platform, owner_id)`.
### Tests | Interaction | Custom-id | Handler |
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs` |------------------------|------------------------------------------|-------------------------------------------------|
`FakeWizardMessenger` now implements `IWizardMessenger`; `NewDraft` | Choice button | `wizard:btn:choice:<step>:<value>` | Wizard's `ApplyChoice` |
sets `Platform = "Telegram"` and uses string ids; new helper | Back button | `wizard:btn:back` | Wizard's `ApplyBack` |
factories `CallbackInteraction`, `TextInteraction`, `PhotoInteraction` | Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" |
build the platform-neutral `WizardInteraction`; legacy | Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
`CallbackUpdate` / `TextUpdate` helpers preserved for router-level | Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger |
tests that still consume `Update`. | Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run |
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs` | StringSelectMenu | `wizard:select:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
— schema updated to TEXT columns + `platform` column. | Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs`
— uses the new `GetActiveAsync(platform, ownerId)` signature and string
ids.
- All wizard / create-session test files updated to call
`wizard.HandleInteractionAsync(...)` with a `WizardInteraction` instead
of `wizard.HandleUpdateAsync(Update, …)`.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs` (new)
— 5 cases covering callback / text / photo / captioned-photo / empty
updates.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs`
`NewDraft` updated to use string `ChatId`.
## Verification 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.
- `dotnet build` — full solution builds with 0 warnings, 0 errors. ## Acceptance
- `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
568/571 pass (2 pre-existing `[Fact(Skip = …)]` happy-path tests
skipped, 1 pre-existing test failure in
`CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion`
which expects the old `v3.7.1` string in `NavMenu.razor` after the
3.8.0 release bump in commit `71080ae` — unrelated to this refactor).
All 101 wizard / create-session tests pass.
- `dotnet format --verify-no-changes` — clean.
- `git grep "Telegram.Bot" src/GmRelay.Shared/` — empty.
- `git grep "NetCord" src/GmRelay.Bot/` — empty.
- `GameCreationWizard.cs` exists exactly once, in
`src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/`.
- `IWizardMessenger` lives in `src/GmRelay.Shared/...`;
`ITelegramWizardMessenger` is gone.
- `dotnet list package --vulnerable` — clean.
## Notes for the verifier -`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`).
- The callback-data wire format (`wizard:cancel`, `wizard:back`, PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
`wizard:create`, `wizard:choice:{step}:{value}`) is unchanged; the
router-level `wizard:resume` / `wizard:reset` controls are still ## Open questions
produced by `UpdateRouter`.
- `WizardDraft.DraftMessageId` switched from `long?` to `string?` so the - **Club lookup at the PickClub step** — `DiscordWizardMessenger.GetOwnerClubsAsync`
same column can hold Discord's 64-bit snowflakes. V032 converts queries `group_managers` for the user's owned/co-GM groups; the
`BIGINT → TEXT`; existing rows in the V031 schema (if any are still dispatcher's `WizardClubLookup` stub currently returns an empty list
live) survive the cast. (DB access goes through `DiscordWizardMessenger`, which is a sibling
- The `FakeWizardMessenger` keeps the long-typed recording shape of the dispatcher in DI). The actual `DiscordWizardMessenger.SendDraftMessageAsync`
(`Edits` / `Sends` tuples) so the existing assertions in path also has the same gap: it doesn't pre-populate clubs. At runtime
`CreateSessionHandlerSubmitMissingFieldsTests` etc. keep working the user sees "У вас нет клубов" at the PickClub step even if they
without rewrites. The fake converts `draft.ChatId` / actually own clubs. The fix is to plumb `NpgsqlDataSource` (or the
`draft.MessageThreadId` to long on the way out, matching the old test messenger itself) into the dispatcher.
contract. - **MaybeOpenModalAsync was a no-op in the previous commit.** The
- The Telegram-renderer Bot-side `WizardStep` keeps the same public fix: don't defer the interaction response before the wizard runs;
surface (`WizardStep.Render(draft, payload, clubs)` returning instead, run the wizard (which edits the draft embed), then send
`(string, InlineKeyboardMarkup)`) so call sites in the response as either `InteractionCallback.Modal(modalProperties)`
`UpdateRouter.TryHandleDraftControlCallbackAsync` and the (when the new step needs text input) or
`CreateSessionHandler` continue to work. The view layer behind it is `InteractionCallback.DeferredMessage()` (otherwise). NetCord locks
now `WizardStepViewBuilder`. the response type after the first `SendResponseAsync` call, so the
- A future `DiscordWizardMessenger` and `DiscordWizardInteractionMapper` fix requires NOT calling `DeferredMessage` upfront.
can be added without any change to the Shared wizard; this is - **Modal handler's free-text mapping is a hack.** Modal steps like
deliberately not part of this task (the plan calls it out as Task 2). `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)`.
@@ -19,7 +19,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// One class per interaction context type — NetCord's /// One class per interaction context type — NetCord's
/// <c>ComponentInteractionModule&lt;TContext&gt;</c> is single-context. All /// <c>ComponentInteractionModule&lt;TContext&gt;</c> is single-context. All
/// three classes share the same dispatch table (parse customId → load /// three classes share the same dispatch table (parse customId → load
/// draft → check owner → call the shared /// draft → call the shared
/// <see cref="SharedWizard.HandleInteractionAsync"/>) implemented in /// <see cref="SharedWizard.HandleInteractionAsync"/>) implemented in
/// <see cref="WizardInteractionDispatcher"/>. /// <see cref="WizardInteractionDispatcher"/>.
/// ///
@@ -81,8 +81,8 @@ public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalI
/// <summary> /// <summary>
/// Shared dispatch table for the three wizard interaction modules. /// Shared dispatch table for the three wizard interaction modules.
/// Owns all the stateful collaborators (drafts, context store, wizard /// Owns all the stateful collaborators (drafts, context store, wizard
/// state machine, submitter, reply cache) so the three NetCord module /// state machine, submitter, messenger, reply cache) so the three
/// shells can stay trivially thin. /// NetCord module shells can stay trivially thin.
/// </summary> /// </summary>
public sealed class WizardInteractionDispatcher public sealed class WizardInteractionDispatcher
{ {
@@ -112,12 +112,39 @@ public sealed class WizardInteractionDispatcher
_log = log; _log = log;
} }
/// <summary>
/// Steps that, after the wizard's state advance, expect the user
/// to fill a popup. The dispatcher uses this to decide whether
/// the interaction response is a Modal() (the user sees a popup)
/// or a DeferredMessage() (the wizard's edit is the only visible
/// feedback). Keep in sync with <see cref="DiscordWizardStep.OpenModalStep"/>
/// returns.
/// </summary>
private static readonly IReadOnlySet<string> StepsThatOpenModal = new HashSet<string>(StringComparer.Ordinal)
{
WizardStepNames.Title,
WizardStepNames.Description,
WizardStepNames.Cover,
WizardStepNames.DateTime,
WizardStepNames.Capacity,
WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolSlotCapacity,
"SystemFreeText",
"DurationFreeText",
"PoolSystemDurationFreeText",
};
// ── Button handler ──────────────────────────────────────────────── // ── Button handler ────────────────────────────────────────────────
public async Task HandleButtonAsync(ButtonInteractionContext context, string args) public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
{ {
// NetCord contexts don't expose a per-request CancellationToken; // NetCord only allows one response per interaction. The
// the REST calls already carry their own timeout, so we use // previous implementation deferred too early and then
// CancellationToken.None for the DB and HTTP calls. // tried to "swap" the deferred response for a Modal — which
// NetCord forbids. The new flow is: do the wizard work
// (which is a separate REST call to edit the draft message),
// THEN send the interaction response. The response is
// either a Modal popup (when the new step needs text input)
// or a plain DeferredMessage ack.
var ct = CancellationToken.None; var ct = CancellationToken.None;
// args looks like one of: // args looks like one of:
// "btn:choice:<step>:<value>" (a choice) // "btn:choice:<step>:<value>" (a choice)
@@ -146,24 +173,53 @@ public sealed class WizardInteractionDispatcher
return; return;
} }
// Acknowledge the click first so the user sees no lag; the // Special case: "create" doesn't go through the wizard — the
// wizard's edit is a separate REST call. The "back" / "cancel" // submitter edits the draft message directly with the result
// actions need to defer so the wizard's edits complete before // embed ("✅ Создано" or retry buttons). After the submitter
// the response window closes. // returns, ack the click so the user doesn't see "Application
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage()); // did not respond".
if (parts[1] == "create")
{
await _submitter.SubmitAsync(draft, ct);
_contextStore.Remove(draft.Id);
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
return;
}
// Special case: "resume" — the slash command's resume row
// gives the user a chance to keep or restart their active
// draft. The wizard has no built-in resume case, so we
// handle the two resume kinds directly.
if (parts[1] == "resume")
{
await HandleResumeAsync(context, parts, draft, ownerId, interactionId, ct);
return;
}
// Choice / back / cancel — route through the shared wizard.
string callback;
switch (parts[1]) switch (parts[1])
{ {
case "choice": case "choice":
{
if (parts.Length < 4) if (parts.Length < 4)
{ {
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка"); await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
return; return;
} }
var step = parts[2]; callback = WizardCallbackData.Choice(parts[2], parts[3]);
var value = parts[3]; break;
var callback = WizardCallbackData.Choice(step, value); case "back":
callback = WizardCallbackData.Back();
break;
case "cancel":
callback = WizardCallbackData.Cancel();
break;
default:
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
return;
}
var interaction = new WizardInteraction( var interaction = new WizardInteraction(
OwnerId: ownerId, OwnerId: ownerId,
Text: null, Text: null,
@@ -172,91 +228,56 @@ public sealed class WizardInteractionDispatcher
PhotoUrl: null, PhotoUrl: null,
InteractionId: interactionId); InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct); await _wizard.HandleInteractionAsync(interaction, draft, ct);
await MaybeOpenModalAsync(context.Interaction, draft, ct);
break; // After the wizard's state advance, decide the response.
} // The wizard's EditDraftMessageAsync already updated the
case "back": // draft embed; we just need the interaction response to
// either pop a modal or quietly ack.
if (parts[1] == "cancel")
{ {
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: WizardCallbackData.Back(),
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
await MaybeOpenModalAsync(context.Interaction, draft, ct);
break;
}
case "cancel":
{
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: WizardCallbackData.Cancel(),
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
_contextStore.Remove(draft.Id); _contextStore.Remove(draft.Id);
break;
} }
case "create": await RespondAfterWizardAsync(context, draft, ct);
{
// The submitter edits the draft message directly via
// the messenger's REST client, so we don't need the
// wizard's render here.
await _submitter.SubmitAsync(draft, ct);
_contextStore.Remove(draft.Id);
break;
} }
case "resume":
private async Task HandleResumeAsync(
ButtonInteractionContext context,
string[] parts,
WizardDraft draft,
string ownerId,
string interactionId,
CancellationToken ct)
{ {
// resume:continue = re-render the current step; resume:restart // resume:continue re-render the current step (the wizard
// = delete the existing draft and tell the user to re-run // itself doesn't know about resume, so we just edit the
// /newsession-wizard. // draft message via the messenger).
// resume:restart → delete the draft and prompt the user to
// re-run /newsession-wizard.
if (parts.Length >= 3 && parts[2] == "restart") if (parts.Length >= 3 && parts[2] == "restart")
{ {
await _drafts.DeleteAsync(draft.Id, ct); await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id); _contextStore.Remove(draft.Id);
await context.Interaction.ModifyResponseAsync(msg => await context.Interaction.SendResponseAsync(InteractionCallback.Message(
{ new InteractionMessageProperties()
msg.Content = "♻️ Мастер сброшен. Запустите /newsession-wizard заново."; .WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
msg.Flags = MessageFlags.Ephemeral; .WithFlags(MessageFlags.Ephemeral)));
}); return;
} }
else // continue
{
// continue: re-render the current step.
var payload = LoadPayload(draft); var payload = LoadPayload(draft);
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct)); var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
var interaction = new WizardInteraction( await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
OwnerId: ownerId, await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
Text: null, MessageFlags.Ephemeral));
CallbackPayload: "wizard:resume:continue",
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
// Use the wizard's edit path via a synthetic callback
// for the current step. The state machine has no
// special resume case — we just edit the embed.
var messenger = ResolveMessenger();
await messenger.EditDraftMessageAsync(draft, text, actions, ct);
_ = interaction; // suppress unused warning when not used below
}
break;
}
default:
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
break;
}
} }
// ── StringSelectMenu handler ─────────────────────────────────────── // ── StringSelectMenu handler ───────────────────────────────────────
public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args) public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args)
{ {
// NetCord contexts don't expose a per-request CancellationToken; // NetCord's interaction response type is locked after the
// use CancellationToken.None and let the REST timeout apply. // first SendResponse call. For menu selections we don't
// need a popup, so we always defer. The wizard's edit is
// a separate REST call.
var ct = CancellationToken.None; var ct = CancellationToken.None;
// args looks like "select:<step>" (e.g. "select:Visibility" or // args looks like "select:<step>" (e.g. "select:Visibility" or
// "select:PoolSystemDuration"). The chosen value lives in // "select:PoolSystemDuration"). The chosen value lives in
@@ -298,8 +319,10 @@ public sealed class WizardInteractionDispatcher
// ── Modal submit handler ────────────────────────────────────────── // ── Modal submit handler ──────────────────────────────────────────
public async Task HandleModalAsync(ModalInteractionContext context, string args) public async Task HandleModalAsync(ModalInteractionContext context, string args)
{ {
// NetCord contexts don't expose a per-request CancellationToken; // The modal text becomes the user's input. The wizard's
// use CancellationToken.None. // ApplyText dispatcher consumes it as either a text-input
// step (Title, Description, etc.) or, via the helper, a
// free-text variant of System/Duration/PoolSystemDuration.
var ct = CancellationToken.None; var ct = CancellationToken.None;
// args looks like "modal:<step>" (e.g. "modal:Title" or // args looks like "modal:<step>" (e.g. "modal:Title" or
// "modal:SystemFreeText"). The text value lives in // "modal:SystemFreeText"). The text value lives in
@@ -333,13 +356,10 @@ public sealed class WizardInteractionDispatcher
return; return;
} }
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
// Modal values are routed by step name. The shared wizard knows // Modal values are routed by step name. The shared wizard knows
// how to apply Title, Description, etc.; the free-text variants // how to apply Title, Description, etc.; the free-text variants
// (SystemFreeText, DurationFreeText, PoolSystemDurationFreeText) // (SystemFreeText, DurationFreeText, PoolSystemDurationFreeText)
// are mapped to the equivalent step here so the wizard's existing // are mapped to the canonical step here so the wizard's existing
// ApplyText dispatcher handles them. // ApplyText dispatcher handles them.
var wizardStep = MapModalStepToWizardStep(step); var wizardStep = MapModalStepToWizardStep(step);
var interaction = new WizardInteraction( var interaction = new WizardInteraction(
@@ -349,25 +369,59 @@ public sealed class WizardInteractionDispatcher
PhotoFileId: null, PhotoFileId: null,
PhotoUrl: null, PhotoUrl: null,
InteractionId: interactionId); InteractionId: interactionId);
// For free-text modal steps the wizard's "current step" is the // For free-text modal steps the wizard's "current step" is the
// canonical step (System, Duration, etc.), but the user just // canonical step (System, Duration, etc.), but the user just
// submitted via the free-text modal. Temporarily adjust the // submitted via the free-text modal. Temporarily set the
// draft so the wizard's ApplyText runs the right branch. // draft.Step to the canonical step so the wizard's ApplyText
var originalStep = draft.Step; // runs the right branch. The wizard then advances draft.Step
draft.Step = wizardStep; // to the NEXT step (e.g. Duration) and persists that via
try // _drafts.UpsertAsync. We must NOT restore draft.Step to
// the original value afterwards — the DB has already been
// updated to the new step, and restoring locally would only
// mask the truth from the next interaction's GetActiveAsync.
if (draft.Step != wizardStep)
{ {
draft.Step = wizardStep;
}
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
await _wizard.HandleInteractionAsync(interaction, draft, ct); await _wizard.HandleInteractionAsync(interaction, draft, ct);
} }
finally
// ── Response helper ───────────────────────────────────────────────
private async Task RespondAfterWizardAsync(
ButtonInteractionContext context,
WizardDraft draft,
CancellationToken ct)
{ {
// The wizard sets draft.Step to the next step; restore the // The wizard's state machine has advanced draft.Step. Re-render
// user's current step for state correctness in the next // the new step locally to discover whether it expects a popup,
// button click (e.g. if they press Back they should land // then send the appropriate response.
// on the step they were on, not the post-modal step). try
draft.Step = originalStep; {
if (StepsThatOpenModal.Contains(draft.Step))
{
var modal = DiscordWizardStep.BuildModal(draft.Step, draft.ChatId);
if (modal is not null)
{
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
return;
} }
} }
}
catch (Exception ex)
{
_log.LogWarning(ex, "Modal popup failed for step {Step}; falling back to ack.", draft.Step);
}
// No popup needed — the wizard's edit is the only visible
// feedback. Acknowledge with a deferred message so Discord
// doesn't show "Application did not respond".
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
}
// ── Helpers ─────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────
private static string ExtractModalText(ModalInteractionContext context) private static string ExtractModalText(ModalInteractionContext context)
@@ -394,54 +448,6 @@ public sealed class WizardInteractionDispatcher
_ => modalStep, _ => modalStep,
}; };
private async Task MaybeOpenModalAsync(Interaction interaction, WizardDraft draft, CancellationToken ct)
{
// The wizard has just advanced draft.Step. Re-render the step
// locally to discover the OpenModalStep hint, then send the
// modal as a follow-up. We use SendResponseAsync's deferred
// pattern: the wizard's edit already happened, this is the
// post-edit prompt.
try
{
var clubs = draft.Step == WizardStepNames.PickClub
? await LoadClubsAsync(draft, ct)
: null;
var render = DiscordWizardStep.Render(draft, LoadPayload(draft), clubs);
if (string.IsNullOrEmpty(render.OpenModalStep))
{
return;
}
var modal = DiscordWizardStep.BuildModal(render.OpenModalStep, draft.ChatId);
if (modal is null)
{
return;
}
await interaction.ModifyResponseAsync(msg =>
{
// The modal callback is the wizard's "respond with
// modal" — NetCord only allows one response per
// interaction, so we replace the deferred "empty"
// response with the modal. This is the documented
// pattern for "open a modal as a follow-up to a button
// click" (the wizard's REST edit to the draft message
// already happened in HandleInteractionAsync).
_ = msg; // unreachable
});
// We can't actually swap a deferred-message response for a
// modal — NetCord locks the response type after SendResponse.
// Instead we expose a follow-up modal: NetCord's
// InteractionCallback.Modal can be returned as the
// FollowupMessage? No — the only way to open a modal is
// the interaction's response. Since we already deferred
// a message, the modal path here is a no-op.
_ = modal; // surfaced via BuildModal for the next iteration
}
catch (Exception ex)
{
_log.LogWarning(ex, "MaybeOpenModalAsync swallowed an exception (non-fatal).");
}
}
private async Task<IReadOnlyList<WizardClubOption>?> LoadClubsAsync(WizardDraft draft, CancellationToken ct) private async Task<IReadOnlyList<WizardClubOption>?> LoadClubsAsync(WizardDraft draft, CancellationToken ct)
{ {
// The wizard's GetOwnerClubsAsync would do this for us, but the // The wizard's GetOwnerClubsAsync would do this for us, but the
@@ -457,8 +463,6 @@ public sealed class WizardInteractionDispatcher
: System.Text.Json.JsonSerializer.Deserialize( : System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload(); draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
private DiscordWizardMessenger ResolveMessenger() => _messenger;
private static async Task AckWithErrorAsync(Interaction interaction, string text) private static async Task AckWithErrorAsync(Interaction interaction, string text)
{ {
try try