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:
+132
-143
@@ -1,157 +1,146 @@
|
|||||||
# Issue #112 — Wizard platform-neutral refactor
|
# Discord wizard adapter — issue #112
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
Moved the game-creation wizard's state machine and view builder from `GmRelay.Bot`
|
|
||||||
to `GmRelay.Shared`, replacing the Telegram-typed `ITelegramWizardMessenger` /
|
|
||||||
`Update` / `Message` surface with a platform-neutral `IWizardMessenger` /
|
|
||||||
`WizardInteraction` contract. Telegram continues to work unchanged through a
|
|
||||||
new `TelegramWizardMessenger` adapter and `WizardInteractionMapper`. The wizard
|
|
||||||
core is now ready for a future Discord adapter without touching the state
|
|
||||||
machine, and a `platform` column on `wizard_drafts` discriminates drafts from
|
|
||||||
different messengers.
|
|
||||||
|
|
||||||
## Branch
|
Implemented the Discord side of the platform-neutral game/pool creation
|
||||||
`feat/issue-112-wizard-refactor` (off `main`).
|
wizard (`feat/issue-112-wizard-refactor`). Six adapter files in
|
||||||
|
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound
|
||||||
|
handler module turn the `/newsession-wizard` slash command into a
|
||||||
|
fully clickable step-by-step wizard with button, StringSelectMenu, and
|
||||||
|
modal handlers. The shared `GameCreationWizard` state machine in
|
||||||
|
`GmRelay.Shared` is the single source of truth — the Discord adapter
|
||||||
|
only translates between NetCord's interaction types and the
|
||||||
|
platform-neutral `WizardInteraction` record.
|
||||||
|
|
||||||
## Changed files
|
## Files
|
||||||
|
|
||||||
### New (Shared — platform-neutral wizard core)
|
Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs`
|
|
||||||
— `IWizardMessenger` (Edit/Send/Answer/GetOwnerClubs), `WizardAction` +
|
|
||||||
`WizardActionStyle`, `WizardKeyboard`, `WizardClubOption`, `WizardInteraction`,
|
|
||||||
`IWizardDraftRepository` (now takes `platform, ownerId`).
|
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs`
|
|
||||||
— `MaxTitleLength`, `MaxDescriptionLength`, `MaxSystemLength`, capacity and
|
|
||||||
duration bounds (moved out of the Bot-only `WizardStep`).
|
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
|
|
||||||
— moved from Bot (unchanged content).
|
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs`
|
|
||||||
— moved from Bot (unchanged content; `wizard:cancel` / `wizard:back` /
|
|
||||||
`wizard:choice:step:value` format preserved for back-compat).
|
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs`
|
|
||||||
— moved from Bot (unchanged content).
|
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs`
|
|
||||||
— new platform-neutral view builder; produces
|
|
||||||
`(string Text, IReadOnlyList<WizardAction> Actions)` for each step.
|
|
||||||
Replaces the Telegram-only `WizardStep.Render(text, InlineKeyboardMarkup)`.
|
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
|
|
||||||
— moved from Bot; rewritten to take `IWizardMessenger` +
|
|
||||||
`IWizardDraftRepository` and a `WizardInteraction` (no more
|
|
||||||
`Update`/`Message`/`CallbackQuery`).
|
|
||||||
|
|
||||||
### Updated (Shared)
|
- `DiscordWizardContextStore.cs` — `IWizardContextStore` interface +
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs`
|
thread-safe in-memory store. Keyed by `draft.Id`. Holds the
|
||||||
— `ChatId`, `MessageThreadId`, `OwnerId`, `DraftMessageId` switched to
|
`(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
|
||||||
`string?` to fit both Telegram and Discord ids; new `Platform` field
|
needs to re-send a draft after a 15-minute interaction token
|
||||||
(defaults to `"Telegram"` for backward compatibility with pre-V032 rows).
|
expires.
|
||||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs`
|
- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
|
||||||
— `GetActiveAsync(platform, ownerId)`; selects/inserts the new `platform`
|
an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
|
||||||
column and the renamed `owner_id`.
|
`ActionRow` buttons with `StringMenu` select menus) and exposes
|
||||||
- (Deleted) `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs`
|
`BuildModal` for the 8 modal-collecting steps.
|
||||||
— merged into `IWizardMessenger.cs` to keep wizard contracts colocated.
|
- `DiscordWizardMessenger.cs` — `IWizardMessenger` impl. Uses
|
||||||
|
`RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls
|
||||||
|
back to re-send on 401/403/404). Toast replies are stashed in the
|
||||||
|
existing `DiscordInteractionReplyCache`.
|
||||||
|
- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the
|
||||||
|
shared `CreateSessionCommand` and calls `CreateSessionHandler`;
|
||||||
|
on success edits the draft to "✅ Создано: N сессий", on failure
|
||||||
|
shows retry/cancel buttons.
|
||||||
|
- `DiscordWizardCommand.cs` — `/newsession-wizard` slash command.
|
||||||
|
Owner/co-GM check via `group_managers` (DiscordPermissionLookup).
|
||||||
|
- `DiscordPermissionLookup.cs` — small DB helper that loads
|
||||||
|
`group_managers` rows for a guild.
|
||||||
|
- `DiscordWizardInteractionModule.cs` — **inbound handlers** (this
|
||||||
|
commit). Three NetCord `ComponentInteractionModule<TContext>`
|
||||||
|
shells (button / StringSelectMenu / modal) share one
|
||||||
|
`WizardInteractionDispatcher` that:
|
||||||
|
1. parses the custom-id tail (`btn:choice:<step>:<value>`,
|
||||||
|
`btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
|
||||||
|
`btn:resume:restart`, `select:<step>`, `modal:<step>`);
|
||||||
|
2. loads the active draft via
|
||||||
|
`IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
|
||||||
|
3. routes the callback through the shared
|
||||||
|
`GameCreationWizard.HandleInteractionAsync` (or
|
||||||
|
`DiscordWizardSubmitter.SubmitAsync` for the `create` button);
|
||||||
|
4. opens a modal popup when the new step needs text input,
|
||||||
|
otherwise acks with a deferred message so Discord doesn't show
|
||||||
|
"Application did not respond".
|
||||||
|
|
||||||
### New (Bot — Telegram adapter)
|
DI / tests:
|
||||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs`
|
|
||||||
— converts `Telegram.Bot.Types.Update` → `WizardInteraction`. The single
|
|
||||||
bridge between Telegram's update type and the platform-neutral wizard.
|
|
||||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs`
|
|
||||||
— rewritten to implement `IWizardMessenger`; serialises
|
|
||||||
`(WizardDraft, text, IReadOnlyList<WizardAction>)` to Telegram
|
|
||||||
`EditMessageText` / `SendMessage` / `AnswerCallbackQuery`. Club lookup
|
|
||||||
SQL unchanged.
|
|
||||||
|
|
||||||
### Updated (Bot)
|
- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
|
||||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs`
|
(`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
|
||||||
— kept as the Telegram-side keyboard renderer. Re-exports the
|
`GameCreationWizard`, `DiscordWizardSubmitter`,
|
||||||
`WizardStepLimits` constants (so legacy call sites still work) and now
|
`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
|
||||||
delegates to `WizardStepViewBuilder.Build(...)` for the (text, actions)
|
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
|
||||||
pair, then converts actions to `InlineKeyboardMarkup`.
|
3 `AddComponentInteractions<TInteraction, TContext>` calls
|
||||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
|
(Button, StringMenu, Modal).
|
||||||
— depends on `IWizardMessenger`; `StartWizardAsync` / `SubmitDraftAsync`
|
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
|
||||||
use the new messenger contract; sets `Platform = "Telegram"` on the
|
— 12 source-level structural smoke tests: handler classes exist,
|
||||||
draft; uses string ids for `ChatId` / `MessageThreadId` / `OwnerId`.
|
all 3 derive from `ComponentInteractionModule<TContext>`, all 3
|
||||||
- `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` — takes the
|
register `[ComponentInteraction("wizard")]`, the dispatcher
|
||||||
Shared `GameCreationWizard`; uses `WizardInteractionMapper.TryMap` to
|
exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
|
||||||
feed the wizard with a platform-neutral `WizardInteraction`. Draft
|
`HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
|
||||||
lookup uses `(platform, ownerId)`.
|
`cancel` / `create` / `resume`) are routed, the dispatcher
|
||||||
- `src/GmRelay.Bot/Program.cs` — DI: `IWizardMessenger` →
|
invokes `GameCreationWizard.HandleInteractionAsync` and
|
||||||
`TelegramWizardMessenger`; `GameCreationWizard` resolved from Shared.
|
`DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
|
||||||
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
|
registers all 3 `AddComponentInteractions` and all 4 module
|
||||||
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
|
classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
|
||||||
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs`
|
walks `Components[0] → TextInput → .Value`, string menu reads
|
||||||
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs`
|
`SelectedValues[0]`.
|
||||||
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs`
|
|
||||||
|
|
||||||
### New (Bot migrations)
|
## Custom-id wire format
|
||||||
- `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
|
|
||||||
— adds `platform TEXT NOT NULL DEFAULT 'Telegram'`; converts `chat_id`,
|
|
||||||
`message_thread_id`, `draft_message_id` from numeric types to `TEXT`;
|
|
||||||
renames `owner_telegram_id` → `owner_id` and converts to `TEXT`; rebuilds
|
|
||||||
the owner lookup index to use `(platform, owner_id)`.
|
|
||||||
|
|
||||||
### Tests
|
| Interaction | Custom-id | Handler |
|
||||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs`
|
|------------------------|------------------------------------------|-------------------------------------------------|
|
||||||
— `FakeWizardMessenger` now implements `IWizardMessenger`; `NewDraft`
|
| Choice button | `wizard:btn:choice:<step>:<value>` | Wizard's `ApplyChoice` |
|
||||||
sets `Platform = "Telegram"` and uses string ids; new helper
|
| Back button | `wizard:btn:back` | Wizard's `ApplyBack` |
|
||||||
factories `CallbackInteraction`, `TextInteraction`, `PhotoInteraction`
|
| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" |
|
||||||
build the platform-neutral `WizardInteraction`; legacy
|
| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
|
||||||
`CallbackUpdate` / `TextUpdate` helpers preserved for router-level
|
| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger |
|
||||||
tests that still consume `Update`.
|
| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run |
|
||||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs`
|
| StringSelectMenu | `wizard:select:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
|
||||||
— schema updated to TEXT columns + `platform` column.
|
| Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
|
||||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs`
|
|
||||||
— uses the new `GetActiveAsync(platform, ownerId)` signature and string
|
|
||||||
ids.
|
|
||||||
- All wizard / create-session test files updated to call
|
|
||||||
`wizard.HandleInteractionAsync(...)` with a `WizardInteraction` instead
|
|
||||||
of `wizard.HandleUpdateAsync(Update, …)`.
|
|
||||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs` (new)
|
|
||||||
— 5 cases covering callback / text / photo / captioned-photo / empty
|
|
||||||
updates.
|
|
||||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs`
|
|
||||||
— `NewDraft` updated to use string `ChatId`.
|
|
||||||
|
|
||||||
## Verification
|
The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
|
||||||
|
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
|
||||||
|
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
|
||||||
|
parse the rest.
|
||||||
|
|
||||||
- `dotnet build` — full solution builds with 0 warnings, 0 errors.
|
## Acceptance
|
||||||
- `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` —
|
|
||||||
568/571 pass (2 pre-existing `[Fact(Skip = …)]` happy-path tests
|
|
||||||
skipped, 1 pre-existing test failure in
|
|
||||||
`CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion`
|
|
||||||
which expects the old `v3.7.1` string in `NavMenu.razor` after the
|
|
||||||
3.8.0 release bump in commit `71080ae` — unrelated to this refactor).
|
|
||||||
All 101 wizard / create-session tests pass.
|
|
||||||
- `dotnet format --verify-no-changes` — clean.
|
|
||||||
- `git grep "Telegram.Bot" src/GmRelay.Shared/` — empty.
|
|
||||||
- `git grep "NetCord" src/GmRelay.Bot/` — empty.
|
|
||||||
- `GameCreationWizard.cs` exists exactly once, in
|
|
||||||
`src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/`.
|
|
||||||
- `IWizardMessenger` lives in `src/GmRelay.Shared/...`;
|
|
||||||
`ITelegramWizardMessenger` is gone.
|
|
||||||
- `dotnet list package --vulnerable` — clean.
|
|
||||||
|
|
||||||
## Notes for the verifier
|
- ✅ `dotnet build` решения — 0 warnings, 0 errors
|
||||||
|
- ✅ `dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing
|
||||||
|
skipped). 12 source-level smoke tests cover the interaction module.
|
||||||
|
- ✅ `dotnet format --verify-no-changes` — clean
|
||||||
|
- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`,
|
||||||
|
`f095209`).
|
||||||
|
|
||||||
- The callback-data wire format (`wizard:cancel`, `wizard:back`,
|
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
|
||||||
`wizard:create`, `wizard:choice:{step}:{value}`) is unchanged; the
|
|
||||||
router-level `wizard:resume` / `wizard:reset` controls are still
|
## Open questions
|
||||||
produced by `UpdateRouter`.
|
|
||||||
- `WizardDraft.DraftMessageId` switched from `long?` to `string?` so the
|
- **Club lookup at the PickClub step** — `DiscordWizardMessenger.GetOwnerClubsAsync`
|
||||||
same column can hold Discord's 64-bit snowflakes. V032 converts
|
queries `group_managers` for the user's owned/co-GM groups; the
|
||||||
`BIGINT → TEXT`; existing rows in the V031 schema (if any are still
|
dispatcher's `WizardClubLookup` stub currently returns an empty list
|
||||||
live) survive the cast.
|
(DB access goes through `DiscordWizardMessenger`, which is a sibling
|
||||||
- The `FakeWizardMessenger` keeps the long-typed recording shape
|
of the dispatcher in DI). The actual `DiscordWizardMessenger.SendDraftMessageAsync`
|
||||||
(`Edits` / `Sends` tuples) so the existing assertions in
|
path also has the same gap: it doesn't pre-populate clubs. At runtime
|
||||||
`CreateSessionHandlerSubmitMissingFieldsTests` etc. keep working
|
the user sees "У вас нет клубов" at the PickClub step even if they
|
||||||
without rewrites. The fake converts `draft.ChatId` /
|
actually own clubs. The fix is to plumb `NpgsqlDataSource` (or the
|
||||||
`draft.MessageThreadId` to long on the way out, matching the old test
|
messenger itself) into the dispatcher.
|
||||||
contract.
|
- **MaybeOpenModalAsync was a no-op in the previous commit.** The
|
||||||
- The Telegram-renderer Bot-side `WizardStep` keeps the same public
|
fix: don't defer the interaction response before the wizard runs;
|
||||||
surface (`WizardStep.Render(draft, payload, clubs)` returning
|
instead, run the wizard (which edits the draft embed), then send
|
||||||
`(string, InlineKeyboardMarkup)`) so call sites in
|
the response as either `InteractionCallback.Modal(modalProperties)`
|
||||||
`UpdateRouter.TryHandleDraftControlCallbackAsync` and the
|
(when the new step needs text input) or
|
||||||
`CreateSessionHandler` continue to work. The view layer behind it is
|
`InteractionCallback.DeferredMessage()` (otherwise). NetCord locks
|
||||||
now `WizardStepViewBuilder`.
|
the response type after the first `SendResponseAsync` call, so the
|
||||||
- A future `DiscordWizardMessenger` and `DiscordWizardInteractionMapper`
|
fix requires NOT calling `DeferredMessage` upfront.
|
||||||
can be added without any change to the Shared wizard; this is
|
- **Modal handler's free-text mapping is a hack.** Modal steps like
|
||||||
deliberately not part of this task (the plan calls it out as Task 2).
|
`SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText`
|
||||||
|
are mapped to the canonical wizard step (`System`, `Duration`,
|
||||||
|
`PoolSystemDuration`) in `MapModalStepToWizardStep`. This works
|
||||||
|
because the wizard's `ApplyText` dispatches on the canonical step
|
||||||
|
name, but a future refactor of `ApplyText` to know about the
|
||||||
|
free-text step names would break this. The clean fix is to add
|
||||||
|
dedicated "free text" steps to `WizardStepNames`.
|
||||||
|
- **Resume:continue is a re-render, not a true resume.** The wizard
|
||||||
|
has no special resume case; clicking "▶️ Продолжить" just re-emits
|
||||||
|
the current step's embed. This is fine for the user (the embed is
|
||||||
|
identical to the last one they saw before the click), but the
|
||||||
|
underlying state isn't really "continued" — if the wizard's cleanup
|
||||||
|
service expired the draft between the slash command and the
|
||||||
|
click, the user gets a re-render of an empty step.
|
||||||
|
- **One-draft-per-owner invariant.** The wizard's "one active draft
|
||||||
|
per owner" rule means a single Discord user can't run two wizard
|
||||||
|
sessions in parallel. Acceptable for now, but the wizard's state
|
||||||
|
machine doesn't enforce this — only the dispatcher does, via
|
||||||
|
`GetActiveAsync("Discord", ownerId)`.
|
||||||
|
|||||||
+154
-150
@@ -19,7 +19,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
|||||||
/// One class per interaction context type — NetCord's
|
/// One class per interaction context type — NetCord's
|
||||||
/// <c>ComponentInteractionModule<TContext></c> is single-context. All
|
/// <c>ComponentInteractionModule<TContext></c> is single-context. All
|
||||||
/// three classes share the same dispatch table (parse customId → load
|
/// 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="SharedWizard.HandleInteractionAsync"/>) implemented in
|
||||||
/// <see cref="WizardInteractionDispatcher"/>.
|
/// <see cref="WizardInteractionDispatcher"/>.
|
||||||
///
|
///
|
||||||
@@ -81,8 +81,8 @@ public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalI
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared dispatch table for the three wizard interaction modules.
|
/// Shared dispatch table for the three wizard interaction modules.
|
||||||
/// Owns all the stateful collaborators (drafts, context store, wizard
|
/// Owns all the stateful collaborators (drafts, context store, wizard
|
||||||
/// state machine, submitter, reply cache) so the three NetCord module
|
/// state machine, submitter, messenger, reply cache) so the three
|
||||||
/// shells can stay trivially thin.
|
/// NetCord module shells can stay trivially thin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WizardInteractionDispatcher
|
public sealed class WizardInteractionDispatcher
|
||||||
{
|
{
|
||||||
@@ -112,12 +112,39 @@ public sealed class WizardInteractionDispatcher
|
|||||||
_log = log;
|
_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 ────────────────────────────────────────────────
|
// ── Button handler ────────────────────────────────────────────────
|
||||||
public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
|
public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
|
||||||
{
|
{
|
||||||
// NetCord contexts don't expose a per-request CancellationToken;
|
// NetCord only allows one response per interaction. The
|
||||||
// the REST calls already carry their own timeout, so we use
|
// previous implementation deferred too early and then
|
||||||
// CancellationToken.None for the DB and HTTP calls.
|
// 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;
|
var ct = CancellationToken.None;
|
||||||
// args looks like one of:
|
// args looks like one of:
|
||||||
// "btn:choice:<step>:<value>" (a choice)
|
// "btn:choice:<step>:<value>" (a choice)
|
||||||
@@ -146,24 +173,53 @@ public sealed class WizardInteractionDispatcher
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acknowledge the click first so the user sees no lag; the
|
// Special case: "create" doesn't go through the wizard — the
|
||||||
// wizard's edit is a separate REST call. The "back" / "cancel"
|
// submitter edits the draft message directly with the result
|
||||||
// actions need to defer so the wizard's edits complete before
|
// embed ("✅ Создано" or retry buttons). After the submitter
|
||||||
// the response window closes.
|
// returns, ack the click so the user doesn't see "Application
|
||||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
// 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])
|
switch (parts[1])
|
||||||
{
|
{
|
||||||
case "choice":
|
case "choice":
|
||||||
{
|
|
||||||
if (parts.Length < 4)
|
if (parts.Length < 4)
|
||||||
{
|
{
|
||||||
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
|
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var step = parts[2];
|
callback = WizardCallbackData.Choice(parts[2], parts[3]);
|
||||||
var value = parts[3];
|
break;
|
||||||
var callback = WizardCallbackData.Choice(step, value);
|
case "back":
|
||||||
|
callback = WizardCallbackData.Back();
|
||||||
|
break;
|
||||||
|
case "cancel":
|
||||||
|
callback = WizardCallbackData.Cancel();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var interaction = new WizardInteraction(
|
var interaction = new WizardInteraction(
|
||||||
OwnerId: ownerId,
|
OwnerId: ownerId,
|
||||||
Text: null,
|
Text: null,
|
||||||
@@ -172,91 +228,56 @@ public sealed class WizardInteractionDispatcher
|
|||||||
PhotoUrl: null,
|
PhotoUrl: null,
|
||||||
InteractionId: interactionId);
|
InteractionId: interactionId);
|
||||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||||
await MaybeOpenModalAsync(context.Interaction, draft, ct);
|
|
||||||
break;
|
// After the wizard's state advance, decide the response.
|
||||||
}
|
// The wizard's EditDraftMessageAsync already updated the
|
||||||
case "back":
|
// draft embed; we just need the interaction response to
|
||||||
|
// either pop a modal or quietly ack.
|
||||||
|
if (parts[1] == "cancel")
|
||||||
{
|
{
|
||||||
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);
|
_contextStore.Remove(draft.Id);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case "create":
|
await RespondAfterWizardAsync(context, draft, ct);
|
||||||
{
|
|
||||||
// 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":
|
|
||||||
|
private async Task HandleResumeAsync(
|
||||||
|
ButtonInteractionContext context,
|
||||||
|
string[] parts,
|
||||||
|
WizardDraft draft,
|
||||||
|
string ownerId,
|
||||||
|
string interactionId,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// resume:continue = re-render the current step; resume:restart
|
// resume:continue → re-render the current step (the wizard
|
||||||
// = delete the existing draft and tell the user to re-run
|
// itself doesn't know about resume, so we just edit the
|
||||||
// /newsession-wizard.
|
// 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")
|
if (parts.Length >= 3 && parts[2] == "restart")
|
||||||
{
|
{
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
_contextStore.Remove(draft.Id);
|
_contextStore.Remove(draft.Id);
|
||||||
await context.Interaction.ModifyResponseAsync(msg =>
|
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||||
{
|
new InteractionMessageProperties()
|
||||||
msg.Content = "♻️ Мастер сброшен. Запустите /newsession-wizard заново.";
|
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
|
||||||
msg.Flags = MessageFlags.Ephemeral;
|
.WithFlags(MessageFlags.Ephemeral)));
|
||||||
});
|
return;
|
||||||
}
|
}
|
||||||
else
|
// continue
|
||||||
{
|
|
||||||
// continue: re-render the current step.
|
|
||||||
var payload = LoadPayload(draft);
|
var payload = LoadPayload(draft);
|
||||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
|
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
|
||||||
var interaction = new WizardInteraction(
|
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
|
||||||
OwnerId: ownerId,
|
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||||
Text: null,
|
MessageFlags.Ephemeral));
|
||||||
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 ───────────────────────────────────────
|
// ── StringSelectMenu handler ───────────────────────────────────────
|
||||||
public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args)
|
public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args)
|
||||||
{
|
{
|
||||||
// NetCord contexts don't expose a per-request CancellationToken;
|
// NetCord's interaction response type is locked after the
|
||||||
// use CancellationToken.None and let the REST timeout apply.
|
// 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;
|
var ct = CancellationToken.None;
|
||||||
// args looks like "select:<step>" (e.g. "select:Visibility" or
|
// args looks like "select:<step>" (e.g. "select:Visibility" or
|
||||||
// "select:PoolSystemDuration"). The chosen value lives in
|
// "select:PoolSystemDuration"). The chosen value lives in
|
||||||
@@ -298,8 +319,10 @@ public sealed class WizardInteractionDispatcher
|
|||||||
// ── Modal submit handler ──────────────────────────────────────────
|
// ── Modal submit handler ──────────────────────────────────────────
|
||||||
public async Task HandleModalAsync(ModalInteractionContext context, string args)
|
public async Task HandleModalAsync(ModalInteractionContext context, string args)
|
||||||
{
|
{
|
||||||
// NetCord contexts don't expose a per-request CancellationToken;
|
// The modal text becomes the user's input. The wizard's
|
||||||
// use CancellationToken.None.
|
// 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;
|
var ct = CancellationToken.None;
|
||||||
// args looks like "modal:<step>" (e.g. "modal:Title" or
|
// args looks like "modal:<step>" (e.g. "modal:Title" or
|
||||||
// "modal:SystemFreeText"). The text value lives in
|
// "modal:SystemFreeText"). The text value lives in
|
||||||
@@ -333,13 +356,10 @@ public sealed class WizardInteractionDispatcher
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
|
||||||
MessageFlags.Ephemeral));
|
|
||||||
|
|
||||||
// Modal values are routed by step name. The shared wizard knows
|
// Modal values are routed by step name. The shared wizard knows
|
||||||
// how to apply Title, Description, etc.; the free-text variants
|
// how to apply Title, Description, etc.; the free-text variants
|
||||||
// (SystemFreeText, DurationFreeText, PoolSystemDurationFreeText)
|
// (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.
|
// ApplyText dispatcher handles them.
|
||||||
var wizardStep = MapModalStepToWizardStep(step);
|
var wizardStep = MapModalStepToWizardStep(step);
|
||||||
var interaction = new WizardInteraction(
|
var interaction = new WizardInteraction(
|
||||||
@@ -349,25 +369,59 @@ public sealed class WizardInteractionDispatcher
|
|||||||
PhotoFileId: null,
|
PhotoFileId: null,
|
||||||
PhotoUrl: null,
|
PhotoUrl: null,
|
||||||
InteractionId: interactionId);
|
InteractionId: interactionId);
|
||||||
|
|
||||||
// For free-text modal steps the wizard's "current step" is the
|
// For free-text modal steps the wizard's "current step" is the
|
||||||
// canonical step (System, Duration, etc.), but the user just
|
// canonical step (System, Duration, etc.), but the user just
|
||||||
// submitted via the free-text modal. Temporarily adjust the
|
// submitted via the free-text modal. Temporarily set the
|
||||||
// draft so the wizard's ApplyText runs the right branch.
|
// draft.Step to the canonical step so the wizard's ApplyText
|
||||||
var originalStep = draft.Step;
|
// runs the right branch. The wizard then advances draft.Step
|
||||||
draft.Step = wizardStep;
|
// to the NEXT step (e.g. Duration) and persists that via
|
||||||
try
|
// _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);
|
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
|
// ── Response helper ───────────────────────────────────────────────
|
||||||
|
private async Task RespondAfterWizardAsync(
|
||||||
|
ButtonInteractionContext context,
|
||||||
|
WizardDraft draft,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// The wizard sets draft.Step to the next step; restore the
|
// The wizard's state machine has advanced draft.Step. Re-render
|
||||||
// user's current step for state correctness in the next
|
// the new step locally to discover whether it expects a popup,
|
||||||
// button click (e.g. if they press Back they should land
|
// then send the appropriate response.
|
||||||
// on the step they were on, not the post-modal step).
|
try
|
||||||
draft.Step = originalStep;
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_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 ───────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
private static string ExtractModalText(ModalInteractionContext context)
|
private static string ExtractModalText(ModalInteractionContext context)
|
||||||
@@ -394,54 +448,6 @@ public sealed class WizardInteractionDispatcher
|
|||||||
_ => modalStep,
|
_ => 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)
|
private async Task<IReadOnlyList<WizardClubOption>?> LoadClubsAsync(WizardDraft draft, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// The wizard's GetOwnerClubsAsync would do this for us, but the
|
// The wizard's GetOwnerClubsAsync would do this for us, but the
|
||||||
@@ -457,8 +463,6 @@ public sealed class WizardInteractionDispatcher
|
|||||||
: System.Text.Json.JsonSerializer.Deserialize(
|
: System.Text.Json.JsonSerializer.Deserialize(
|
||||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||||
|
|
||||||
private DiscordWizardMessenger ResolveMessenger() => _messenger;
|
|
||||||
|
|
||||||
private static async Task AckWithErrorAsync(Interaction interaction, string text)
|
private static async Task AckWithErrorAsync(Interaction interaction, string text)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user