feat(discord): step-by-step game/pool creation wizard (issue #112) #122

Merged
Toutsu merged 8 commits from feat/issue-112-wizard-refactor into main 2026-06-06 08:04:16 +03:00
2 changed files with 304 additions and 311 deletions
Showing only changes of commit b1bd47f6c1 - Show all commits
+132 -143
View File
@@ -1,157 +1,146 @@
# Issue #112 — Wizard platform-neutral refactor
# Discord wizard adapter — issue #112
## 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
`feat/issue-112-wizard-refactor` (off `main`).
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.
## Changed files
## Files
### New (Shared — platform-neutral wizard core)
- `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`).
Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
### Updated (Shared)
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs`
`ChatId`, `MessageThreadId`, `OwnerId`, `DraftMessageId` switched to
`string?` to fit both Telegram and Discord ids; new `Platform` field
(defaults to `"Telegram"` for backward compatibility with pre-V032 rows).
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs`
`GetActiveAsync(platform, ownerId)`; selects/inserts the new `platform`
column and the renamed `owner_id`.
- (Deleted) `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs`
— merged into `IWizardMessenger.cs` to keep wizard contracts colocated.
- `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".
### New (Bot — Telegram adapter)
- `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.
DI / tests:
### Updated (Bot)
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs`
— kept as the Telegram-side keyboard renderer. Re-exports the
`WizardStepLimits` constants (so legacy call sites still work) and now
delegates to `WizardStepViewBuilder.Build(...)` for the (text, actions)
pair, then converts actions to `InlineKeyboardMarkup`.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
— depends on `IWizardMessenger`; `StartWizardAsync` / `SubmitDraftAsync`
use the new messenger contract; sets `Platform = "Telegram"` on the
draft; uses string ids for `ChatId` / `MessageThreadId` / `OwnerId`.
- `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` — takes the
Shared `GameCreationWizard`; uses `WizardInteractionMapper.TryMap` to
feed the wizard with a platform-neutral `WizardInteraction`. Draft
lookup uses `(platform, ownerId)`.
- `src/GmRelay.Bot/Program.cs` — DI: `IWizardMessenger`
`TelegramWizardMessenger`; `GameCreationWizard` resolved from Shared.
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs`
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs`
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs`
- `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]`.
### New (Bot migrations)
- `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)`.
## Custom-id wire format
### Tests
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs`
`FakeWizardMessenger` now implements `IWizardMessenger`; `NewDraft`
sets `Platform = "Telegram"` and uses string ids; new helper
factories `CallbackInteraction`, `TextInteraction`, `PhotoInteraction`
build the platform-neutral `WizardInteraction`; legacy
`CallbackUpdate` / `TextUpdate` helpers preserved for router-level
tests that still consume `Update`.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs`
— schema updated to TEXT columns + `platform` column.
- `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`.
| 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) |
## 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.
- `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.
## Acceptance
## 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`,
`wizard:create`, `wizard:choice:{step}:{value}`) is unchanged; the
router-level `wizard:resume` / `wizard:reset` controls are still
produced by `UpdateRouter`.
- `WizardDraft.DraftMessageId` switched from `long?` to `string?` so the
same column can hold Discord's 64-bit snowflakes. V032 converts
`BIGINT → TEXT`; existing rows in the V031 schema (if any are still
live) survive the cast.
- The `FakeWizardMessenger` keeps the long-typed recording shape
(`Edits` / `Sends` tuples) so the existing assertions in
`CreateSessionHandlerSubmitMissingFieldsTests` etc. keep working
without rewrites. The fake converts `draft.ChatId` /
`draft.MessageThreadId` to long on the way out, matching the old test
contract.
- The Telegram-renderer Bot-side `WizardStep` keeps the same public
surface (`WizardStep.Render(draft, payload, clubs)` returning
`(string, InlineKeyboardMarkup)`) so call sites in
`UpdateRouter.TryHandleDraftControlCallbackAsync` and the
`CreateSessionHandler` continue to work. The view layer behind it is
now `WizardStepViewBuilder`.
- A future `DiscordWizardMessenger` and `DiscordWizardInteractionMapper`
can be added without any change to the Shared wizard; this is
deliberately not part of this task (the plan calls it out as Task 2).
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
## Open questions
- **Club lookup at the PickClub step** — `DiscordWizardMessenger.GetOwnerClubsAsync`
queries `group_managers` for the user's owned/co-GM groups; the
dispatcher's `WizardClubLookup` stub currently returns an empty list
(DB access goes through `DiscordWizardMessenger`, which is a sibling
of the dispatcher in DI). The actual `DiscordWizardMessenger.SendDraftMessageAsync`
path 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 plumb `NpgsqlDataSource` (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) or
`InteractionCallback.DeferredMessage()` (otherwise). NetCord locks
the response type after the first `SendResponseAsync` call, so the
fix requires NOT calling `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)`.
@@ -19,7 +19,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// One class per interaction context type — NetCord's
/// <c>ComponentInteractionModule&lt;TContext&gt;</c> is single-context. All
/// 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="WizardInteractionDispatcher"/>.
///
@@ -81,8 +81,8 @@ public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalI
/// <summary>
/// Shared dispatch table for the three wizard interaction modules.
/// Owns all the stateful collaborators (drafts, context store, wizard
/// state machine, submitter, reply cache) so the three NetCord module
/// shells can stay trivially thin.
/// state machine, submitter, messenger, reply cache) so the three
/// NetCord module shells can stay trivially thin.
/// </summary>
public sealed class WizardInteractionDispatcher
{
@@ -112,12 +112,39 @@ public sealed class WizardInteractionDispatcher
_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 ────────────────────────────────────────────────
public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
{
// NetCord contexts don't expose a per-request CancellationToken;
// the REST calls already carry their own timeout, so we use
// CancellationToken.None for the DB and HTTP calls.
// NetCord only allows one response per interaction. The
// previous implementation deferred too early and then
// 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;
// args looks like one of:
// "btn:choice:<step>:<value>" (a choice)
@@ -146,117 +173,111 @@ public sealed class WizardInteractionDispatcher
return;
}
// Acknowledge the click first so the user sees no lag; the
// wizard's edit is a separate REST call. The "back" / "cancel"
// actions need to defer so the wizard's edits complete before
// the response window closes.
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
// Special case: "create" doesn't go through the wizard — the
// submitter edits the draft message directly with the result
// embed ("✅ Создано" or retry buttons). After the submitter
// returns, ack the click so the user doesn't see "Application
// 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])
{
case "choice":
if (parts.Length < 4)
{
if (parts.Length < 4)
{
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
return;
}
var step = parts[2];
var value = parts[3];
var callback = WizardCallbackData.Choice(step, value);
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: callback,
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
await MaybeOpenModalAsync(context.Interaction, draft, ct);
break;
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
return;
}
callback = WizardCallbackData.Choice(parts[2], parts[3]);
break;
case "back":
{
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;
}
callback = WizardCallbackData.Back();
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);
break;
}
case "create":
{
// 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":
{
// resume:continue = re-render the current step; resume:restart
// = delete the existing draft and tell the user to re-run
// /newsession-wizard.
if (parts.Length >= 3 && parts[2] == "restart")
{
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
await context.Interaction.ModifyResponseAsync(msg =>
{
msg.Content = "♻️ Мастер сброшен. Запустите /newsession-wizard заново.";
msg.Flags = MessageFlags.Ephemeral;
});
}
else
{
// continue: re-render the current step.
var payload = LoadPayload(draft);
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
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;
}
callback = WizardCallbackData.Cancel();
break;
default:
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
break;
return;
}
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: callback,
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
// After the wizard's state advance, decide the response.
// The wizard's EditDraftMessageAsync already updated the
// draft embed; we just need the interaction response to
// either pop a modal or quietly ack.
if (parts[1] == "cancel")
{
_contextStore.Remove(draft.Id);
}
await RespondAfterWizardAsync(context, draft, ct);
}
private async Task HandleResumeAsync(
ButtonInteractionContext context,
string[] parts,
WizardDraft draft,
string ownerId,
string interactionId,
CancellationToken ct)
{
// resume:continue → re-render the current step (the wizard
// itself doesn't know about resume, so we just edit the
// 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")
{
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
// continue
var payload = LoadPayload(draft);
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
}
// ── StringSelectMenu handler ───────────────────────────────────────
public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args)
{
// NetCord contexts don't expose a per-request CancellationToken;
// use CancellationToken.None and let the REST timeout apply.
// NetCord's interaction response type is locked after the
// 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;
// args looks like "select:<step>" (e.g. "select:Visibility" or
// "select:PoolSystemDuration"). The chosen value lives in
@@ -298,8 +319,10 @@ public sealed class WizardInteractionDispatcher
// ── Modal submit handler ──────────────────────────────────────────
public async Task HandleModalAsync(ModalInteractionContext context, string args)
{
// NetCord contexts don't expose a per-request CancellationToken;
// use CancellationToken.None.
// The modal text becomes the user's input. The wizard's
// 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;
// args looks like "modal:<step>" (e.g. "modal:Title" or
// "modal:SystemFreeText"). The text value lives in
@@ -333,13 +356,10 @@ public sealed class WizardInteractionDispatcher
return;
}
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
// Modal values are routed by step name. The shared wizard knows
// how to apply Title, Description, etc.; the free-text variants
// (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.
var wizardStep = MapModalStepToWizardStep(step);
var interaction = new WizardInteraction(
@@ -349,24 +369,58 @@ public sealed class WizardInteractionDispatcher
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
// For free-text modal steps the wizard's "current step" is the
// canonical step (System, Duration, etc.), but the user just
// submitted via the free-text modal. Temporarily adjust the
// draft so the wizard's ApplyText runs the right branch.
var originalStep = draft.Step;
draft.Step = wizardStep;
// submitted via the free-text modal. Temporarily set the
// draft.Step to the canonical step so the wizard's ApplyText
// runs the right branch. The wizard then advances draft.Step
// to the NEXT step (e.g. Duration) and persists that via
// _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);
}
// ── Response helper ───────────────────────────────────────────────
private async Task RespondAfterWizardAsync(
ButtonInteractionContext context,
WizardDraft draft,
CancellationToken ct)
{
// The wizard's state machine has advanced draft.Step. Re-render
// the new step locally to discover whether it expects a popup,
// then send the appropriate response.
try
{
await _wizard.HandleInteractionAsync(interaction, draft, ct);
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;
}
}
}
finally
catch (Exception ex)
{
// The wizard sets draft.Step to the next step; restore the
// user's current step for state correctness in the next
// button click (e.g. if they press Back they should land
// on the step they were on, not the post-modal step).
draft.Step = originalStep;
_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 ───────────────────────────────────────────────────────
@@ -394,54 +448,6 @@ public sealed class WizardInteractionDispatcher
_ => 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)
{
// The wizard's GetOwnerClubsAsync would do this for us, but the
@@ -457,8 +463,6 @@ public sealed class WizardInteractionDispatcher
: System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
private DiscordWizardMessenger ResolveMessenger() => _messenger;
private static async Task AckWithErrorAsync(Interaction interaction, string text)
{
try