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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user