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:
+172
-168
@@ -19,7 +19,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
/// One class per interaction context type — NetCord's
|
||||
/// <c>ComponentInteractionModule<TContext></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
|
||||
|
||||
Reference in New Issue
Block a user