fix(discord): open modal popup after wizard state advance

The previous commit (f095209) shipped a DiscordWizardInteractionModule
whose MaybeOpenModalAsync helper was a documented no-op: the handler
called SendResponseAsync(DeferredMessage) early, then tried to swap
the deferred response for a Modal via ModifyResponseAsync, which
NetCord forbids (the response type is locked after the first call).
As a result, the wizard's button click that advances to a text-input
step (Title, Description, Cover, DateTime, Capacity, PoolSlot*…)
edited the draft embed but never popped the modal, leaving the user
stuck on a step that demanded popup input.

This commit restructures the dispatcher:
- Run the wizard FIRST (a separate REST call to edit the draft embed;
  no interaction response is touched yet).
- Then send the interaction response as either
  InteractionCallback.Modal(modalProperties) when the new step is in
  the OpenModal set (Title, Description, Cover, DateTime, Capacity,
  PoolSlotDateTime, PoolSlotCapacity, SystemFreeText,
  DurationFreeText, PoolSystemDurationFreeText), or
  InteractionCallback.DeferredMessage(MessageFlags.Ephemeral) otherwise.
- The Modal handler's draft.Step = wizardStep / originalStep restore
  hack is removed: the wizard's mutation of draft.Step must persist
  to the DB (the wizard already called _drafts.UpsertAsync before we
  get control back), so restoring locally would only mask the truth
  from the next interaction's GetActiveAsync.
- The Resume:continue path re-renders the current step via the
  messenger and acks the click with a deferred ephemeral.
- The Create path delegates to DiscordWizardSubmitter.SubmitAsync and
  acks the click with a deferred ephemeral.
- The constructor now assigns _messenger (was unassigned, caught by
  nullable-flow analysis).

Also adds deliverable.md in the repo root describing the full Discord
adapter for issue #112.

