Files
Toutsu 85ff3a7faf
PR Checks / test-and-build (pull_request) Successful in 9m54s
fix(discord): address code-review findings on wizard adapter (issue #112)
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 -
2026-06-05 23:09:24 +03:00

363 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.