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
@@ -192,17 +192,21 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
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();
row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("resume", "continue"),
"wizard:btn:resume:continue",
"▶️ Продолжить",
ButtonStyle.Primary));
row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("resume", "restart"),
"wizard:btn:resume:restart",
"🔄 Заново",
ButtonStyle.Secondary));
row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("cancel", "1"),
"wizard:btn:cancel:1",
"❌ Отмена",
ButtonStyle.Danger));
return new IMessageComponentProperties[] { row };
@@ -192,6 +192,25 @@ public sealed class WizardInteractionDispatcher
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
// gives the user a chance to keep or restart their active
// draft. The wizard has no built-in resume case, so we
@@ -76,8 +76,24 @@ public static class DiscordWizardStep
}
// ── Custom-id helpers ─────────────────────────────────────────────
public static string ButtonCustomId(string step, string value) =>
$"wizard:btn:{step}:{value}";
// Three custom-id shapes for buttons, all with the literal "wizard" prefix
// that the NetCord [ComponentInteraction("wizard")] matcher strips off.
// After prefix-strip the dispatcher receives the suffix as `args`.
//
// Choice : wizard:btn:choice:<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}";
@@ -86,13 +102,13 @@ public static class DiscordWizardStep
public static bool TryParseButtonCustomId(string customId, out string step, out string value)
{
step = value = string.Empty;
var parts = customId.Split(':', 4);
if (parts.Length < 4 || parts[0] != "wizard" || parts[1] != "btn")
var parts = customId.Split(':', 5);
if (parts.Length < 5 || parts[0] != "wizard" || parts[1] != "btn" || parts[2] != "choice")
{
return false;
}
step = parts[2];
value = parts[3];
step = parts[3];
value = parts[4];
return true;
}
@@ -111,9 +127,26 @@ public static class DiscordWizardStep
}
// ── Helpers ───────────────────────────────────────────────────────
private static ButtonProperties Btn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary)
// Three button factories, one per custom-id shape (see ChoiceButtonCustomId
// comment above). RenderX() uses these to build rows; the call site
// determines which kind of button each row needs.
private static ButtonProperties ChoiceBtn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary)
{
var cid = ButtonCustomId(step, value);
var cid = ChoiceButtonCustomId(step, value);
EnsureCustomIdFits(cid);
return new ButtonProperties(cid, label, style);
}
private static ButtonProperties ControlBtn(string label, string action, ButtonStyle style = ButtonStyle.Secondary)
{
var cid = ControlButtonCustomId(action);
EnsureCustomIdFits(cid);
return new ButtonProperties(cid, label, style);
}
private static ButtonProperties ModalTriggerBtn(string label, string modalStep, ButtonStyle style = ButtonStyle.Secondary)
{
var cid = ModalTriggerButtonCustomId(modalStep);
EnsureCustomIdFits(cid);
return new ButtonProperties(cid, label, style);
}
@@ -149,32 +182,32 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderType() => new(
"🎲 Создание игровой сессии",
"Выберите тип: одна игра или пул.",
new IMessageComponentProperties[] { Row(Btn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary),
Btn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
new IMessageComponentProperties[] { Row(ChoiceBtn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary),
ChoiceBtn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: null);
private static DiscordWizardRender RenderTitle() => new(
"📝 Название",
"Введите название игры в модальном окне.",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Title);
private static DiscordWizardRender RenderDescription() => new(
"📄 Описание",
"Введите описание (или «-», чтобы пропустить).",
new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Description, "-"),
ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Description);
private static DiscordWizardRender RenderCover() => new(
"🖼 Обложка",
"Введите URL картинки (или «-», чтобы пропустить).",
new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Cover, "-"),
ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Cover);
private static DiscordWizardRender RenderSystem() => new(
@@ -182,15 +215,15 @@ public static class DiscordWizardStep
"Выберите систему.",
new[]
{
Row(Btn("D&D 5e", WizardStepNames.System, "Dnd5e"),
Btn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"),
Btn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"),
Btn("GURPS", WizardStepNames.System, "GURPS"),
Btn("Fate", WizardStepNames.System, "Fate")),
Row(Btn("Другое… ✏️", "modal", "SystemFreeText", ButtonStyle.Primary),
Btn("⏭ Пропустить", WizardStepNames.System, "_skip"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ChoiceBtn("D&D 5e", WizardStepNames.System, "Dnd5e"),
ChoiceBtn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"),
ChoiceBtn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"),
ChoiceBtn("GURPS", WizardStepNames.System, "GURPS"),
ChoiceBtn("Fate", WizardStepNames.System, "Fate")),
Row(ModalTriggerBtn("Другое… ✏️", "SystemFreeText", ButtonStyle.Primary),
ChoiceBtn("⏭ Пропустить", WizardStepNames.System, "_skip"),
ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
@@ -199,22 +232,22 @@ public static class DiscordWizardStep
"Выберите длительность (или «Другое…»).",
new[]
{
Row(Btn("3 часа", WizardStepNames.Duration, "180"),
Btn("4 часа", WizardStepNames.Duration, "240"),
Btn("5 часов", WizardStepNames.Duration, "300"),
Btn("6 часов", WizardStepNames.Duration, "360")),
Row(Btn("Другое… ✏️", "modal", "DurationFreeText", ButtonStyle.Primary),
Btn("⏭ Пропустить", WizardStepNames.Duration, "_skip"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ChoiceBtn("3 часа", WizardStepNames.Duration, "180"),
ChoiceBtn("4 часа", WizardStepNames.Duration, "240"),
ChoiceBtn("5 часов", WizardStepNames.Duration, "300"),
ChoiceBtn("6 часов", WizardStepNames.Duration, "360")),
Row(ModalTriggerBtn("Другое… ✏️", "DurationFreeText", ButtonStyle.Primary),
ChoiceBtn("⏭ Пропустить", WizardStepNames.Duration, "_skip"),
ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderDateTime() => new(
"📅 Дата и время",
"Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.DateTime);
private static DiscordWizardRender RenderCapacity() => new(
@@ -222,10 +255,10 @@ public static class DiscordWizardStep
"Введите лимит (1..50) и выберите waitlist.",
new[]
{
Row(Btn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
Btn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: WizardStepNames.Capacity);
@@ -241,8 +274,8 @@ public static class DiscordWizardStep
new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"),
new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"),
}),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
@@ -253,8 +286,8 @@ public static class DiscordWizardStep
return new DiscordWizardRender(
"🏷 Выбор клуба",
"У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: null);
}
var options = new List<StringMenuSelectOptionProperties>(clubs.Count);
@@ -268,8 +301,8 @@ public static class DiscordWizardStep
new IMessageComponentProperties[]
{
BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
}
@@ -279,10 +312,10 @@ public static class DiscordWizardStep
"Опубликовать в витрине сейчас?",
new[]
{
Row(Btn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success),
Btn("📝 Только в чате", WizardStepNames.Publish, "no")),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ChoiceBtn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success),
ChoiceBtn("📝 Только в чате", WizardStepNames.Publish, "no")),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
@@ -291,9 +324,9 @@ public static class DiscordWizardStep
BuildConfirmDescription(p),
new[]
{
Row(Btn("✅ Создать", "create", "1", ButtonStyle.Success),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ControlBtn("✅ Создать", "create", ButtonStyle.Success),
ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
@@ -311,9 +344,9 @@ public static class DiscordWizardStep
new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"),
new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"),
}),
Row(Btn("Другое… ✏️", "modal", "PoolSystemDurationFreeText", ButtonStyle.Primary),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ModalTriggerBtn("Другое… ✏️", "PoolSystemDurationFreeText", ButtonStyle.Primary),
ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
@@ -322,18 +355,18 @@ public static class DiscordWizardStep
$"Добавлено: {p.Pool?.Slots.Count ?? 0}.",
new[]
{
Row(Btn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary),
Btn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ChoiceBtn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary),
ChoiceBtn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderPoolSlotDateTime() => new(
"📅 Дата/время слота",
"Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.PoolSlotDateTime);
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
@@ -341,10 +374,10 @@ public static class DiscordWizardStep
"Введите лимит (1..50) и выберите waitlist.",
new[]
{
Row(Btn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
Btn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: WizardStepNames.PoolSlotCapacity);
@@ -353,9 +386,9 @@ public static class DiscordWizardStep
BuildPoolConfirmDescription(p),
new[]
{
Row(Btn("✅ Создать пул", "create", "1", ButtonStyle.Success),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
Row(ControlBtn("✅ Создать пул", "create", ButtonStyle.Success),
ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
@@ -99,9 +99,13 @@ public sealed class DiscordWizardSubmitter
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
// The full exception (with stack trace, Postgres constraint
// name, sometimes partial SQL) is already logged server-side
// on line 86. Show the user a generic message — never leak
// internal error strings to the Discord channel.
await EditDraftMessageAsync(
draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
$"💥 Не удалось создать сессию. Попробуйте ещё раз (попытка {payload.RetryCount}/{MaxRetries}).",
RetryCancelActions(),
ct);
}