Build green. 190/190 Discord+Wizard tests pass (2 pre-existing skipped).
dotnet format clean. The previous 12 source-level smoke tests still
pass — they assert on file shape, not runtime flow.
This commit is contained in:
Coder
2026-06-05 18:53:59 +03:00
parent f0952096f3
commit b1bd47f6c1
2 changed files with 304 additions and 311 deletions
@@ -19,7 +19,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// One class per interaction context type — NetCord's
/// <c>ComponentInteractionModule&lt;TContext&gt;</c> is single-context. All
/// three classes share the same dispatch table (parse customId → load
/// draft → check owner → call the shared
/// draft → call the shared
/// <see cref="SharedWizard.HandleInteractionAsync"/>) implemented in
/// <see cref="WizardInteractionDispatcher"/>.
///
@@ -81,8 +81,8 @@ public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalI
/// <summary>
/// Shared dispatch table for the three wizard interaction modules.
/// Owns all the stateful collaborators (drafts, context store, wizard
/// state machine, submitter, reply cache) so the three NetCord module
/// shells can stay trivially thin.
/// state machine, submitter, messenger, reply cache) so the three
/// NetCord module shells can stay trivially thin.
/// </summary>
public sealed class WizardInteractionDispatcher
{
@@ -112,12 +112,39 @@ public sealed class WizardInteractionDispatcher
_log = log;
}
/// <summary>
/// Steps that, after the wizard's state advance, expect the user
/// to fill a popup. The dispatcher uses this to decide whether
/// the interaction response is a Modal() (the user sees a popup)
/// or a DeferredMessage() (the wizard's edit is the only visible
/// feedback). Keep in sync with <see cref="DiscordWizardStep.OpenModalStep"/>
/// returns.
/// </summary>
private static readonly IReadOnlySet<string> StepsThatOpenModal = new HashSet<string>(StringComparer.Ordinal)
{
WizardStepNames.Title,
WizardStepNames.Description,
WizardStepNames.Cover,
WizardStepNames.DateTime,
WizardStepNames.Capacity,
WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolSlotCapacity,
"SystemFreeText",
"DurationFreeText",
"PoolSystemDurationFreeText",
};
// ── Button handler ────────────────────────────────────────────────
public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
{
// NetCord contexts don't expose a per-request CancellationToken;
// the REST calls already carry their own timeout, so we use
// CancellationToken.None for the DB and HTTP calls.
// NetCord only allows one response per interaction. The
// previous implementation deferred too early and then
// tried to "swap" the deferred response for a Modal — which
// NetCord forbids. The new flow is: do the wizard work
// (which is a separate REST call to edit the draft message),
// THEN send the interaction response. The response is
// either a Modal popup (when the new step needs text input)
// or a plain DeferredMessage ack.
var ct = CancellationToken.None;
// args looks like one of:
// "btn:choice:<step>:<value>" (a choice)
@@ -146,117 +173,111 @@ public sealed class WizardInteractionDispatcher
return;
}
// Acknowledge the click first so the user sees no lag; the
// wizard's edit is a separate REST call. The "back" / "cancel"
// actions need to defer so the wizard's edits complete before
// the response window closes.
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
// Special case: "create" doesn't go through the wizard — the
// submitter edits the draft message directly with the result
// embed ("✅ Создано" or retry buttons). After the submitter
// returns, ack the click so the user doesn't see "Application
// did not respond".
if (parts[1] == "create")
{
await _submitter.SubmitAsync(draft, ct);
_contextStore.Remove(draft.Id);
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
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
// handle the two resume kinds directly.
if (parts[1] == "resume")
{
await HandleResumeAsync(context, parts, draft, ownerId, interactionId, ct);
return;
}
// Choice / back / cancel — route through the shared wizard.
string callback;
switch (parts[1])
{
case "choice":
if (parts.Length < 4)
{
if (parts.Length < 4)
{
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
return;
}
var step = parts[2];
var value = parts[3];
var callback = WizardCallbackData.Choice(step, value);
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: callback,
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
await MaybeOpenModalAsync(context.Interaction, draft, ct);
break;
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
return;
}
callback = WizardCallbackData.Choice(parts[2], parts[3]);
break;
case "back":
{
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: WizardCallbackData.Back(),
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
await MaybeOpenModalAsync(context.Interaction, draft, ct);
break;
}
callback = WizardCallbackData.Back();
break;
case "cancel":
{
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: WizardCallbackData.Cancel(),
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
_contextStore.Remove(draft.Id);
break;
}
case "create":
{
// The submitter edits the draft message directly via
// the messenger's REST client, so we don't need the
// wizard's render here.
await _submitter.SubmitAsync(draft, ct);
_contextStore.Remove(draft.Id);
break;
}
case "resume":
{
// resume:continue = re-render the current step; resume:restart
// = delete the existing draft and tell the user to re-run
// /newsession-wizard.
if (parts.Length >= 3 && parts[2] == "restart")
{
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
await context.Interaction.ModifyResponseAsync(msg =>
{
msg.Content = "♻️ Мастер сброшен. Запустите /newsession-wizard заново.";
msg.Flags = MessageFlags.Ephemeral;
});
}
else
{
// continue: re-render the current step.
var payload = LoadPayload(draft);
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: "wizard:resume:continue",
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
// Use the wizard's edit path via a synthetic callback
// for the current step. The state machine has no
// special resume case — we just edit the embed.
var messenger = ResolveMessenger();
await messenger.EditDraftMessageAsync(draft, text, actions, ct);
_ = interaction; // suppress unused warning when not used below
}
break;
}
callback = WizardCallbackData.Cancel();
break;
default:
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
break;
return;
}
var interaction = new WizardInteraction(
OwnerId: ownerId,
Text: null,
CallbackPayload: callback,
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
await _wizard.HandleInteractionAsync(interaction, draft, ct);
// After the wizard's state advance, decide the response.
// The wizard's EditDraftMessageAsync already updated the
// draft embed; we just need the interaction response to
// either pop a modal or quietly ack.
if (parts[1] == "cancel")
{
_contextStore.Remove(draft.Id);
}
await RespondAfterWizardAsync(context, draft, ct);
}
private async Task HandleResumeAsync(
ButtonInteractionContext context,
string[] parts,
WizardDraft draft,
string ownerId,
string interactionId,
CancellationToken ct)
{
// resume:continue → re-render the current step (the wizard
// itself doesn't know about resume, so we just edit the
// draft message via the messenger).
// resume:restart → delete the draft and prompt the user to
// re-run /newsession-wizard.
if (parts.Length >= 3 && parts[2] == "restart")
{
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
// continue
var payload = LoadPayload(draft);
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
}
// ── StringSelectMenu handler ───────────────────────────────────────
public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args)
{
// NetCord contexts don't expose a per-request CancellationToken;
// use CancellationToken.None and let the REST timeout apply.
// NetCord's interaction response type is locked after the
// first SendResponse call. For menu selections we don't
// need a popup, so we always defer. The wizard's edit is
// a separate REST call.
var ct = CancellationToken.None;
// args looks like "select:<step>" (e.g. "select:Visibility" or
// "select:PoolSystemDuration"). The chosen value lives in
@@ -298,8 +319,10 @@ public sealed class WizardInteractionDispatcher
// ── Modal submit handler ──────────────────────────────────────────
public async Task HandleModalAsync(ModalInteractionContext context, string args)
{
// NetCord contexts don't expose a per-request CancellationToken;
// use CancellationToken.None.
// The modal text becomes the user's input. The wizard's
// ApplyText dispatcher consumes it as either a text-input
// step (Title, Description, etc.) or, via the helper, a
// free-text variant of System/Duration/PoolSystemDuration.
var ct = CancellationToken.None;
// args looks like "modal:<step>" (e.g. "modal:Title" or
// "modal:SystemFreeText"). The text value lives in
@@ -333,13 +356,10 @@ public sealed class WizardInteractionDispatcher
return;
}
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
// Modal values are routed by step name. The shared wizard knows
// how to apply Title, Description, etc.; the free-text variants
// (SystemFreeText, DurationFreeText, PoolSystemDurationFreeText)
// are mapped to the equivalent step here so the wizard's existing
// are mapped to the canonical step here so the wizard's existing
// ApplyText dispatcher handles them.
var wizardStep = MapModalStepToWizardStep(step);
var interaction = new WizardInteraction(
@@ -349,24 +369,58 @@ public sealed class WizardInteractionDispatcher
PhotoFileId: null,
PhotoUrl: null,
InteractionId: interactionId);
// For free-text modal steps the wizard's "current step" is the
// canonical step (System, Duration, etc.), but the user just
// submitted via the free-text modal. Temporarily adjust the
// draft so the wizard's ApplyText runs the right branch.
var originalStep = draft.Step;
draft.Step = wizardStep;
// submitted via the free-text modal. Temporarily set the
// draft.Step to the canonical step so the wizard's ApplyText
// runs the right branch. The wizard then advances draft.Step
// to the NEXT step (e.g. Duration) and persists that via
// _drafts.UpsertAsync. We must NOT restore draft.Step to
// the original value afterwards — the DB has already been
// updated to the new step, and restoring locally would only
// mask the truth from the next interaction's GetActiveAsync.
if (draft.Step != wizardStep)
{
draft.Step = wizardStep;
}
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
await _wizard.HandleInteractionAsync(interaction, draft, ct);
}
// ── Response helper ───────────────────────────────────────────────
private async Task RespondAfterWizardAsync(
ButtonInteractionContext context,
WizardDraft draft,
CancellationToken ct)
{
// The wizard's state machine has advanced draft.Step. Re-render
// the new step locally to discover whether it expects a popup,
// then send the appropriate response.
try
{
await _wizard.HandleInteractionAsync(interaction, draft, ct);
if (StepsThatOpenModal.Contains(draft.Step))
{
var modal = DiscordWizardStep.BuildModal(draft.Step, draft.ChatId);
if (modal is not null)
{
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
return;
}
}
}
finally
catch (Exception ex)
{
// The wizard sets draft.Step to the next step; restore the
// user's current step for state correctness in the next
// button click (e.g. if they press Back they should land
// on the step they were on, not the post-modal step).
draft.Step = originalStep;
_log.LogWarning(ex, "Modal popup failed for step {Step}; falling back to ack.", draft.Step);
}
// No popup needed — the wizard's edit is the only visible
// feedback. Acknowledge with a deferred message so Discord
// doesn't show "Application did not respond".
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
}
// ── Helpers ───────────────────────────────────────────────────────
@@ -394,54 +448,6 @@ public sealed class WizardInteractionDispatcher
_ => modalStep,
};
private async Task MaybeOpenModalAsync(Interaction interaction, WizardDraft draft, CancellationToken ct)
{
// The wizard has just advanced draft.Step. Re-render the step
// locally to discover the OpenModalStep hint, then send the
// modal as a follow-up. We use SendResponseAsync's deferred
// pattern: the wizard's edit already happened, this is the
// post-edit prompt.
try
{
var clubs = draft.Step == WizardStepNames.PickClub
? await LoadClubsAsync(draft, ct)
: null;
var render = DiscordWizardStep.Render(draft, LoadPayload(draft), clubs);
if (string.IsNullOrEmpty(render.OpenModalStep))
{
return;
}
var modal = DiscordWizardStep.BuildModal(render.OpenModalStep, draft.ChatId);
if (modal is null)
{
return;
}
await interaction.ModifyResponseAsync(msg =>
{
// The modal callback is the wizard's "respond with
// modal" — NetCord only allows one response per
// interaction, so we replace the deferred "empty"
// response with the modal. This is the documented
// pattern for "open a modal as a follow-up to a button
// click" (the wizard's REST edit to the draft message
// already happened in HandleInteractionAsync).
_ = msg; // unreachable
});
// We can't actually swap a deferred-message response for a
// modal — NetCord locks the response type after SendResponse.
// Instead we expose a follow-up modal: NetCord's
// InteractionCallback.Modal can be returned as the
// FollowupMessage? No — the only way to open a modal is
// the interaction's response. Since we already deferred
// a message, the modal path here is a no-op.
_ = modal; // surfaced via BuildModal for the next iteration
}
catch (Exception ex)
{
_log.LogWarning(ex, "MaybeOpenModalAsync swallowed an exception (non-fatal).");
}
}
private async Task<IReadOnlyList<WizardClubOption>?> LoadClubsAsync(WizardDraft draft, CancellationToken ct)
{
// The wizard's GetOwnerClubsAsync would do this for us, but the
@@ -457,8 +463,6 @@ public sealed class WizardInteractionDispatcher
: System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
private DiscordWizardMessenger ResolveMessenger() => _messenger;
private static async Task AckWithErrorAsync(Interaction interaction, string text)
{
try