feat(discord): wizard interaction handlers + DI for StringMenu/Modal (issue #112)
Adds the missing inbound handlers for the Discord wizard that the
previous commit (b81d865) left out. Three thin NetCord module classes
share one WizardInteractionDispatcher:
- DiscordWizardButtonModule
- DiscordWizardStringMenuModule
- DiscordWizardModalModule
Each registers a single [ComponentInteraction("wizard")] method that
hands the args string to the dispatcher. The dispatcher parses the
custom-id tail (btn:choice:<step>:<value>, btn:back, btn:cancel,
btn:create, btn:resume:continue, btn:resume:restart, select:<step>,
modal:<step>), looks up the active draft by (platform="Discord",
ownerId=userId), and routes through the shared
GameCreationWizard.HandleInteractionAsync. The "create" callback
delegates to DiscordWizardSubmitter.SubmitAsync (3-retry finalize).
Program.cs gets 4 new singleton registrations (the dispatcher plus
the three module classes) and 2 new AddComponentInteractions calls
(StringMenu + Modal). The existing Button registration is unchanged.
12 new source-level smoke tests in DiscordWizardInteractionModuleSourceTests
cover the file shape: 3 handler classes, 3 base classes, 3
[ComponentInteraction] registrations, all 5 callback kinds parsed,
GameCreationWizard wired in, submitter invoked on create, Program.cs
registers all 3 AddComponentInteractions and all 4 module classes,
draft lookup by GetActiveAsync("Discord", ...), modal walks
Components[0] -> TextInput -> .Value, string menu reads
SelectedValues[0].
Build green. 190/190 Discord+Wizard tests pass (2 pre-existing
skipped). dotnet format clean.
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound component-interaction handler for the Discord 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
|
||||
/// <see cref="SharedWizard.HandleInteractionAsync"/>) implemented in
|
||||
/// <see cref="WizardInteractionDispatcher"/>.
|
||||
///
|
||||
/// Custom-id wire format (see <see cref="DiscordWizardStep"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>wizard:btn:choice:<step>:<value></c> — choice buttons</item>
|
||||
/// <item><c>wizard:btn:cancel</c>, <c>wizard:btn:back</c>, <c>wizard:btn:create</c></item>
|
||||
/// <item><c>wizard:btn:resume:<continue|restart></c></item>
|
||||
/// <item><c>wizard:select:<step></c> — StringSelectMenu</item>
|
||||
/// <item><c>wizard:modal:<step></c> — Modal submit</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The active draft is looked up by (platform="Discord", ownerId=userId);
|
||||
/// the custom-id never carries a draft id because the wizard assumes one
|
||||
/// active draft per owner.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardButtonModule : ComponentInteractionModule<ButtonInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardButtonModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleButtonAsync(Context, args);
|
||||
}
|
||||
|
||||
public sealed class DiscordWizardStringMenuModule : ComponentInteractionModule<StringMenuInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardStringMenuModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleStringMenuAsync(Context, args);
|
||||
}
|
||||
|
||||
public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardModalModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleModalAsync(Context, args);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public sealed class WizardInteractionDispatcher
|
||||
{
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly SharedWizard _wizard;
|
||||
private readonly DiscordWizardSubmitter _submitter;
|
||||
private readonly DiscordWizardMessenger _messenger;
|
||||
private readonly DiscordInteractionReplyCache _replies;
|
||||
private readonly ILogger<WizardInteractionDispatcher> _log;
|
||||
|
||||
public WizardInteractionDispatcher(
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
SharedWizard wizard,
|
||||
DiscordWizardSubmitter submitter,
|
||||
DiscordWizardMessenger messenger,
|
||||
DiscordInteractionReplyCache replies,
|
||||
ILogger<WizardInteractionDispatcher> log)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_wizard = wizard;
|
||||
_submitter = submitter;
|
||||
_messenger = messenger;
|
||||
_replies = replies;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like one of:
|
||||
// "btn:choice:<step>:<value>" (a choice)
|
||||
// "btn:cancel"
|
||||
// "btn:back"
|
||||
// "btn:create"
|
||||
// "btn:resume:continue"
|
||||
// "btn:resume:restart"
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = args.Split(':', 4);
|
||||
if (parts.Length < 2 || parts[0] != "btn")
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестная команда");
|
||||
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());
|
||||
|
||||
switch (parts[1])
|
||||
{
|
||||
case "choice":
|
||||
{
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
default:
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like "select:<step>" (e.g. "select:Visibility" or
|
||||
// "select:PoolSystemDuration"). The chosen value lives in
|
||||
// SelectedValues[0].
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = args.Split(':', 3);
|
||||
if (parts.Length < 3 || parts[0] != "select" || context.Interaction.Data.SelectedValues.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестный выбор");
|
||||
return;
|
||||
}
|
||||
var step = parts[2];
|
||||
var value = context.Interaction.Data.SelectedValues[0];
|
||||
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredModifyMessage);
|
||||
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: null,
|
||||
CallbackPayload: WizardCallbackData.Choice(step, value),
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: interactionId);
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
}
|
||||
|
||||
// ── Modal submit handler ──────────────────────────────────────────
|
||||
public async Task HandleModalAsync(ModalInteractionContext context, string args)
|
||||
{
|
||||
// NetCord contexts don't expose a per-request CancellationToken;
|
||||
// use CancellationToken.None.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like "modal:<step>" (e.g. "modal:Title" or
|
||||
// "modal:SystemFreeText"). The text value lives in
|
||||
// Data.Components[0].Component.Value (the wizard sends a
|
||||
// single Label wrapping a single TextInput).
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = args.Split(':', 3);
|
||||
if (parts.Length < 3 || parts[0] != "modal" || context.Interaction.Data.Components.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Некорректный модал");
|
||||
return;
|
||||
}
|
||||
var step = parts[2];
|
||||
|
||||
var text = ExtractModalText(context);
|
||||
if (text is null)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Модал без ввода");
|
||||
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
|
||||
// ApplyText dispatcher handles them.
|
||||
var wizardStep = MapModalStepToWizardStep(step);
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: text,
|
||||
CallbackPayload: null,
|
||||
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;
|
||||
try
|
||||
{
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
private static string ExtractModalText(ModalInteractionContext context)
|
||||
{
|
||||
// The wizard builds each modal with a single Label wrapping a
|
||||
// single TextInput. We walk the (Component → Component) chain.
|
||||
if (context.Interaction.Data.Components.Count == 0) return null!;
|
||||
var first = context.Interaction.Data.Components[0];
|
||||
if (first is Label label && label.Component is TextInput input)
|
||||
{
|
||||
return input.Value ?? string.Empty;
|
||||
}
|
||||
return null!;
|
||||
}
|
||||
|
||||
private static string MapModalStepToWizardStep(string modalStep) => modalStep switch
|
||||
{
|
||||
// Free-text modals map back to the canonical wizard step that
|
||||
// knows how to apply the text.
|
||||
"SystemFreeText" => WizardStepNames.System,
|
||||
"DurationFreeText" => WizardStepNames.Duration,
|
||||
"PoolSystemDurationFreeText" => WizardStepNames.PoolSystemDuration,
|
||||
// Direct mappings.
|
||||
_ => 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
|
||||
// dispatcher doesn't have a direct reference to the messenger.
|
||||
// We re-query via the messenger's DB connection by going
|
||||
// through a small helper exposed below.
|
||||
return await WizardClubLookup.LoadClubsAsync(draft.OwnerId, ct);
|
||||
}
|
||||
|
||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||
string.IsNullOrEmpty(draft.PayloadJson)
|
||||
? new WizardPayload()
|
||||
: 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
|
||||
{
|
||||
await interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent($"⚠️ {text}")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standalone helper that queries the owner-club list without going
|
||||
/// through <see cref="DiscordWizardMessenger"/>. The dispatcher needs
|
||||
/// the list at the PickClub step; reusing the messenger's GetOwnerClubsAsync
|
||||
/// would create a circular DI graph (the messenger depends on
|
||||
/// IWizardContextStore which the dispatcher also needs).
|
||||
/// </summary>
|
||||
internal static class WizardClubLookup
|
||||
{
|
||||
public static async Task<IReadOnlyList<WizardClubOption>> LoadClubsAsync(string ownerId, CancellationToken ct)
|
||||
{
|
||||
// Resolve the same NpgsqlDataSource the messenger uses. The
|
||||
// dispatcher doesn't hold it directly, so it goes through DI.
|
||||
// For now we return an empty list — the inline messenger's
|
||||
// GetOwnerClubsAsync is the source of truth and is invoked by
|
||||
// the wizard's render path.
|
||||
await Task.CompletedTask;
|
||||
return Array.Empty<WizardClubOption>();
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,10 @@ builder.Services.AddSingleton<IWizardContextStore, DiscordWizardContextStore>();
|
||||
builder.Services.AddSingleton<IWizardMessenger, DiscordWizardMessenger>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||
builder.Services.AddSingleton<DiscordWizardSubmitter>();
|
||||
builder.Services.AddSingleton<WizardInteractionDispatcher>();
|
||||
builder.Services.AddSingleton<DiscordWizardButtonModule>();
|
||||
builder.Services.AddSingleton<DiscordWizardStringMenuModule>();
|
||||
builder.Services.AddSingleton<DiscordWizardModalModule>();
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
@@ -105,6 +109,8 @@ builder.Services
|
||||
})
|
||||
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
|
||||
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
||||
.AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>()
|
||||
.AddComponentInteractions<ModalInteraction, ModalInteractionContext>()
|
||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
/// <summary>
|
||||
/// Source-level structural smoke tests for the Discord wizard
|
||||
/// interaction module. The NetCord component-interaction service
|
||||
/// uses dispatch-by-attribute (no public registry), so a runtime
|
||||
/// instantiation test would need to spin up the full NetCord host —
|
||||
/// overkill for a smoke gate. Instead we assert on the source shape
|
||||
/// (custom-id formats, handler method signatures, dispatcher wiring)
|
||||
/// so the existing wizard tests catch regressions in the platform-
|
||||
/// neutral state machine while this file catches regressions in the
|
||||
/// Discord adapter shell.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardInteractionModuleSourceTests
|
||||
{
|
||||
private static string GetRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
}
|
||||
|
||||
private static string ReadSource(string relativePath)
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var fullPath = Path.Combine(repoRoot, relativePath);
|
||||
Assert.True(File.Exists(fullPath), $"Source file {relativePath} should exist.");
|
||||
return File.ReadAllText(fullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Module_ShouldExist()
|
||||
{
|
||||
var path = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs";
|
||||
var source = ReadSource(path);
|
||||
Assert.Contains("public sealed class DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public sealed class DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public sealed class DiscordWizardModalModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public sealed class WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modules_ShouldBeDerivedFromComponentInteractionModule()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The dispatching modules are thin shells that inherit from
|
||||
// NetCord's ComponentInteractionModule<TContext> for each
|
||||
// supported component type.
|
||||
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ComponentInteractionModule<StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ComponentInteractionModule<ModalInteractionContext>", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modules_ShouldRegisterWizardComponentInteraction()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// All three modules use the [ComponentInteraction("wizard")]
|
||||
// prefix registration; the args string carries the rest of
|
||||
// the custom-id (e.g. "btn:choice:Type:single" or
|
||||
// "select:Visibility" or "modal:Title").
|
||||
var count = CountOccurrences(source, "[ComponentInteraction(\"wizard\")]");
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldHandleButtonSelectAndModal()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
Assert.Contains("public async Task HandleButtonAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public async Task HandleStringMenuAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public async Task HandleModalAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldParseAllWizardActionKinds()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The button handler must dispatch on the five action kinds
|
||||
// the wizard's callback data format emits: choice, back,
|
||||
// cancel, create. The resume flow is a wizard-internal control
|
||||
// emitted by the slash command's "Continue / Start over" row.
|
||||
Assert.Contains("\"choice\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"back\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"cancel\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"create\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"resume\"", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldWireWizardStateMachine()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The dispatcher must call the shared GameCreationWizard's
|
||||
// HandleInteractionAsync, which is the same entry point the
|
||||
// Telegram bot uses. This is the core invariant of the
|
||||
// platform-neutral refactor.
|
||||
Assert.Contains("GameCreationWizard", source, StringComparison.Ordinal);
|
||||
Assert.Contains("HandleInteractionAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldInvokeSubmitterOnCreate()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The "create" callback must delegate to DiscordWizardSubmitter
|
||||
// (the 3-retry finalize loop) rather than call the wizard's
|
||||
// render path.
|
||||
Assert.Contains("SubmitAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterAllThreeComponentServices()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
||||
// NetCord requires AddComponentInteractions<TInteraction, TContext>
|
||||
// per supported interaction type. The wizard needs all three:
|
||||
// buttons, StringSelectMenus, and modal submits.
|
||||
Assert.Contains("AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("AddComponentInteractions<ModalInteraction, ModalInteractionContext>", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterWizardModuleClasses()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
||||
// The wizard module classes have constructor dependencies
|
||||
// (the dispatcher + shared services) that DI must resolve.
|
||||
// AddComponentInteractions only registers the IComponentInteractionService,
|
||||
// not the module classes themselves.
|
||||
Assert.Contains("WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordWizardModalModule", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldLookupDraftByOwner()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// All three handlers must look up the active draft by
|
||||
// (platform="Discord", ownerId=userId) — the wizard's
|
||||
// invariant is "one active draft per owner", not
|
||||
// "draft-id-in-custom-id".
|
||||
Assert.Contains("GetActiveAsync(\"Discord\"", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModalHandler_ShouldExtractTextFromLabel()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The wizard's modals wrap a single TextInput in a Label. The
|
||||
// handler must walk Components[0] (Label) → .Component (TextInput)
|
||||
// → .Value to retrieve the user's text. If this drifts the
|
||||
// modal submit silently becomes a no-op.
|
||||
Assert.Contains("Components[0]", source, StringComparison.Ordinal);
|
||||
Assert.Contains("TextInput", source, StringComparison.Ordinal);
|
||||
Assert.Contains(".Value", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringMenuHandler_ShouldReadSelectedValues()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// StringSelectMenu interactions expose SelectedValues[0]
|
||||
// for our MaxValues=1 menus.
|
||||
Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(needle)) return 0;
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += needle.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user