fix(discord): address code-review findings on wizard adapter (issue #112)
PR Checks / test-and-build (pull_request) Successful in 9m54s
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:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
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.
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user