fix(discord): address code-review findings on wizard adapter (issue #112)
PR Checks / test-and-build (pull_request) Successful in 9m54s

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:<step>:<value>' 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:<step>:<value>'
      ControlButtonCustomId(action)            -> 'wizard:btn:<action>:1'  (back/cancel/skip/create)
      ModalTriggerButtonCustomId(modalStep)    -> 'wizard:btn:modal:<modalStep>'
    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: <Version>3.9.0</Version>
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 -
This commit is contained in:
2026-06-05 23:09:24 +03:00
parent d034d6acb9
commit 85ff3a7faf
8 changed files with 588 additions and 79 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.8.0</Version> <Version>3.9.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.8.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: 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 restart: always
depends_on: depends_on:
db: db:
@@ -86,7 +86,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.8.0 image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0
restart: always restart: always
depends_on: depends_on:
db: db:
+362
View File
@@ -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:<step>:<value>`. 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<TInteraction, TContext>` 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
24), 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.
@@ -192,17 +192,21 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
private static IReadOnlyList<IMessageComponentProperties> BuildResumeRow(Guid draftId) private static IReadOnlyList<IMessageComponentProperties> 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(); var row = new ActionRowProperties();
row.Add(new ButtonProperties( row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("resume", "continue"), "wizard:btn:resume:continue",
"▶️ Продолжить", "▶️ Продолжить",
ButtonStyle.Primary)); ButtonStyle.Primary));
row.Add(new ButtonProperties( row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("resume", "restart"), "wizard:btn:resume:restart",
"🔄 Заново", "🔄 Заново",
ButtonStyle.Secondary)); ButtonStyle.Secondary));
row.Add(new ButtonProperties( row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("cancel", "1"), "wizard:btn:cancel:1",
"❌ Отмена", "❌ Отмена",
ButtonStyle.Danger)); ButtonStyle.Danger));
return new IMessageComponentProperties[] { row }; return new IMessageComponentProperties[] { row };
@@ -192,6 +192,25 @@ public sealed class WizardInteractionDispatcher
return; return;
} }
// Special case: "modal:<step>" — 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 // Special case: "resume" — the slash command's resume row
// gives the user a chance to keep or restart their active // gives the user a chance to keep or restart their active
// draft. The wizard has no built-in resume case, so we // draft. The wizard has no built-in resume case, so we
@@ -76,8 +76,24 @@ public static class DiscordWizardStep
} }
// ── Custom-id helpers ───────────────────────────────────────────── // ── Custom-id helpers ─────────────────────────────────────────────
public static string ButtonCustomId(string step, string value) => // Three custom-id shapes for buttons, all with the literal "wizard" prefix
$"wizard:btn:{step}:{value}"; // that the NetCord [ComponentInteraction("wizard")] matcher strips off.
// After prefix-strip the dispatcher receives the suffix as `args`.
//
// Choice : wizard:btn:choice:<step>:<value> → wizard's ApplyChoice
// Control : wizard:btn:<action>:1 → dispatcher special case
// Modal trig. : wizard:btn:modal:<modalStep> → 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}"; 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) public static bool TryParseButtonCustomId(string customId, out string step, out string value)
{ {
step = value = string.Empty; step = value = string.Empty;
var parts = customId.Split(':', 4); var parts = customId.Split(':', 5);
if (parts.Length < 4 || parts[0] != "wizard" || parts[1] != "btn") if (parts.Length < 5 || parts[0] != "wizard" || parts[1] != "btn" || parts[2] != "choice")
{ {
return false; return false;
} }
step = parts[2]; step = parts[3];
value = parts[3]; value = parts[4];
return true; return true;
} }
@@ -111,9 +127,26 @@ public static class DiscordWizardStep
} }
// ── Helpers ─────────────────────────────────────────────────────── // ── 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); EnsureCustomIdFits(cid);
return new ButtonProperties(cid, label, style); return new ButtonProperties(cid, label, style);
} }
@@ -149,32 +182,32 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderType() => new( private static DiscordWizardRender RenderType() => new(
"🎲 Создание игровой сессии", "🎲 Создание игровой сессии",
"Выберите тип: одна игра или пул.", "Выберите тип: одна игра или пул.",
new IMessageComponentProperties[] { Row(Btn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary), new IMessageComponentProperties[] { Row(ChoiceBtn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary),
Btn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary), ChoiceBtn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: null); OpenModalStep: null);
private static DiscordWizardRender RenderTitle() => new( private static DiscordWizardRender RenderTitle() => new(
"📝 Название", "📝 Название",
"Введите название игры в модальном окне.", "Введите название игры в модальном окне.",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Title); OpenModalStep: WizardStepNames.Title);
private static DiscordWizardRender RenderDescription() => new( private static DiscordWizardRender RenderDescription() => new(
"📄 Описание", "📄 Описание",
"Введите описание (или «-», чтобы пропустить).", "Введите описание (или «-», чтобы пропустить).",
new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"), new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Description, "-"),
Btn("⬅️ Назад", "back", "1"), ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Description); OpenModalStep: WizardStepNames.Description);
private static DiscordWizardRender RenderCover() => new( private static DiscordWizardRender RenderCover() => new(
"🖼 Обложка", "🖼 Обложка",
"Введите URL картинки (или «-», чтобы пропустить).", "Введите URL картинки (или «-», чтобы пропустить).",
new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"), new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Cover, "-"),
Btn("⬅️ Назад", "back", "1"), ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Cover); OpenModalStep: WizardStepNames.Cover);
private static DiscordWizardRender RenderSystem() => new( private static DiscordWizardRender RenderSystem() => new(
@@ -182,15 +215,15 @@ public static class DiscordWizardStep
"Выберите систему.", "Выберите систему.",
new[] new[]
{ {
Row(Btn("D&D 5e", WizardStepNames.System, "Dnd5e"), Row(ChoiceBtn("D&D 5e", WizardStepNames.System, "Dnd5e"),
Btn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"), ChoiceBtn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"),
Btn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"), ChoiceBtn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"),
Btn("GURPS", WizardStepNames.System, "GURPS"), ChoiceBtn("GURPS", WizardStepNames.System, "GURPS"),
Btn("Fate", WizardStepNames.System, "Fate")), ChoiceBtn("Fate", WizardStepNames.System, "Fate")),
Row(Btn("Другое… ✏️", "modal", "SystemFreeText", ButtonStyle.Primary), Row(ModalTriggerBtn("Другое… ✏️", "SystemFreeText", ButtonStyle.Primary),
Btn("⏭ Пропустить", WizardStepNames.System, "_skip"), ChoiceBtn("⏭ Пропустить", WizardStepNames.System, "_skip"),
Btn("⬅️ Назад", "back", "1"), ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
@@ -199,22 +232,22 @@ public static class DiscordWizardStep
"Выберите длительность (или «Другое…»).", "Выберите длительность (или «Другое…»).",
new[] new[]
{ {
Row(Btn("3 часа", WizardStepNames.Duration, "180"), Row(ChoiceBtn("3 часа", WizardStepNames.Duration, "180"),
Btn("4 часа", WizardStepNames.Duration, "240"), ChoiceBtn("4 часа", WizardStepNames.Duration, "240"),
Btn("5 часов", WizardStepNames.Duration, "300"), ChoiceBtn("5 часов", WizardStepNames.Duration, "300"),
Btn("6 часов", WizardStepNames.Duration, "360")), ChoiceBtn("6 часов", WizardStepNames.Duration, "360")),
Row(Btn("Другое… ✏️", "modal", "DurationFreeText", ButtonStyle.Primary), Row(ModalTriggerBtn("Другое… ✏️", "DurationFreeText", ButtonStyle.Primary),
Btn("⏭ Пропустить", WizardStepNames.Duration, "_skip"), ChoiceBtn("⏭ Пропустить", WizardStepNames.Duration, "_skip"),
Btn("⬅️ Назад", "back", "1"), ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
private static DiscordWizardRender RenderDateTime() => new( private static DiscordWizardRender RenderDateTime() => new(
"📅 Дата и время", "📅 Дата и время",
"Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).", "Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.DateTime); OpenModalStep: WizardStepNames.DateTime);
private static DiscordWizardRender RenderCapacity() => new( private static DiscordWizardRender RenderCapacity() => new(
@@ -222,10 +255,10 @@ public static class DiscordWizardStep
"Введите лимит (1..50) и выберите waitlist.", "Введите лимит (1..50) и выберите waitlist.",
new[] new[]
{ {
Row(Btn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success), Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
Btn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)), ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
Row(Btn("⬅️ Назад", "back", "1"), Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: WizardStepNames.Capacity); OpenModalStep: WizardStepNames.Capacity);
@@ -241,8 +274,8 @@ public static class DiscordWizardStep
new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"), new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"),
new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"), new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"),
}), }),
Row(Btn("⬅️ Назад", "back", "1"), Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
@@ -253,8 +286,8 @@ public static class DiscordWizardStep
return new DiscordWizardRender( return new DiscordWizardRender(
"🏷 Выбор клуба", "🏷 Выбор клуба",
"У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.", "У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: null); OpenModalStep: null);
} }
var options = new List<StringMenuSelectOptionProperties>(clubs.Count); var options = new List<StringMenuSelectOptionProperties>(clubs.Count);
@@ -268,8 +301,8 @@ public static class DiscordWizardStep
new IMessageComponentProperties[] new IMessageComponentProperties[]
{ {
BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options), BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options),
Row(Btn("⬅️ Назад", "back", "1"), Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
} }
@@ -279,10 +312,10 @@ public static class DiscordWizardStep
"Опубликовать в витрине сейчас?", "Опубликовать в витрине сейчас?",
new[] new[]
{ {
Row(Btn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success), Row(ChoiceBtn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success),
Btn("📝 Только в чате", WizardStepNames.Publish, "no")), ChoiceBtn("📝 Только в чате", WizardStepNames.Publish, "no")),
Row(Btn("⬅️ Назад", "back", "1"), Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
@@ -291,9 +324,9 @@ public static class DiscordWizardStep
BuildConfirmDescription(p), BuildConfirmDescription(p),
new[] new[]
{ {
Row(Btn("✅ Создать", "create", "1", ButtonStyle.Success), Row(ControlBtn("✅ Создать", "create", ButtonStyle.Success),
Btn("⬅️ Назад", "back", "1"), ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
@@ -311,9 +344,9 @@ public static class DiscordWizardStep
new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"), new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"),
new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"), new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"),
}), }),
Row(Btn("Другое… ✏️", "modal", "PoolSystemDurationFreeText", ButtonStyle.Primary), Row(ModalTriggerBtn("Другое… ✏️", "PoolSystemDurationFreeText", ButtonStyle.Primary),
Btn("⬅️ Назад", "back", "1"), ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
@@ -322,18 +355,18 @@ public static class DiscordWizardStep
$"Добавлено: {p.Pool?.Slots.Count ?? 0}.", $"Добавлено: {p.Pool?.Slots.Count ?? 0}.",
new[] new[]
{ {
Row(Btn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary), Row(ChoiceBtn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary),
Btn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)), ChoiceBtn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)),
Row(Btn("⬅️ Назад", "back", "1"), Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
private static DiscordWizardRender RenderPoolSlotDateTime() => new( private static DiscordWizardRender RenderPoolSlotDateTime() => new(
"📅 Дата/время слота", "📅 Дата/время слота",
"Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).", "Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"), new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) }, ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.PoolSlotDateTime); OpenModalStep: WizardStepNames.PoolSlotDateTime);
private static DiscordWizardRender RenderPoolSlotCapacity() => new( private static DiscordWizardRender RenderPoolSlotCapacity() => new(
@@ -341,10 +374,10 @@ public static class DiscordWizardStep
"Введите лимит (1..50) и выберите waitlist.", "Введите лимит (1..50) и выберите waitlist.",
new[] new[]
{ {
Row(Btn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success), Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
Btn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)), ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
Row(Btn("⬅️ Назад", "back", "1"), Row(ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: WizardStepNames.PoolSlotCapacity); OpenModalStep: WizardStepNames.PoolSlotCapacity);
@@ -353,9 +386,9 @@ public static class DiscordWizardStep
BuildPoolConfirmDescription(p), BuildPoolConfirmDescription(p),
new[] new[]
{ {
Row(Btn("✅ Создать пул", "create", "1", ButtonStyle.Success), Row(ControlBtn("✅ Создать пул", "create", ButtonStyle.Success),
Btn("⬅️ Назад", "back", "1"), ControlBtn("⬅️ Назад", "back"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)), ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
}, },
OpenModalStep: null); OpenModalStep: null);
@@ -99,9 +99,13 @@ public sealed class DiscordWizardSubmitter
} }
draft.UpdatedAt = DateTimeOffset.UtcNow; draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct); 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( await EditDraftMessageAsync(
draft, draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", $"💥 Не удалось создать сессию. Попробуйте ещё раз (попытка {payload.RetryCount}/{MaxRetries}).",
RetryCancelActions(), RetryCancelActions(),
ct); ct);
} }
@@ -2,6 +2,7 @@ using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using GmRelay.DiscordBot.Features.Sessions.Wizard;
namespace GmRelay.Bot.Tests.Discord; namespace GmRelay.Bot.Tests.Discord;
@@ -177,6 +178,92 @@ public sealed class DiscordWizardInteractionModuleSourceTests
Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal); Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal);
} }
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Renderer_And_Dispatcher_Agree_On_Wire_Format()
{
// Choice button: dispatcher expects `btn:choice:<step>:<value>`.
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:<action>: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:<modalStep>`.
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}"));
}
/// <summary>
/// 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.
/// </summary>
[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]);
}
}
/// <summary>Mirror NetCord's [ComponentInteraction("wizard")] prefix strip.</summary>
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) private static int CountOccurrences(string haystack, string needle)
{ {
if (string.IsNullOrEmpty(needle)) return 0; if (string.IsNullOrEmpty(needle)) return 0;