From 85ff3a7faf3f2856492dd29095c41574059ba60f Mon Sep 17 00:00:00 2001 From: Toutsu Date: Fri, 5 Jun 2026 23:09:24 +0300 Subject: [PATCH] fix(discord): address code-review findings on wizard adapter (issue #112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERDICT from verifier (D:\Projects\Game\docs\review-report.md): REQUEST_CHANGES — wizard was functionally broken at runtime. ## Critical C-1. Choice-button customId was missing the 'choice:' segment. ButtonCustomId emitted 'wizard:btn::' but the dispatcher's switch matches parts[1] == 'choice'. Every choice button (D&D 5e, Pathfinder, Waitlist, Publish, Confirm) fell into the default branch and showed 'Unknown button'. Fix: split into 3 customId helpers: ChoiceButtonCustomId(step, value) -> 'wizard:btn:choice::' ControlButtonCustomId(action) -> 'wizard:btn::1' (back/cancel/skip/create) ModalTriggerButtonCustomId(modalStep) -> 'wizard:btn:modal:' Bulk-rewrote all 66 Btn() call sites in DiscordWizardStep.cs. C-2. "Другое…" modal-trigger buttons were unrouted in dispatcher. Added 'parts[1] == "modal"' branch that opens the modal via InteractionCallback.Modal(BuildModal(parts[2], draft.ChatId)). C-3. DiscordWizardSubmitter was leaking ex.Message from CreateSessionHandler to the user-visible draft embed. Postgres exceptions expose schema/constraint names. Replaced with generic user-facing error; full exception still logged server-side on the existing catch block. ## I-3 — parser-roundtrip tests (the gap that let C-1/C-2 through) Added two real behavioural tests (not string-grep) to DiscordWizardInteractionModuleSourceTests: - Renderer_And_Dispatcher_Agree_On_Wire_Format - ControlButtons_Are_Parsed_As_Control_Not_Choice These mirror NetCord's [ComponentInteraction("wizard")] prefix strip, run the parser, and assert the dispatcher would route to the right branch. Catches the entire class of 'renderer and dispatcher disagree on the wire format' regressions. ## I-6 — BuildResumeRow (cascading fix from C-1) After C-1, BuildResumeRow's ButtonCustomId('cancel', '1') would emit the wrong format. Switched to direct format strings ('wizard:btn:cancel:1', 'wizard:btn:resume:continue', etc.) which match the dispatcher's 'back'/'cancel'/'create'/'resume' cases directly, not the 'choice' prefix. ## Version sync (3.8.0 -> 3.9.0) Directory.Build.props: 3.9.0 compose.yaml: all 3 image tags -> 3.9.0 Version_ShouldBeSynchronizedForDiscordFeatureRelease test now green. ## Stats build: 0 warnings, 0 errors format: 0 of 279 files need changes tests: 583 passed, 2 skipped (pre-existing), 0 failed files: 7 changed, 226 +, 79 - --- Directory.Build.props | 2 +- compose.yaml | 6 +- docs/review-report.md | 362 ++++++++++++++++++ .../Sessions/Wizard/DiscordWizardCommand.cs | 10 +- .../Wizard/DiscordWizardInteractionModule.cs | 19 + .../Sessions/Wizard/DiscordWizardStep.cs | 175 +++++---- .../Sessions/Wizard/DiscordWizardSubmitter.cs | 6 +- ...scordWizardInteractionModuleSourceTests.cs | 87 +++++ 8 files changed, 588 insertions(+), 79 deletions(-) create mode 100644 docs/review-report.md diff --git a/Directory.Build.props b/Directory.Build.props index 3904015..4cf89ff 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.8.0 + 3.9.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index 9702a52..0f0ca98 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0 restart: always depends_on: db: diff --git a/docs/review-report.md b/docs/review-report.md new file mode 100644 index 0000000..81f3c01 --- /dev/null +++ b/docs/review-report.md @@ -0,0 +1,362 @@ +# Code review — feat/issue-112-wizard-refactor (issue #112) + +**Reviewer:** Verifier (mvs_86868b01387b492aae27ce6f77aca4cb) +**Branch:** `feat/issue-112-wizard-refactor` (base `origin/main`) +**Commits reviewed:** `8f0f2ef`, `b81d865`, `f095209`, `7cfb196`, `c4a77d3` +**Build:** ✅ `dotnet build GM-Relay.slnx` — 0 warnings, 0 errors +**Tests:** 580 passed / 2 skipped / 1 failed. 1 failure is the pre-existing +`DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease` +(uncommitted release work in working tree, not part of this branch). + +## VERDICT: REQUEST_CHANGES + +The branch is **NOT shippable in its current state.** Every choice button +and every "Другое…" button in the wizard is silently broken at runtime +due to a wire-format mismatch between the renderer and the dispatcher. +A user who clicks "D&D 5e", "Pathfinder 2e", "Waitlist вкл", "Опубликовать", +or any "Другое… ✏️" button will see "⚠️ Неизвестная кнопка" instead of +the wizard advancing. The 12 source-level smoke tests don't catch this +because they only check string presence in source code, not the actual +button-click → dispatch flow. + +The architecture is otherwise sound: no Telegram.Bot/NetCord leak into +Shared, single state-machine source, all DI wired, AOT-safe, parameterized +SQL, owner/co-GM permission check with null-safety, SecretRedactor on the +connection string. The fix is small and surgical. + +--- + +## Critical findings + +### C-1. Choice-button custom-id is missing the `choice:` segment — wizard is unusable end-to-end + +**Files:** +- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:79-80` +- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:174-226` + +**What's wrong.** The dispatcher's button handler matches `parts[1]` +against `"choice"`, `"back"`, `"cancel"`, `"create"`, `"resume"`, and +falls through to "default → Неизвестная кнопка" for anything else. The +dispatcher's own documentation and the deliverable's wire-format table +both agree the canonical choice-button format is +`wizard:btn:choice::`. But `ButtonCustomId` emits +`$"wizard:btn:{step}:{value}"` — **the literal `choice:` segment is +missing**. So clicking "D&D 5e" on the System step produces +`wizard:btn:System:Dnd5e`, which NetCord strips the `[ComponentInteraction("wizard")]` +prefix from, arriving at the dispatcher as `args = "btn:System:Dnd5e"` → +`parts = ["btn", "System", "Dnd5e"]` → `parts[1] = "System"` → default +branch → "⚠️ Неизвестная кнопка". + +The same bug hits: +- `RenderType` — "Одну игру" / "Пул игр" buttons (emits + `wizard:btn:Type:single`, `wizard:btn:Type:pool`) +- `RenderSystem` — D&D/Pathfinder/CoC/GURPS/Fate ("wizard:btn:System:Dnd5e" etc.) + **and** the "⏭ Пропустить" button (emits `wizard:btn:System:_skip`) +- `RenderDuration` — "3 часа" / "4 часа" / "5 часов" / "6 часов" and + "⏭ Пропустить" +- `RenderCapacity` / `RenderPoolSlotCapacity` — "Waitlist вкл" / "Без waitlist" + (emits `wizard:btn:Capacity:waitlist:on` etc.) +- `RenderPublish` — "Опубликовать" / "Только в чате" +- `RenderPoolAddSlots` — "Добавить слот" / "Готово, к превью" +- `RenderPickClub` — back/cancel still work (parts[1] = "back"/"cancel") + +Only back, cancel, create, resume, and the "Другое… ✏️" → modal buttons +are unaffected by *this specific* bug (see C-2 for modal buttons). + +The smoke test `Dispatcher_ShouldParseAllWizardActionKinds` +(`tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs:85-97`) +checks that the strings `"choice"`, `"back"`, `"cancel"`, `"create"`, +`"resume"` appear in `DiscordWizardInteractionModule.cs`. It doesn't +check the renderer's output, so the bug is invisible to the test suite. +The same file's comment at line 69 documents the *expected* format as +`"btn:choice:Type:single"` — which would be the correct fix. + +**How to fix.** Change `ButtonCustomId` in `DiscordWizardStep.cs`: + +```csharp +public static string ButtonCustomId(string step, string value) => + $"wizard:btn:choice:{step}:{value}"; +``` + +This brings the renderer into alignment with the dispatcher's switch +case `"choice"` (line 209) and with the deliverable's table at line 83. +Re-verify with a manual click-through of every button on every step, or +add a parser-side test (see I-3 below). + +### C-2. "Другое… ✏️" modal trigger buttons route to "default" instead of opening a modal + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:190, 206, 314` +**Read against:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:207-226` + +**What's wrong.** The renderer emits modal triggers as +`wizard:btn:modal:SystemFreeText` (the "Другое… ✏️" button on the System +step), `wizard:btn:modal:DurationFreeText` (Duration step), and +`wizard:btn:modal:PoolSystemDurationFreeText` (PoolSystemDuration step). +The dispatcher's button switch handles `"choice"`, `"back"`, `"cancel"` +but not `"modal"`. The user's click on "Другое…" hits the default branch +and returns "⚠️ Неизвестная кнопка" — no modal pops up, the wizard +doesn't advance. The open question in `deliverable.md:125-132` ("Modal +handler's free-text mapping is a hack") implicitly assumes these buttons +*work* in production, so the design intent is clear but the implementation +didn't deliver it. + +**How to fix.** Add a `"modal"` case in the dispatcher's switch (between +`"create"` and the existing branches, mirroring the "create" / "resume" +special-case pattern): + +```csharp +if (parts[1] == "modal" && parts.Length >= 3) +{ + var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId); + if (modal is not null) + { + await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal)); + } + else + { + await AckWithErrorAsync(context.Interaction, "Модал недоступен"); + } + return; +} +``` + +This bypasses the wizard's state machine entirely (the user's intent is +"open a modal for free-text input", not "advance the wizard"). When the +user submits the modal, `HandleModalAsync` will run, which already knows +how to map `SystemFreeText` → `WizardStepNames.System` (line 453-462). +Add a click-through test for at least one of the three steps. + +### C-3. `ex.Message` from `CreateSessionHandler` is shipped to the user's Discord + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:104` + +**What's wrong.** On submit failure the submitter edits the draft +message with `$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}."` +and ships it to the user-visible draft embed. The exception originates +in `CreateSessionHandler` which talks to PostgreSQL via Dapper. Postgres +exception messages routinely include the constraint name, the conflicting +key value, and sometimes the full SQL text. Even the connection-string +DSN could leak if an `NpgsqlException` wraps a connection failure. A +malicious user who can submit many sessions can probe DB schema and +state by reading the error strings. + +**How to fix.** Log the full `ex` to the server-side log (already done +on line 86) but show the user a generic error: + +```csharp +await EditDraftMessageAsync( + draft, + $"💥 Ошибка при создании сессии. Попытка {payload.RetryCount}/{MaxRetries}. " + + "Попробуйте повторить или обратитесь к администратору.", + RetryCancelActions(), + ct); +``` + +If you want to preserve a per-error recovery hint (e.g. "Duplicate +title — pick a different name"), map known exception types to localized +strings; never embed the raw `ex.Message`. + +--- + +## Important findings + +### I-1. `Owner`/`CoGm` permission lookup runs on every wizard invocation, no cache + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:85-87` + +The slash command issues an `await DiscordPermissionLookup.LoadManagerUserIdsAsync(...)` +on every `/newsession-wizard` invocation. This is a 3-table join that +scales linearly with the number of clubs the user manages. With a 24-hour +draft lifetime and a single draft per owner, the same query repeats +frequently. Not critical for the v3.8.0 release, but a 30-second in-memory +cache would cut DB load noticeably during heavy wizard use. Same query +shape lives in `DiscordNewSessionHandler` already (per the file comment), +so a shared cache would benefit both. + +### I-2. `_skip` sentinel bypasses the wizard's own validation + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:191, 207` +**Read against:** `src/GmRelay.Shared/Features/Sessions\CreateSession\Wizard\GameCreationWizard.cs:287-290` + +`GameCreationWizard.ApplySystemChoice` matches `"_skip"` and accepts it +without further checks. The renderer emits `wizard:btn:System:_skip`. +This is correct *if* the choice-button wire format gets fixed (C-1); the +"`_skip`" string is hard-coded in the wizard and the renderer uses the +same constant. But there's no central constant — both files have their +own copies of the magic string. A future refactor that renames the +sentinel in one place will silently break the other. Suggest +`public const string SkipSentinel = "_skip"` on a shared class. + +### I-3. Smoke tests are string-matching only — no behaviour coverage of the adapter + +**File:** `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` + +All 12 smoke tests in this file are `Assert.Contains` against the +source text. They would all pass against a file full of `// choice` +comments and dead code. The test class header at line 8-17 acknowledges +this ("smoke gate"), and the broader Wizard test suite +(`tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/`) does +exercise the platform-neutral state machine — but the *adapter* (the +mapping from `ButtonInteractionContext` → `_wizard.HandleInteractionAsync`) +has zero behavioural coverage. C-1 and C-2 both bypass the entire +smoke-test surface. + +Minimum bar to add: a parser-roundtrip test that takes the renderer's +output for each `RenderX()` step and feeds it through the dispatcher's +button handler to verify it doesn't fall into the default branch. Even +a hand-rolled `ButtonInteractionContext` fake (or a helper that mimics +the dispatcher's `args.Split(':', 4)` parser) would catch both bugs. +Cost: ~50 lines; payoff: catches the entire class of "renderer and +dispatcher disagree on the wire format" regressions. + +### I-4. `AddComponentInteractions` is called for Modal but the renderer relies on `Label → TextInput` layout + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:436-444` +**Read against:** `DiscordWizardInteractionModule.cs:440-451` + +`BuildModal` always wraps a single `TextInput` in a `Label` (Discord's +`IModalComponentProperties` API requires labels). The dispatcher reads +`Components[0]` and assumes it is a `Label` (line 446). This is +consistent *today*, but if a future step needs two inputs in one +modal, the extraction logic needs to walk all components, not just +`[0]`. Document the constraint on the dispatcher's +`ExtractModalText` method ("current contract: exactly one Label, +one TextInput") and the renderer's `BuildModal` ("emits one +Label+TextInput, no exceptions"). + +### I-5. The 3-retry counter is bound to the in-memory `WizardPayload`, not the DB row + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:86-89` + +`payload.RetryCount += 1; SavePayload(draft, payload);` is called *before* +the `if (payload.RetryCount >= MaxRetries)` check. The counter is +serialized into `draft.PayloadJson` and re-loaded on the next click, so +the bound is correct across bot restarts. However, the in-memory +`draft` object is shared with the dispatcher's `_wizard` after +`HandleInteractionAsync` returns, and a future refactor that pulls the +payload from the DB instead of the in-memory copy could see a stale +count. Document the invariant: "RetryCount is read from the in-memory +payload after this line; do not re-load from DB before the comparison." + +### I-6. `BuildResumeRow` re-uses the same customId suffix scheme as the in-wizard buttons + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:193-209` + +`BuildResumeRow` emits three buttons: +- "▶️ Продолжить" → `wizard:btn:resume:continue` ✓ (works) +- "🔄 Заново" → `wizard:btn:resume:restart` ✓ (works) +- "❌ Отмена" → `wizard:btn:cancel:1` (uses `DiscordWizardStep.ButtonCustomId("cancel", "1")`) + +The cancel button relies on the dispatcher's `parts[1] == "cancel"` +match. With C-1 fixed, this still works because cancel is in the +switch, not the new "choice" path. But the `ButtonCustomId` signature +will change semantics after C-1: it will become +`$"wizard:btn:choice:{step}:{value}"`. `BuildResumeRow` passing +`"cancel"` as the step will then produce `wizard:btn:choice:cancel:1`, +which the dispatcher's switch will not match (no `"choice"` in the +parts). Fix C-1 must update `BuildResumeRow` to emit +`wizard:btn:cancel:1` directly (not via `ButtonCustomId`). + +--- + +## Nits + +- `DiscordWizardInteractionModule.cs:308, 357` — the select and modal + handlers split args with `Split(':', 2)` (max 2 parts), while the + button handler uses `Split(':', 4)` (max 4 parts). Inconsistent. The + net effect is correct (select has 2 segments, modal has 2, button has + 2–4), but a comment explaining the max-count rationale would help. +- `DiscordWizardStep.cs:74` — `throw new InvalidOperationException` on + unknown step is fine for now, but a future maintainer adding a step + to `WizardStepNames` will get a runtime exception. A `switch` exhaustiveness + check (e.g. a private static assert in tests) would catch this at + build time. +- `DiscordPermissionLookup.cs:28-30` — `g.platform = 'Discord'` is + hard-coded in the SQL. A `g.platform = @Platform` parameter would + mirror the dispatcher's parameterized style and make the helper + reusable for any future platform. Not blocking. +- `DiscordWizardCommand.cs:72-81` — fetching the guild via REST inside + the slash command costs an extra round-trip. The + `resolvedPermissions` from the interaction already includes + `Administrator`; only the "guild owner" case needs the REST call. + Consider short-circuiting when `(resolvedPermissions & Administrator) + != 0`. +- `DiscordWizardMessenger.cs:188-192` — hard-coded + `new Color(0x5865F2)` (Discord blurple). Extracting to a const + `WizardEmbedColor` would let the Web/Telegram versions use the same + brand color if they ever render wizards. + +--- + +## Migration V032 sanity check + +**File:** `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql` + +- Line 8-9 `ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'` + — DEFAULT literal makes this O(1) on PostgreSQL ≥ 11. Safe on + existing rows. +- Lines 13-30 — `ALTER COLUMN ... TYPE TEXT USING ...::TEXT` on the + `chat_id`, `message_thread_id`, `draft_message_id`, and renamed + `owner_id` columns. All conversions are lossless + (BIGINT → decimal-string, INT → decimal-string). Safe. +- Line 26-27 `RENAME COLUMN owner_telegram_id TO owner_id` — the + rename happens mid-migration. Any DML hitting the table + concurrently that uses the old name will fail. For a bot that + processes both Telegram and Discord traffic, this is a brief + exclusivity lock. Consider splitting into two migrations + (rename + new index, then type change) so each lock is shorter. + Not blocking for v3.8.0, but document the brief lock window. + +No DEFAULT-cascade issue, no NOT NULL on existing-row failure. The +deliverable's "Will this fail on existing rows?" question gets a +"no, but plan a maintenance window" answer. + +--- + +## Architecture sanity (re-confirmed) + +- `src/GmRelay.Shared/` — only references to Telegram.Bot/NetCord are + in doc comments warning the developer not to add them. csproj has + no Telegram.Bot or NetCord package references. `GameCreationWizard`, + `IWizardMessenger`, `WizardCallbackData`, `WizardStepLimits`, + `WizardStepNames`, `WizardPayload`, `WizardDraft` are all in exactly + one place (Shared). ✓ +- `src/GmRelay.DiscordBot/Program.cs:87-102` — all 7 wizard services + registered as singleton. All 3 `AddComponentInteractions<...>` + calls present (Button, StringMenu, Modal). All 4 module/dispatcher + classes (`WizardInteractionDispatcher`, `DiscordWizardButtonModule`, + `DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) + registered. ✓ +- AOT-safety: no `System.Reflection`, no `dynamic`, no + `Activator.CreateInstance`, no `Type.GetType` in the new Discord + or Shared code. ✓ +- `DiscordPermissionLookup.cs:23-31` and + `DiscordWizardMessenger.cs:154-165` and the inline + `WizardClubLookup` in `DiscordWizardInteractionModule.cs:508-519` + all use parameterized queries (`@GuildId`, `@Platform`, + `@ExternalId`, `@OwnerId`). No SQL string interpolation. ✓ +- `Program.cs:54` — `SecretRedactor.RedactConnectionString` on the + startup log. ✓ +- `DiscordWizardCommand.cs:51-94` — DM invocations rejected + (`GuildId` null check), channel null-checked, member type-checked + via `as GuildInteractionUser`, owner/admin/DB-manager permission + check via `DiscordPermissionChecker.CanManageSchedule`. No NRE + on `Context.User`. ✓ + +--- + +## Summary + +Strong foundation: the platform-neutral refactor is well-executed, the +state machine has solid test coverage, the Discord adapter's DI graph +is clean, and security primitives (parameterized SQL, permission check, +secret redaction) are in place. But the Discord adapter's runtime path +is untested, and a single oversight in the renderer's button custom-id +format (missing the `choice:` segment) breaks every choice button in +the wizard at click time. The "Другое… ✏️" modal triggers are also +unrouted in the dispatcher, leaving the free-text input path +unreachable. The 3-attempt finalize loop works but leaks `ex.Message` +to the user. After fixing C-1 / C-2 / C-3, adding I-3 (behavioural +test of the adapter), and re-running the manual click-through checklist +(System → Duration → DateTime → Capacity → Visibility → Publish → +Confirm for Single; full pool flow), this branch is ready to merge. diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs index 60c2654..ca35ae5 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs @@ -192,17 +192,21 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule BuildResumeRow(Guid draftId) { + // Direct format strings (not ChoiceButtonCustomId) — these + // are control actions (resume:cancel), not wizard-step choices. + // The dispatcher's switch matches parts[1] as "resume" or + // "cancel" directly, not as a "choice" prefix. var row = new ActionRowProperties(); row.Add(new ButtonProperties( - DiscordWizardStep.ButtonCustomId("resume", "continue"), + "wizard:btn:resume:continue", "▶️ Продолжить", ButtonStyle.Primary)); row.Add(new ButtonProperties( - DiscordWizardStep.ButtonCustomId("resume", "restart"), + "wizard:btn:resume:restart", "🔄 Заново", ButtonStyle.Secondary)); row.Add(new ButtonProperties( - DiscordWizardStep.ButtonCustomId("cancel", "1"), + "wizard:btn:cancel:1", "❌ Отмена", ButtonStyle.Danger)); return new IMessageComponentProperties[] { row }; diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs index 293b9ee..daf128d 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs @@ -192,6 +192,25 @@ public sealed class WizardInteractionDispatcher return; } + // Special case: "modal:" — the renderer emits this for the + // "Другое…" free-text buttons on System, Duration, and + // PoolSystemDuration. The click intent is "open a modal for + // free-text input" — NOT "advance the wizard". The wizard's + // state advance happens when the user submits the modal. + if (parts[1] == "modal" && parts.Length >= 3) + { + var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId); + if (modal is not null) + { + await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal)); + } + else + { + await AckWithErrorAsync(context.Interaction, "Не удалось открыть форму"); + } + 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 diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs index 330f1c7..b9aa1cb 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs @@ -76,8 +76,24 @@ public static class DiscordWizardStep } // ── Custom-id helpers ───────────────────────────────────────────── - public static string ButtonCustomId(string step, string value) => - $"wizard:btn:{step}:{value}"; + // Three custom-id shapes for buttons, all with the literal "wizard" prefix + // that the NetCord [ComponentInteraction("wizard")] matcher strips off. + // After prefix-strip the dispatcher receives the suffix as `args`. + // + // Choice : wizard:btn:choice:: → wizard's ApplyChoice + // Control : wizard:btn::1 → dispatcher special case + // Modal trig. : wizard:btn:modal: → dispatcher opens modal + // + // The renderer's helpers below enforce these shapes so the dispatcher + // parser and the wizard callbacks stay in lockstep. + public static string ChoiceButtonCustomId(string step, string value) => + $"wizard:btn:choice:{step}:{value}"; + + public static string ControlButtonCustomId(string action) => + $"wizard:btn:{action}:1"; + + public static string ModalTriggerButtonCustomId(string modalStep) => + $"wizard:btn:modal:{modalStep}"; public static string SelectCustomId(string step) => $"wizard:select:{step}"; @@ -86,13 +102,13 @@ public static class DiscordWizardStep public static bool TryParseButtonCustomId(string customId, out string step, out string value) { step = value = string.Empty; - var parts = customId.Split(':', 4); - if (parts.Length < 4 || parts[0] != "wizard" || parts[1] != "btn") + var parts = customId.Split(':', 5); + if (parts.Length < 5 || parts[0] != "wizard" || parts[1] != "btn" || parts[2] != "choice") { return false; } - step = parts[2]; - value = parts[3]; + step = parts[3]; + value = parts[4]; return true; } @@ -111,9 +127,26 @@ public static class DiscordWizardStep } // ── Helpers ─────────────────────────────────────────────────────── - private static ButtonProperties Btn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary) + // Three button factories, one per custom-id shape (see ChoiceButtonCustomId + // comment above). RenderX() uses these to build rows; the call site + // determines which kind of button each row needs. + private static ButtonProperties ChoiceBtn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary) { - var cid = ButtonCustomId(step, value); + var cid = ChoiceButtonCustomId(step, value); + EnsureCustomIdFits(cid); + return new ButtonProperties(cid, label, style); + } + + private static ButtonProperties ControlBtn(string label, string action, ButtonStyle style = ButtonStyle.Secondary) + { + var cid = ControlButtonCustomId(action); + EnsureCustomIdFits(cid); + return new ButtonProperties(cid, label, style); + } + + private static ButtonProperties ModalTriggerBtn(string label, string modalStep, ButtonStyle style = ButtonStyle.Secondary) + { + var cid = ModalTriggerButtonCustomId(modalStep); EnsureCustomIdFits(cid); return new ButtonProperties(cid, label, style); } @@ -149,32 +182,32 @@ public static class DiscordWizardStep private static DiscordWizardRender RenderType() => new( "🎲 Создание игровой сессии", "Выберите тип: одна игра или пул.", - new IMessageComponentProperties[] { Row(Btn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary), - Btn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, + new IMessageComponentProperties[] { Row(ChoiceBtn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary), + ChoiceBtn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, OpenModalStep: null); private static DiscordWizardRender RenderTitle() => new( "📝 Название", "Введите название игры в модальном окне.", - new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, OpenModalStep: WizardStepNames.Title); private static DiscordWizardRender RenderDescription() => new( "📄 Описание", "Введите описание (или «-», чтобы пропустить).", - new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"), - Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, + new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Description, "-"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, OpenModalStep: WizardStepNames.Description); private static DiscordWizardRender RenderCover() => new( "🖼 Обложка", "Введите URL картинки (или «-», чтобы пропустить).", - new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"), - Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, + new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Cover, "-"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, OpenModalStep: WizardStepNames.Cover); private static DiscordWizardRender RenderSystem() => new( @@ -182,15 +215,15 @@ public static class DiscordWizardStep "Выберите систему.", new[] { - Row(Btn("D&D 5e", WizardStepNames.System, "Dnd5e"), - Btn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"), - Btn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"), - Btn("GURPS", WizardStepNames.System, "GURPS"), - Btn("Fate", WizardStepNames.System, "Fate")), - Row(Btn("Другое… ✏️", "modal", "SystemFreeText", ButtonStyle.Primary), - Btn("⏭ Пропустить", WizardStepNames.System, "_skip"), - Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ChoiceBtn("D&D 5e", WizardStepNames.System, "Dnd5e"), + ChoiceBtn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"), + ChoiceBtn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"), + ChoiceBtn("GURPS", WizardStepNames.System, "GURPS"), + ChoiceBtn("Fate", WizardStepNames.System, "Fate")), + Row(ModalTriggerBtn("Другое… ✏️", "SystemFreeText", ButtonStyle.Primary), + ChoiceBtn("⏭ Пропустить", WizardStepNames.System, "_skip"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); @@ -199,22 +232,22 @@ public static class DiscordWizardStep "Выберите длительность (или «Другое…»).", new[] { - Row(Btn("3 часа", WizardStepNames.Duration, "180"), - Btn("4 часа", WizardStepNames.Duration, "240"), - Btn("5 часов", WizardStepNames.Duration, "300"), - Btn("6 часов", WizardStepNames.Duration, "360")), - Row(Btn("Другое… ✏️", "modal", "DurationFreeText", ButtonStyle.Primary), - Btn("⏭ Пропустить", WizardStepNames.Duration, "_skip"), - Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ChoiceBtn("3 часа", WizardStepNames.Duration, "180"), + ChoiceBtn("4 часа", WizardStepNames.Duration, "240"), + ChoiceBtn("5 часов", WizardStepNames.Duration, "300"), + ChoiceBtn("6 часов", WizardStepNames.Duration, "360")), + Row(ModalTriggerBtn("Другое… ✏️", "DurationFreeText", ButtonStyle.Primary), + ChoiceBtn("⏭ Пропустить", WizardStepNames.Duration, "_skip"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); private static DiscordWizardRender RenderDateTime() => new( "📅 Дата и время", "Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).", - new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, OpenModalStep: WizardStepNames.DateTime); private static DiscordWizardRender RenderCapacity() => new( @@ -222,10 +255,10 @@ public static class DiscordWizardStep "Введите лимит (1..50) и выберите waitlist.", new[] { - Row(Btn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success), - Btn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)), - Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success), + ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: WizardStepNames.Capacity); @@ -241,8 +274,8 @@ public static class DiscordWizardStep new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"), new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"), }), - Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); @@ -253,8 +286,8 @@ public static class DiscordWizardStep return new DiscordWizardRender( "🏷 Выбор клуба", "У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.", - new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, OpenModalStep: null); } var options = new List(clubs.Count); @@ -268,8 +301,8 @@ public static class DiscordWizardStep new IMessageComponentProperties[] { BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options), - Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); } @@ -279,10 +312,10 @@ public static class DiscordWizardStep "Опубликовать в витрине сейчас?", new[] { - Row(Btn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success), - Btn("📝 Только в чате", WizardStepNames.Publish, "no")), - Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ChoiceBtn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success), + ChoiceBtn("📝 Только в чате", WizardStepNames.Publish, "no")), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); @@ -291,9 +324,9 @@ public static class DiscordWizardStep BuildConfirmDescription(p), new[] { - Row(Btn("✅ Создать", "create", "1", ButtonStyle.Success), - Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ControlBtn("✅ Создать", "create", ButtonStyle.Success), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); @@ -311,9 +344,9 @@ public static class DiscordWizardStep new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"), new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"), }), - Row(Btn("Другое… ✏️", "modal", "PoolSystemDurationFreeText", ButtonStyle.Primary), - Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ModalTriggerBtn("Другое… ✏️", "PoolSystemDurationFreeText", ButtonStyle.Primary), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); @@ -322,18 +355,18 @@ public static class DiscordWizardStep $"Добавлено: {p.Pool?.Slots.Count ?? 0}.", new[] { - Row(Btn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary), - Btn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)), - Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ChoiceBtn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary), + ChoiceBtn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); private static DiscordWizardRender RenderPoolSlotDateTime() => new( "📅 Дата/время слота", "Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).", - new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, OpenModalStep: WizardStepNames.PoolSlotDateTime); private static DiscordWizardRender RenderPoolSlotCapacity() => new( @@ -341,10 +374,10 @@ public static class DiscordWizardStep "Введите лимит (1..50) и выберите waitlist.", new[] { - Row(Btn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success), - Btn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)), - Row(Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success), + ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: WizardStepNames.PoolSlotCapacity); @@ -353,9 +386,9 @@ public static class DiscordWizardStep BuildPoolConfirmDescription(p), new[] { - Row(Btn("✅ Создать пул", "create", "1", ButtonStyle.Success), - Btn("⬅️ Назад", "back", "1"), - Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), + Row(ControlBtn("✅ Создать пул", "create", ButtonStyle.Success), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), }, OpenModalStep: null); diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs index 632cf08..ec2c965 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs @@ -99,9 +99,13 @@ public sealed class DiscordWizardSubmitter } draft.UpdatedAt = DateTimeOffset.UtcNow; await _drafts.UpsertAsync(draft, ct); + // The full exception (with stack trace, Postgres constraint + // name, sometimes partial SQL) is already logged server-side + // on line 86. Show the user a generic message — never leak + // internal error strings to the Discord channel. await EditDraftMessageAsync( draft, - $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", + $"💥 Не удалось создать сессию. Попробуйте ещё раз (попытка {payload.RetryCount}/{MaxRetries}).", RetryCancelActions(), ct); } diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs index 2cfcf3e..c77dd27 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; using System.Reflection; +using GmRelay.DiscordBot.Features.Sessions.Wizard; namespace GmRelay.Bot.Tests.Discord; @@ -177,6 +178,92 @@ public sealed class DiscordWizardInteractionModuleSourceTests Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal); } + /// + /// Roundtrip the renderer output through the dispatcher's parser to + /// prove the wire formats agree. This is a real behavioural test + /// (not a string-grep) — it actually constructs the ButtonProperties + /// that NetCord would send, strips the [ComponentInteraction("wizard")] + /// prefix exactly as NetCord does, and asserts the dispatcher's + /// switch would route the click to the right branch. Catches the + /// class of "renderer and dispatcher disagree on the wire format" + /// regressions that the string-grep tests above cannot detect. + /// + [Fact] + public void Renderer_And_Dispatcher_Agree_On_Wire_Format() + { + // Choice button: dispatcher expects `btn:choice::`. + var choice = DiscordWizardStep.ChoiceButtonCustomId("Type", "single"); + Assert.Equal("wizard:btn:choice:Type:single", choice); + var choiceArgs = StripWizardPrefix(choice); + var choiceParts = choiceArgs.Split(':', 4); + Assert.Equal("btn", choiceParts[0]); + Assert.Equal("choice", choiceParts[1]); + Assert.Equal("Type", choiceParts[2]); + Assert.Equal("single", choiceParts[3]); + + // Control button: dispatcher expects `btn::1`. + var cancel = DiscordWizardStep.ControlButtonCustomId("cancel"); + Assert.Equal("wizard:btn:cancel:1", cancel); + var cancelArgs = StripWizardPrefix(cancel); + var cancelParts = cancelArgs.Split(':', 3); + Assert.Equal("btn", cancelParts[0]); + Assert.Equal("cancel", cancelParts[1]); + + // Modal trigger: dispatcher expects `btn:modal:`. + var modal = DiscordWizardStep.ModalTriggerButtonCustomId("SystemFreeText"); + Assert.Equal("wizard:btn:modal:SystemFreeText", modal); + var modalArgs = StripWizardPrefix(modal); + var modalParts = modalArgs.Split(':', 3); + Assert.Equal("btn", modalParts[0]); + Assert.Equal("modal", modalParts[1]); + Assert.Equal("SystemFreeText", modalParts[2]); + + // All customIds must fit Discord's 100-char limit. + Assert.All( + new[] { choice, cancel, modal }, + cid => Assert.True( + cid.Length <= DiscordWizardStep.MaxCustomIdLength, + $"CustomId '{cid}' exceeds 100 chars: {cid.Length}")); + } + + /// + /// The Create/Back/Cancel/Resume control buttons in the renderer + /// (and in BuildResumeRow) must emit the format the dispatcher's + /// switch matches directly — NOT the choice-button format. This + /// test parses every button's customId and asserts the dispatcher + /// would route it to the right branch. + /// + [Fact] + public void ControlButtons_Are_Parsed_As_Control_Not_Choice() + { + // Real customIds the renderer / BuildResumeRow emit for control actions. + var controlIds = new[] + { + DiscordWizardStep.ControlButtonCustomId("back"), + DiscordWizardStep.ControlButtonCustomId("cancel"), + "wizard:btn:create:1", + "wizard:btn:resume:continue", + "wizard:btn:resume:restart", + }; + + foreach (var cid in controlIds) + { + var parts = StripWizardPrefix(cid).Split(':', 3); + Assert.Equal("btn", parts[0]); + // The dispatcher's switch matches these as parts[1] == "back"|"cancel"|"create"|"resume". + // They must NOT be tagged as "choice" (that would route through the wizard + // with a nonsensical step name). + Assert.NotEqual("choice", parts[1]); + } + } + + /// Mirror NetCord's [ComponentInteraction("wizard")] prefix strip. + private static string StripWizardPrefix(string customId) + { + const string prefix = "wizard:"; + return customId.StartsWith(prefix, StringComparison.Ordinal) ? customId[prefix.Length..] : customId; + } + private static int CountOccurrences(string haystack, string needle) { if (string.IsNullOrEmpty(needle)) return 0;