diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b8e43c9..c9db68f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.8.0 + VERSION: 3.9.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 3904015..4cf89ff 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.8.0 + 3.9.0 net10.0 preview enable diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fd6d309..ec59637 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,47 @@ -## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions +## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112) + +Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда. + +### 🧩 Что вошло в релиз + +**Платформо-нейтральный рефакторинг (GmRelay.Shared)** +- `Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` — стейт-машина визарда (один источник правды для обеих платформ) +- `Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs` — контракт мессенджера (edit/send/answer/getOwnerClubs) +- `Features/Sessions/CreateSession/Wizard/WizardInteraction.cs` — запись взаимодействия (OwnerId, Text, CallbackPayload, PhotoFileId, PhotoUrl, InteractionId) +- `Features/Sessions/CreateSession/Wizard/WizardAction.cs`, `WizardKeyboard.cs`, `WizardStepLimits.cs` — модель кнопок и лимитов +- `Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — добавлено поле `Platform` +- `Migrations/V032__add_wizard_drafts_platform.sql` — `ALTER TABLE wizard_drafts ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'` + +**Discord-адаптер (GmRelay.DiscordBot)** +- `Features/Sessions/Wizard/DiscordWizardCommand.cs` — slash-команда `/newsession-wizard` с проверкой owner/co-GM через `DiscordPermissionLookup` +- `Features/Sessions/Wizard/DiscordWizardStep.cs` — рендер 15 шагов в NetCord embed + buttons/StringSelectMenu/modals +- `Features/Sessions/Wizard/DiscordWizardMessenger.cs` — реализация `IWizardMessenger` через NetCord REST (edit с fallback на re-send при 401/403/404) +- `Features/Sessions/Wizard/DiscordWizardSubmitter.cs` — финализация с 3-retry циклом +- `Features/Sessions/Wizard/DiscordWizardContextStore.cs` — in-memory кэш контекста (guild/channel/messageId) для 15-минутного interaction token +- `Features/Sessions/Wizard/DiscordWizardInteractionModule.cs` — inbound handlers: 3 NetCord `ComponentInteractionModule` (button/StringMenu/Modal) + `WizardInteractionDispatcher` +- `Features/Sessions/Wizard/DiscordPermissionLookup.cs` — DB-хелпер для `group_managers` +- `Program.cs` — DI-регистрации + 3 `AddComponentInteractions` + +**Тесты** +- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*` — обновлены под новый контракт +- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` — 12 source-level smoke-тестов на структуру interaction module + +### 🗺 Что это даёт +- Мастера (GM) могут пошагово создавать игры и пулы слотов прямо в Discord через slash-команду, кнопки, выпадающие меню и модальные окна. +- UX адаптирован под Discord (нативные components), а не скопирован из Telegram. +- Общая стейт-машина и валидация: Telegram и Discord визарды развиваются синхронно, баги фиксятся в одном месте. +- PickClub-шаг использует реальный SQL-запрос к `club_memberships` с фильтром по роли Owner/CoGm. + +### 📦 Версия и деплой +- Версия обновлена до 3.9.0 (`NavMenu.razor`, `.gitea/workflows/deploy.yml`) +- Docker-образы будут тегированы `3.9.0` при пуше в `main` +- Миграция V032 применяется автоматически на старте Bot + +## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard. -## 🧩 Что вошло в релиз +### 🧩 Что вошло в релиз - src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link) - src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД - src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions @@ -13,11 +52,11 @@ - ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг - Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0 -## 🗺 Что это даёт +### 🗺 Что это даёт - Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web. - Участники сервера видят расписание через /listsessions. - Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных. -## 📦 Версия и деплой +### 📦 Версия и деплой - версия обновлена до 2.4.0 -- Docker-образы используют тег 2.4.0 \ No newline at end of file +- Docker-образы используют тег 2.4.0 diff --git a/compose.yaml b/compose.yaml index 9702a52..0f0ca98 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.8.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0 restart: always depends_on: db: diff --git a/deliverable.md b/deliverable.md new file mode 100644 index 0000000..1179c18 --- /dev/null +++ b/deliverable.md @@ -0,0 +1,153 @@ +# Discord wizard adapter — issue #112 + +## Summary + +Implemented the Discord side of the platform-neutral game/pool creation +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. + +## Files + +Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`): + +- `DiscordWizardContextStore.cs` — `IWizardContextStore` interface + + thread-safe in-memory store. Keyed by `draft.Id`. Holds the + `(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger + needs to re-send a draft after a 15-minute interaction token + expires. +- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns + an embed + `IReadOnlyList` (mixes + `ActionRow` buttons with `StringMenu` select menus) and exposes + `BuildModal` for the 8 modal-collecting steps. +- `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` + shells (button / StringSelectMenu / modal) share one + `WizardInteractionDispatcher` that: + 1. parses the custom-id tail (`btn:choice::`, + `btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`, + `btn:resume:restart`, `select:`, `modal:`); + 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". + +DI / tests: + +- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations + (`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`, + `GameCreationWizard`, `DiscordWizardSubmitter`, + `WizardInteractionDispatcher`, `DiscordWizardButtonModule`, + `DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus + 3 `AddComponentInteractions` calls + (Button, StringMenu, Modal). +- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` + — 12 source-level structural smoke tests: handler classes exist, + all 3 derive from `ComponentInteractionModule`, all 3 + register `[ComponentInteraction("wizard")]`, the dispatcher + exposes `HandleButtonAsync` / `HandleStringMenuAsync` / + `HandleModalAsync`, all 5 callback kinds (`choice` / `back` / + `cancel` / `create` / `resume`) are routed, the dispatcher + invokes `GameCreationWizard.HandleInteractionAsync` and + `DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs + registers all 3 `AddComponentInteractions` and all 4 module + classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal + walks `Components[0] → TextInput → .Value`, string menu reads + `SelectedValues[0]`. + +## Custom-id wire format + +| Interaction | Custom-id | Handler | +|------------------------|------------------------------------------|-------------------------------------------------| +| Choice button | `wizard:btn:choice::` | Wizard's `ApplyChoice` | +| Back button | `wizard:btn:back` | Wizard's `ApplyBack` | +| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" | +| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) | +| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger | +| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run | +| StringSelectMenu | `wizard:select:` | Wizard's `ApplyChoice` (step, SelectedValues[0]) | +| Modal submit | `wizard:modal:` | Wizard's `ApplyText` (Text = Component[0].Value) | + +The wizard renderer (`DiscordWizardStep`) owns the prefix generation: +`wizard:btn::`, `wizard:select:`, +`wizard:modal:`. The handlers match by the `wizard` prefix and +parse the rest. + +## Acceptance + +- ✅ `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`). + +PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor + +## Open questions + +- ~~**Club lookup at the PickClub step**~~ — **FIXED in commit `7cfb196`.** + `WizardClubLookup.LoadClubsAsync` now queries `group_managers` + directly via injected `NpgsqlDataSource` with the same + `Owner | CoGm` role filter the messenger uses. The dispatcher + reads the owner's real club list and renders them in the + StringSelectMenu. Build green, 12 source-level smoke tests still + pass. +- ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.** + The dispatcher now runs the wizard first (which edits the draft + embed), then sends the response as either + `InteractionCallback.Modal(modalProperties)` (when the new step + needs text input) or `InteractionCallback.DeferredMessage()` + (otherwise). NetCord locks the response type after the first + `SendResponseAsync` call, so the fix is NOT to call + `DeferredMessage` upfront. +- **Modal handler's free-text mapping is a hack.** Modal steps like + `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)`. + +## Final commit history on `feat/issue-112-wizard-refactor` + +- `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types) +- `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command) +- `f095209` — Task 2: interaction module + modal popup fix +- `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup + +PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor diff --git a/docs/review-brief.md b/docs/review-brief.md new file mode 100644 index 0000000..042bdce --- /dev/null +++ b/docs/review-brief.md @@ -0,0 +1,54 @@ +"""Detailed code review plan for the Discord wizard feature branch. + +Read this file FIRST. It has the full review scope. The original prompt +in the spawn was truncated due to Windows CLI limits; this file is the +canonical spec. +""" + +# Branch to review +BRANCH = "feat/issue-112-wizard-refactor" +BASE = "origin/main" + +# Files of interest +SHARED = "src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/*" +BOT_WIZARD = "src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/*" +BOT_CREATE = "src/GmRelay.Bot/Features/Sessions/CreateSession/*" +MIGRATION = "src/GmRelay.Bot/Migrations/V032__add_wizard_drafts_platform.sql" +DISCORD = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/*" +PROG_CS = "src/GmRelay.DiscordBot/Program.cs" +TESTS_WIZ = "tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*" +TESTS_SMOKE = "tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs" +DELIVERABLE = "deliverable.md" + +REVIEW_FOCUS = [ + "Architecture: Shared/Bot/DiscordBot separation; no Telegram.Bot in Shared; no NetCord in Shared; single state machine source.", + "Security: owner/co-GM checks everywhere; NRE on null Context.User; SQL injection; connection strings with passwords.", + "Correctness: AOT-safety (no reflection, no dynamic); off-by-one in customId parsers; CancellationToken/Services.", + "Style: naming consistent; Async/await by convention; logging at right levels.", + "Tests: smoke tests are string-matching — where would real tests be useful?", + "Migration safety: V032 DEFAULT value, will it fail on existing rows?", + "Documentation: deliverable.md updated, open questions listed?", +] + +OUTPUT_FORMAT = """\ +## VERDICT: APPROVE / REQUEST_CHANGES / COMMENT + +## Critical findings +(file:line — what's wrong — how to fix) + +## Important findings +(file:line — what's wrong) + +## Nits +(quick observations) + +## Summary +(1-2 sentences) +""" + +COMMANDS_HINT = """\ +git fetch origin +git diff origin/main..feat/issue-112-wizard-refactor --stat +git diff origin/main..feat/issue-112-wizard-refactor +dotnet build && dotnet test +""" diff --git a/docs/review-report.md b/docs/review-report.md new file mode 100644 index 0000000..81f3c01 --- /dev/null +++ b/docs/review-report.md @@ -0,0 +1,362 @@ +# Code review — feat/issue-112-wizard-refactor (issue #112) + +**Reviewer:** Verifier (mvs_86868b01387b492aae27ce6f77aca4cb) +**Branch:** `feat/issue-112-wizard-refactor` (base `origin/main`) +**Commits reviewed:** `8f0f2ef`, `b81d865`, `f095209`, `7cfb196`, `c4a77d3` +**Build:** ✅ `dotnet build GM-Relay.slnx` — 0 warnings, 0 errors +**Tests:** 580 passed / 2 skipped / 1 failed. 1 failure is the pre-existing +`DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease` +(uncommitted release work in working tree, not part of this branch). + +## VERDICT: REQUEST_CHANGES + +The branch is **NOT shippable in its current state.** Every choice button +and every "Другое…" button in the wizard is silently broken at runtime +due to a wire-format mismatch between the renderer and the dispatcher. +A user who clicks "D&D 5e", "Pathfinder 2e", "Waitlist вкл", "Опубликовать", +or any "Другое… ✏️" button will see "⚠️ Неизвестная кнопка" instead of +the wizard advancing. The 12 source-level smoke tests don't catch this +because they only check string presence in source code, not the actual +button-click → dispatch flow. + +The architecture is otherwise sound: no Telegram.Bot/NetCord leak into +Shared, single state-machine source, all DI wired, AOT-safe, parameterized +SQL, owner/co-GM permission check with null-safety, SecretRedactor on the +connection string. The fix is small and surgical. + +--- + +## Critical findings + +### C-1. Choice-button custom-id is missing the `choice:` segment — wizard is unusable end-to-end + +**Files:** +- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:79-80` +- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:174-226` + +**What's wrong.** The dispatcher's button handler matches `parts[1]` +against `"choice"`, `"back"`, `"cancel"`, `"create"`, `"resume"`, and +falls through to "default → Неизвестная кнопка" for anything else. The +dispatcher's own documentation and the deliverable's wire-format table +both agree the canonical choice-button format is +`wizard:btn:choice::`. But `ButtonCustomId` emits +`$"wizard:btn:{step}:{value}"` — **the literal `choice:` segment is +missing**. So clicking "D&D 5e" on the System step produces +`wizard:btn:System:Dnd5e`, which NetCord strips the `[ComponentInteraction("wizard")]` +prefix from, arriving at the dispatcher as `args = "btn:System:Dnd5e"` → +`parts = ["btn", "System", "Dnd5e"]` → `parts[1] = "System"` → default +branch → "⚠️ Неизвестная кнопка". + +The same bug hits: +- `RenderType` — "Одну игру" / "Пул игр" buttons (emits + `wizard:btn:Type:single`, `wizard:btn:Type:pool`) +- `RenderSystem` — D&D/Pathfinder/CoC/GURPS/Fate ("wizard:btn:System:Dnd5e" etc.) + **and** the "⏭ Пропустить" button (emits `wizard:btn:System:_skip`) +- `RenderDuration` — "3 часа" / "4 часа" / "5 часов" / "6 часов" and + "⏭ Пропустить" +- `RenderCapacity` / `RenderPoolSlotCapacity` — "Waitlist вкл" / "Без waitlist" + (emits `wizard:btn:Capacity:waitlist:on` etc.) +- `RenderPublish` — "Опубликовать" / "Только в чате" +- `RenderPoolAddSlots` — "Добавить слот" / "Готово, к превью" +- `RenderPickClub` — back/cancel still work (parts[1] = "back"/"cancel") + +Only back, cancel, create, resume, and the "Другое… ✏️" → modal buttons +are unaffected by *this specific* bug (see C-2 for modal buttons). + +The smoke test `Dispatcher_ShouldParseAllWizardActionKinds` +(`tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs:85-97`) +checks that the strings `"choice"`, `"back"`, `"cancel"`, `"create"`, +`"resume"` appear in `DiscordWizardInteractionModule.cs`. It doesn't +check the renderer's output, so the bug is invisible to the test suite. +The same file's comment at line 69 documents the *expected* format as +`"btn:choice:Type:single"` — which would be the correct fix. + +**How to fix.** Change `ButtonCustomId` in `DiscordWizardStep.cs`: + +```csharp +public static string ButtonCustomId(string step, string value) => + $"wizard:btn:choice:{step}:{value}"; +``` + +This brings the renderer into alignment with the dispatcher's switch +case `"choice"` (line 209) and with the deliverable's table at line 83. +Re-verify with a manual click-through of every button on every step, or +add a parser-side test (see I-3 below). + +### C-2. "Другое… ✏️" modal trigger buttons route to "default" instead of opening a modal + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:190, 206, 314` +**Read against:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:207-226` + +**What's wrong.** The renderer emits modal triggers as +`wizard:btn:modal:SystemFreeText` (the "Другое… ✏️" button on the System +step), `wizard:btn:modal:DurationFreeText` (Duration step), and +`wizard:btn:modal:PoolSystemDurationFreeText` (PoolSystemDuration step). +The dispatcher's button switch handles `"choice"`, `"back"`, `"cancel"` +but not `"modal"`. The user's click on "Другое…" hits the default branch +and returns "⚠️ Неизвестная кнопка" — no modal pops up, the wizard +doesn't advance. The open question in `deliverable.md:125-132` ("Modal +handler's free-text mapping is a hack") implicitly assumes these buttons +*work* in production, so the design intent is clear but the implementation +didn't deliver it. + +**How to fix.** Add a `"modal"` case in the dispatcher's switch (between +`"create"` and the existing branches, mirroring the "create" / "resume" +special-case pattern): + +```csharp +if (parts[1] == "modal" && parts.Length >= 3) +{ + var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId); + if (modal is not null) + { + await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal)); + } + else + { + await AckWithErrorAsync(context.Interaction, "Модал недоступен"); + } + return; +} +``` + +This bypasses the wizard's state machine entirely (the user's intent is +"open a modal for free-text input", not "advance the wizard"). When the +user submits the modal, `HandleModalAsync` will run, which already knows +how to map `SystemFreeText` → `WizardStepNames.System` (line 453-462). +Add a click-through test for at least one of the three steps. + +### C-3. `ex.Message` from `CreateSessionHandler` is shipped to the user's Discord + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:104` + +**What's wrong.** On submit failure the submitter edits the draft +message with `$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}."` +and ships it to the user-visible draft embed. The exception originates +in `CreateSessionHandler` which talks to PostgreSQL via Dapper. Postgres +exception messages routinely include the constraint name, the conflicting +key value, and sometimes the full SQL text. Even the connection-string +DSN could leak if an `NpgsqlException` wraps a connection failure. A +malicious user who can submit many sessions can probe DB schema and +state by reading the error strings. + +**How to fix.** Log the full `ex` to the server-side log (already done +on line 86) but show the user a generic error: + +```csharp +await EditDraftMessageAsync( + draft, + $"💥 Ошибка при создании сессии. Попытка {payload.RetryCount}/{MaxRetries}. " + + "Попробуйте повторить или обратитесь к администратору.", + RetryCancelActions(), + ct); +``` + +If you want to preserve a per-error recovery hint (e.g. "Duplicate +title — pick a different name"), map known exception types to localized +strings; never embed the raw `ex.Message`. + +--- + +## Important findings + +### I-1. `Owner`/`CoGm` permission lookup runs on every wizard invocation, no cache + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:85-87` + +The slash command issues an `await DiscordPermissionLookup.LoadManagerUserIdsAsync(...)` +on every `/newsession-wizard` invocation. This is a 3-table join that +scales linearly with the number of clubs the user manages. With a 24-hour +draft lifetime and a single draft per owner, the same query repeats +frequently. Not critical for the v3.8.0 release, but a 30-second in-memory +cache would cut DB load noticeably during heavy wizard use. Same query +shape lives in `DiscordNewSessionHandler` already (per the file comment), +so a shared cache would benefit both. + +### I-2. `_skip` sentinel bypasses the wizard's own validation + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:191, 207` +**Read against:** `src/GmRelay.Shared/Features/Sessions\CreateSession\Wizard\GameCreationWizard.cs:287-290` + +`GameCreationWizard.ApplySystemChoice` matches `"_skip"` and accepts it +without further checks. The renderer emits `wizard:btn:System:_skip`. +This is correct *if* the choice-button wire format gets fixed (C-1); the +"`_skip`" string is hard-coded in the wizard and the renderer uses the +same constant. But there's no central constant — both files have their +own copies of the magic string. A future refactor that renames the +sentinel in one place will silently break the other. Suggest +`public const string SkipSentinel = "_skip"` on a shared class. + +### I-3. Smoke tests are string-matching only — no behaviour coverage of the adapter + +**File:** `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` + +All 12 smoke tests in this file are `Assert.Contains` against the +source text. They would all pass against a file full of `// choice` +comments and dead code. The test class header at line 8-17 acknowledges +this ("smoke gate"), and the broader Wizard test suite +(`tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/`) does +exercise the platform-neutral state machine — but the *adapter* (the +mapping from `ButtonInteractionContext` → `_wizard.HandleInteractionAsync`) +has zero behavioural coverage. C-1 and C-2 both bypass the entire +smoke-test surface. + +Minimum bar to add: a parser-roundtrip test that takes the renderer's +output for each `RenderX()` step and feeds it through the dispatcher's +button handler to verify it doesn't fall into the default branch. Even +a hand-rolled `ButtonInteractionContext` fake (or a helper that mimics +the dispatcher's `args.Split(':', 4)` parser) would catch both bugs. +Cost: ~50 lines; payoff: catches the entire class of "renderer and +dispatcher disagree on the wire format" regressions. + +### I-4. `AddComponentInteractions` is called for Modal but the renderer relies on `Label → TextInput` layout + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:436-444` +**Read against:** `DiscordWizardInteractionModule.cs:440-451` + +`BuildModal` always wraps a single `TextInput` in a `Label` (Discord's +`IModalComponentProperties` API requires labels). The dispatcher reads +`Components[0]` and assumes it is a `Label` (line 446). This is +consistent *today*, but if a future step needs two inputs in one +modal, the extraction logic needs to walk all components, not just +`[0]`. Document the constraint on the dispatcher's +`ExtractModalText` method ("current contract: exactly one Label, +one TextInput") and the renderer's `BuildModal` ("emits one +Label+TextInput, no exceptions"). + +### I-5. The 3-retry counter is bound to the in-memory `WizardPayload`, not the DB row + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:86-89` + +`payload.RetryCount += 1; SavePayload(draft, payload);` is called *before* +the `if (payload.RetryCount >= MaxRetries)` check. The counter is +serialized into `draft.PayloadJson` and re-loaded on the next click, so +the bound is correct across bot restarts. However, the in-memory +`draft` object is shared with the dispatcher's `_wizard` after +`HandleInteractionAsync` returns, and a future refactor that pulls the +payload from the DB instead of the in-memory copy could see a stale +count. Document the invariant: "RetryCount is read from the in-memory +payload after this line; do not re-load from DB before the comparison." + +### I-6. `BuildResumeRow` re-uses the same customId suffix scheme as the in-wizard buttons + +**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:193-209` + +`BuildResumeRow` emits three buttons: +- "▶️ Продолжить" → `wizard:btn:resume:continue` ✓ (works) +- "🔄 Заново" → `wizard:btn:resume:restart` ✓ (works) +- "❌ Отмена" → `wizard:btn:cancel:1` (uses `DiscordWizardStep.ButtonCustomId("cancel", "1")`) + +The cancel button relies on the dispatcher's `parts[1] == "cancel"` +match. With C-1 fixed, this still works because cancel is in the +switch, not the new "choice" path. But the `ButtonCustomId` signature +will change semantics after C-1: it will become +`$"wizard:btn:choice:{step}:{value}"`. `BuildResumeRow` passing +`"cancel"` as the step will then produce `wizard:btn:choice:cancel:1`, +which the dispatcher's switch will not match (no `"choice"` in the +parts). Fix C-1 must update `BuildResumeRow` to emit +`wizard:btn:cancel:1` directly (not via `ButtonCustomId`). + +--- + +## Nits + +- `DiscordWizardInteractionModule.cs:308, 357` — the select and modal + handlers split args with `Split(':', 2)` (max 2 parts), while the + button handler uses `Split(':', 4)` (max 4 parts). Inconsistent. The + net effect is correct (select has 2 segments, modal has 2, button has + 2–4), but a comment explaining the max-count rationale would help. +- `DiscordWizardStep.cs:74` — `throw new InvalidOperationException` on + unknown step is fine for now, but a future maintainer adding a step + to `WizardStepNames` will get a runtime exception. A `switch` exhaustiveness + check (e.g. a private static assert in tests) would catch this at + build time. +- `DiscordPermissionLookup.cs:28-30` — `g.platform = 'Discord'` is + hard-coded in the SQL. A `g.platform = @Platform` parameter would + mirror the dispatcher's parameterized style and make the helper + reusable for any future platform. Not blocking. +- `DiscordWizardCommand.cs:72-81` — fetching the guild via REST inside + the slash command costs an extra round-trip. The + `resolvedPermissions` from the interaction already includes + `Administrator`; only the "guild owner" case needs the REST call. + Consider short-circuiting when `(resolvedPermissions & Administrator) + != 0`. +- `DiscordWizardMessenger.cs:188-192` — hard-coded + `new Color(0x5865F2)` (Discord blurple). Extracting to a const + `WizardEmbedColor` would let the Web/Telegram versions use the same + brand color if they ever render wizards. + +--- + +## Migration V032 sanity check + +**File:** `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql` + +- Line 8-9 `ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'` + — DEFAULT literal makes this O(1) on PostgreSQL ≥ 11. Safe on + existing rows. +- Lines 13-30 — `ALTER COLUMN ... TYPE TEXT USING ...::TEXT` on the + `chat_id`, `message_thread_id`, `draft_message_id`, and renamed + `owner_id` columns. All conversions are lossless + (BIGINT → decimal-string, INT → decimal-string). Safe. +- Line 26-27 `RENAME COLUMN owner_telegram_id TO owner_id` — the + rename happens mid-migration. Any DML hitting the table + concurrently that uses the old name will fail. For a bot that + processes both Telegram and Discord traffic, this is a brief + exclusivity lock. Consider splitting into two migrations + (rename + new index, then type change) so each lock is shorter. + Not blocking for v3.8.0, but document the brief lock window. + +No DEFAULT-cascade issue, no NOT NULL on existing-row failure. The +deliverable's "Will this fail on existing rows?" question gets a +"no, but plan a maintenance window" answer. + +--- + +## Architecture sanity (re-confirmed) + +- `src/GmRelay.Shared/` — only references to Telegram.Bot/NetCord are + in doc comments warning the developer not to add them. csproj has + no Telegram.Bot or NetCord package references. `GameCreationWizard`, + `IWizardMessenger`, `WizardCallbackData`, `WizardStepLimits`, + `WizardStepNames`, `WizardPayload`, `WizardDraft` are all in exactly + one place (Shared). ✓ +- `src/GmRelay.DiscordBot/Program.cs:87-102` — all 7 wizard services + registered as singleton. All 3 `AddComponentInteractions<...>` + calls present (Button, StringMenu, Modal). All 4 module/dispatcher + classes (`WizardInteractionDispatcher`, `DiscordWizardButtonModule`, + `DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) + registered. ✓ +- AOT-safety: no `System.Reflection`, no `dynamic`, no + `Activator.CreateInstance`, no `Type.GetType` in the new Discord + or Shared code. ✓ +- `DiscordPermissionLookup.cs:23-31` and + `DiscordWizardMessenger.cs:154-165` and the inline + `WizardClubLookup` in `DiscordWizardInteractionModule.cs:508-519` + all use parameterized queries (`@GuildId`, `@Platform`, + `@ExternalId`, `@OwnerId`). No SQL string interpolation. ✓ +- `Program.cs:54` — `SecretRedactor.RedactConnectionString` on the + startup log. ✓ +- `DiscordWizardCommand.cs:51-94` — DM invocations rejected + (`GuildId` null check), channel null-checked, member type-checked + via `as GuildInteractionUser`, owner/admin/DB-manager permission + check via `DiscordPermissionChecker.CanManageSchedule`. No NRE + on `Context.User`. ✓ + +--- + +## Summary + +Strong foundation: the platform-neutral refactor is well-executed, the +state machine has solid test coverage, the Discord adapter's DI graph +is clean, and security primitives (parameterized SQL, permission check, +secret redaction) are in place. But the Discord adapter's runtime path +is untested, and a single oversight in the renderer's button custom-id +format (missing the `choice:` segment) breaks every choice button in +the wizard at click time. The "Другое… ✏️" modal triggers are also +unrouted in the dispatcher, leaving the free-text input path +unreachable. The 3-attempt finalize loop works but leaks `ex.Message` +to the user. After fixing C-1 / C-2 / C-3, adding I-3 (behavioural +test of the adapter), and re-running the manual click-through checklist +(System → Duration → DateTime → Capacity → Visibility → Publish → +Confirm for Single; full pool flow), this branch is ready to merge. diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 0bf9f19..798e384 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -1,40 +1,41 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Platform; using Microsoft.Extensions.Logging; using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler; namespace GmRelay.Bot.Features.Sessions.CreateSession; /// -/// Wizard-driven entry point for game-session creation. Replaces the legacy -/// text-template parser. Exposes (called from -/// /newsession), (continue a draft), and -/// (finalize on "✅ Создать" callback). +/// Telegram-side entry point for the wizard-driven session creation +/// flow. Talks to the shared wizard through +/// and the platform-neutral . Keeps the +/// platform glue (mapping Message to draft fields, rendering +/// error keyboards, etc.) local to GmRelay.Bot. /// public sealed class CreateSessionHandler { private const int MaxRetries = 3; + private const string PlatformName = "Telegram"; private readonly IWizardDraftRepository _drafts; private readonly SharedCreateSessionHandler _shared; - private readonly ITelegramWizardMessenger _messenger; + private readonly IWizardMessenger _messenger; private readonly ILogger _log; public CreateSessionHandler( IWizardDraftRepository drafts, SharedCreateSessionHandler shared, - ITelegramWizardMessenger messenger, + IWizardMessenger messenger, ILogger log) { _drafts = drafts; @@ -44,14 +45,14 @@ public sealed class CreateSessionHandler } /// - /// Entry point for /newsession. If a non-expired draft already exists for - /// this (chat, thread, owner), returns null so the caller can render a - /// "Continue / Start over / Cancel" menu. + /// Entry point for /newsession. If a non-expired draft + /// already exists for this owner, returns null so the caller + /// can render a "Continue / Start over / Cancel" menu. /// public async Task StartWizardAsync(Message message, CancellationToken ct) { - var existing = await _drafts.GetActiveAsync( - message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct); + var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture); + var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct); if (existing is not null) { return null; @@ -60,9 +61,10 @@ public sealed class CreateSessionHandler var draft = new WizardDraft { Id = Guid.NewGuid(), - ChatId = message.Chat.Id, - MessageThreadId = message.MessageThreadId, - OwnerTelegramId = message.From?.Id ?? 0, + ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture), + MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture), + OwnerId = ownerId, + Platform = PlatformName, Step = WizardStepNames.Type, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, @@ -70,9 +72,8 @@ public sealed class CreateSessionHandler }; await _drafts.UpsertAsync(draft, ct); - var (text, kb) = WizardStep.Render(draft, new WizardPayload()); - var msgId = await _messenger.SendGroupMessageAsync( - draft.ChatId, draft.MessageThreadId, text, kb, ct); + var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload()); + var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct); draft.DraftMessageId = msgId; draft.UpdatedAt = DateTimeOffset.UtcNow; await _drafts.UpsertAsync(draft, ct); @@ -80,24 +81,27 @@ public sealed class CreateSessionHandler } /// - /// Resume an existing draft — returns the draft row so the caller can re-render. + /// Resume an existing draft — returns the draft row so the caller + /// can re-render the resume/reset menu. /// - public Task TryResumeAsync(Message message, CancellationToken ct) => - _drafts.GetActiveAsync( - message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct); + public Task TryResumeAsync(Message message, CancellationToken ct) + { + var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture); + return _drafts.GetActiveAsync(PlatformName, ownerId, ct); + } /// - /// Finalize: build shared command(s), call the shared handler, edit the wizard message. - /// On failure, retry up to times before deleting the draft. + /// Finalize: build shared command(s), call the shared handler, edit + /// the wizard message. On failure, retry up to + /// times before deleting the draft. /// public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct) { var payload = LoadPayload(draft); if (!IsComplete(payload, out var missing)) { - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, - $"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct); + await _messenger.EditDraftMessageAsync( + draft, $"❌ Не заполнены поля: {missing}", Array.Empty(), ct); return; } @@ -109,10 +113,11 @@ public sealed class CreateSessionHandler await _shared.HandleAsync(cmd, ct); } var totalSessions = commands.Sum(c => c.ScheduledTimes.Count); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + await _messenger.EditDraftMessageAsync( + draft, $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}", - EmptyKeyboard(), ct); + Array.Empty(), + ct); await _drafts.DeleteAsync(draft.Id, ct); } catch (Exception ex) @@ -122,26 +127,29 @@ public sealed class CreateSessionHandler SavePayload(draft, payload); if (payload.RetryCount >= MaxRetries) { - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + await _messenger.EditDraftMessageAsync( + draft, "💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.", - EmptyKeyboard(), ct); + Array.Empty(), + ct); await _drafts.DeleteAsync(draft.Id, ct); return; } draft.UpdatedAt = DateTimeOffset.UtcNow; await _drafts.UpsertAsync(draft, ct); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + await _messenger.EditDraftMessageAsync( + draft, $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", - RetryCancelKeyboard(), ct); + RetryCancelActions(), + ct); } } // ── Build shared commands ──────────────────────────────────────── - // The shared handler creates one session per scheduled time in a single transaction - // and assigns the same batch_id to all of them. A wizard pool therefore produces ONE - // command with N times; a single-game wizard produces ONE command with one time. + // The shared handler creates one session per scheduled time in a + // single transaction and assigns the same batch_id to all of them. + // A wizard pool therefore produces ONE command with N times; a + // single-game wizard produces ONE command with one time. private static List BuildCommands(WizardDraft draft, WizardPayload p) { if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0) @@ -153,7 +161,7 @@ public sealed class CreateSessionHandler p, pool.Slots.Select(s => s.ScheduledAt).ToList(), MaxPlayersForPool(pool), - isOneShot: false) + isOneShot: false), }; } return new List @@ -163,7 +171,7 @@ public sealed class CreateSessionHandler p, new[] { p.Single?.ScheduledAt ?? default }, p.Single?.MaxPlayers ?? 0, - isOneShot: true) + isOneShot: true), }; } @@ -177,18 +185,17 @@ public sealed class CreateSessionHandler int maxPlayers, bool isOneShot) { - var gmId = draft.OwnerTelegramId; var user = new PlatformUser( PlatformKind.Telegram, - gmId.ToString(System.Globalization.CultureInfo.InvariantCulture), + draft.OwnerId, DisplayName: string.Empty, ExternalUsername: null); var group = new PlatformGroup( PlatformKind.Telegram, - draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture), + draft.ChatId, DisplayName: string.Empty, ExternalChannelId: null, - ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture)); + ExternalThreadId: draft.MessageThreadId); return new CreateSessionCommand( User: user, Group: group, @@ -245,10 +252,9 @@ public sealed class CreateSessionHandler } // ── Keyboards ──────────────────────────────────────────────────── - private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty()); - private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[] + private static IReadOnlyList RetryCancelActions() => new[] { - new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - }); + new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary), + new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }; } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs deleted file mode 100644 index 86ac0c1..0000000 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; - -public sealed record WizardClubOption(Guid ClubId, string Name); - -public interface ITelegramWizardMessenger -{ - Task EditMessageTextAsync(long chatId, int? messageThreadId, long messageId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct); - Task SendGroupMessageAsync(long chatId, int? messageThreadId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct); - Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct); - Task> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct); -} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs index e671e02..3aec26f 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs @@ -3,48 +3,79 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Dapper; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +/// +/// Telegram-side implementation of . +/// Translates the platform-neutral wizard contracts into the +/// Telegram.Bot SDK calls. All Telegram-specific behaviour +/// (message editing, callback ack, group lookup) lives behind the +/// interface so the wizard core stays in GmRelay.Shared. +/// public sealed class TelegramWizardMessenger( ITelegramBotClient bot, - NpgsqlDataSource dataSource) : ITelegramWizardMessenger + NpgsqlDataSource dataSource) : IWizardMessenger { - public async Task EditMessageTextAsync( - long chatId, int? messageThreadId, long messageId, string text, - InlineKeyboardMarkup keyboard, CancellationToken ct) + public async Task EditDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct) { + if (!TryParseChatId(draft.ChatId, out var chatId)) + { + throw new InvalidOperationException( + $"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'."); + } + if (!TryParseMessageId(draft.DraftMessageId, out var messageId)) + { + // No draft message recorded yet — fall back to sending a new one. + return await SendDraftMessageAsync(draft, text, keyboard, ct); + } var msg = await bot.EditMessageText( chatId: chatId, - messageId: (int)messageId, + messageId: messageId, text: text, - replyMarkup: keyboard, + replyMarkup: WizardStep.ToInlineKeyboard(keyboard), cancellationToken: ct); - return msg.MessageId; + return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture); } - public async Task SendGroupMessageAsync( - long chatId, int? messageThreadId, string text, - InlineKeyboardMarkup keyboard, CancellationToken ct) + public async Task SendDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct) { + if (!TryParseChatId(draft.ChatId, out var chatId)) + { + throw new InvalidOperationException( + $"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'."); + } + int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread) + ? parsedThread + : null; + var msg = await bot.SendMessage( chatId: chatId, text: text, - messageThreadId: messageThreadId, - replyMarkup: keyboard, + messageThreadId: threadId, + replyMarkup: WizardStep.ToInlineKeyboard(keyboard), cancellationToken: ct); - return msg.MessageId; + return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture); } - public async Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct) + public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct) { - await bot.AnswerCallbackQuery(callbackId, text: text, cancellationToken: ct); + return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct); } - public async Task> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct) + public async Task> GetOwnerClubsAsync(string ownerId, CancellationToken ct) { // Adjusted from the plan: this codebase models "clubs" as game_groups // (V001 created game_groups; V026 added public_slug; no `clubs` table exists, @@ -57,14 +88,49 @@ public sealed class TelegramWizardMessenger( FROM game_groups g JOIN group_managers gm ON gm.group_id = g.id JOIN players p ON p.id = gm.player_id - WHERE p.platform = 'Telegram' + WHERE p.platform = @Platform AND p.external_user_id = @ExternalId GROUP BY g.id, g.name ORDER BY g.name """; await using var connection = await dataSource.OpenConnectionAsync(ct); var rows = await connection.QueryAsync( - new CommandDefinition(sql, new { ExternalId = ownerTelegramId.ToString() }, cancellationToken: ct)); + new CommandDefinition( + sql, + new { Platform = "Telegram", ExternalId = ownerId }, + cancellationToken: ct)); return rows.AsList(); } + + private static bool TryParseChatId(string raw, out long chatId) + { + if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId)) + { + return true; + } + chatId = 0; + return false; + } + + private static bool TryParseMessageId(string? raw, out int messageId) + { + if (raw is not null && + int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId)) + { + return true; + } + messageId = 0; + return false; + } + + private static bool TryParseThreadId(string? raw, out int threadId) + { + if (raw is not null && + int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId)) + { + return true; + } + threadId = 0; + return false; + } } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs new file mode 100644 index 0000000..3687826 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs @@ -0,0 +1,68 @@ +using System; +using System.Globalization; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using Telegram.Bot.Types; + +namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; + +/// +/// Converts a Telegram into the +/// platform-neutral consumed by +/// . The mapping is the only place in +/// the bot that knows about both Telegram.Bot.Types and the +/// shared wizard contract, so a future Discord adapter can do the same +/// for its native event without changing the wizard core. +/// +public static class WizardInteractionMapper +{ + /// + /// Returns true if carries a + /// wizard-relevant interaction (text message, photo, or + /// callback). Side-effect-free: the wizard state is not touched. + /// + public static bool TryMap(Update update, out WizardInteraction interaction) + { + interaction = default!; + if (update.CallbackQuery is { } cb && cb.From is not null) + { + interaction = new WizardInteraction( + OwnerId: cb.From.Id.ToString(CultureInfo.InvariantCulture), + Text: null, + CallbackPayload: cb.Data, + PhotoFileId: null, + PhotoUrl: null, + InteractionId: cb.Id); + return true; + } + + if (update.Message is { From: not null } msg) + { + // The original Telegram wizard dispatched on + // `msg.Text is null` to identify a non-text update (photo, + // document, sticker, …) and only ran the text pipeline + // otherwise. We preserve that semantic: a message that + // carries a photo is a photo interaction even if it has a + // caption. Text is null for photos; the wizard checks + // PhotoFileId separately when Text is null. + // + // Note: `Message.MessageId` is exposed as a read-only + // property in Telegram.Bot, so the mapper cannot embed the + // numeric id in the interaction. Text interactions never + // need an ack, so the InteractionId is unused for them — + // we just emit a stable sentinel. + var hasPhoto = msg.Photo is { Length: > 0 }; + var text = hasPhoto ? null : msg.Text; + var photoFileId = hasPhoto ? msg.Photo![^1].FileId : null; + interaction = new WizardInteraction( + OwnerId: msg.From!.Id.ToString(CultureInfo.InvariantCulture), + Text: text, + CallbackPayload: null, + PhotoFileId: photoFileId, + PhotoUrl: null, + InteractionId: "msg"); + return true; + } + + return false; + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs index 6bb141e..f85a0cc 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs @@ -1,253 +1,65 @@ using System; using System.Collections.Generic; -using System.Text; -using GmRelay.Shared.Domain; +using System.Linq; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +/// +/// Telegram-side renderer for wizard keyboards. Acts as the adapter +/// between the platform-neutral list +/// produced by and Telegram's +/// . Each +/// becomes its own row (matching the pre-refactor Telegram layout). +/// is currently ignored by Telegram +/// because the platform has no native primary/danger/success button +/// colours. +/// public static class WizardStep { - public const int MaxTitleLength = 200; - public const int MaxDescriptionLength = 4000; - public const int MaxSystemLength = 100; - public const int MaxCapacity = 50; - public const int MinCapacity = 1; - public const int MinDurationHours = 1; - public const int MaxDurationHours = 12; + public const int MaxTitleLength = WizardStepLimits.MaxTitleLength; + public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength; + public const int MaxSystemLength = WizardStepLimits.MaxSystemLength; + public const int MaxCapacity = WizardStepLimits.MaxCapacity; + public const int MinCapacity = WizardStepLimits.MinCapacity; + public const int MinDurationHours = WizardStepLimits.MinDurationHours; + public const int MaxDurationHours = WizardStepLimits.MaxDurationHours; - public static (string text, InlineKeyboardMarkup keyboard) Render( + /// + /// Render the platform-neutral view into a (text, Telegram keyboard) + /// pair. Used by the wizard's surrounding code (router, create + /// handler) when it needs to send a fresh draft message or render + /// the resume/reset menu. + /// + public static (string Text, InlineKeyboardMarkup Keyboard) Render( WizardDraft draft, WizardPayload payload, IReadOnlyList? clubs = null) { - return draft.Step switch - { - WizardStepNames.Type => RenderType(), - WizardStepNames.Title => RenderTitle(), - WizardStepNames.Description => RenderDescription(), - WizardStepNames.Cover => RenderCover(), - WizardStepNames.System => RenderSystem(), - WizardStepNames.Duration => RenderDuration(), - WizardStepNames.DateTime => RenderDateTime(), - WizardStepNames.Capacity => RenderCapacity(), - WizardStepNames.Visibility => RenderVisibility(), - WizardStepNames.PickClub => RenderPickClub(clubs ?? Array.Empty()), - WizardStepNames.Publish => RenderPublish(), - WizardStepNames.Confirm => RenderSingleConfirm(payload), - - WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(), - WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload), - WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(), - WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(), - WizardStepNames.PoolConfirm => RenderPoolConfirm(payload), - - _ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"), - }; + var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs); + return (text, ToInlineKeyboard(actions)); } - // ── Single-game renderers ────────────────────────────────────────── - private static (string, InlineKeyboardMarkup) RenderType() => ( - "🎲 Создание новой игровой сессии\n\nЧто создаём?", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single")) }, - new[] { InlineKeyboardButton.WithCallbackData("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool")) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - })); - - private static (string, InlineKeyboardMarkup) RenderTitle() => ( - "📝 Введите название игры одним сообщением.", - BackCancel()); - - private static (string, InlineKeyboardMarkup) RenderDescription() => ( - "📄 Введите описание (или «-», чтобы пропустить).", - SkipBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderCover() => ( - "🖼 Пришлите картинку как вложение или URL (или «-»).", - SkipBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderSystem() + /// + /// Convert a flat list of s into a + /// Telegram keyboard. Each action is placed in its own row to + /// preserve the pre-refactor visual layout. + /// + public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList actions) { - var buttons = new List + if (actions.Count == 0) { - new[] { InlineKeyboardButton.WithCallbackData("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")) }, - new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")) }, - new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")) }, - new[] { InlineKeyboardButton.WithCallbackData("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")) }, - new[] { InlineKeyboardButton.WithCallbackData("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")) }, - new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")) }, - new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")) }, - }; - return ("🎲 Выберите систему.", new InlineKeyboardMarkup(buttons).AppendBackCancel()); - } - - private static (string, InlineKeyboardMarkup) RenderDuration() => ( - "⏱ Выберите длительность.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")) }, - new[] { InlineKeyboardButton.WithCallbackData("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")) }, - new[] { InlineKeyboardButton.WithCallbackData("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")) }, - new[] { InlineKeyboardButton.WithCallbackData("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")) }, - new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")) }, - new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderDateTime() => ( - "📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).", - BackCancel()); - - private static (string, InlineKeyboardMarkup) RenderCapacity() => ( - "👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on")) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderVisibility() => ( - "🔒 Выберите видимость.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public")) }, - new[] { InlineKeyboardButton.WithCallbackData("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club")) }, - new[] { InlineKeyboardButton.WithCallbackData("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")) }, - new[] { InlineKeyboardButton.WithCallbackData("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPickClub(IReadOnlyList clubs) - { - if (clubs.Count == 0) - { - return ( - "🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.", - BackCancel()); + return new InlineKeyboardMarkup(Array.Empty()); } - var rows = new List(); - foreach (var club in clubs) + var rows = new InlineKeyboardButton[actions.Count][]; + for (var i = 0; i < actions.Count; i++) { - rows.Add(new[] + rows[i] = new[] { - InlineKeyboardButton.WithCallbackData(club.Name, WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString())) - }); + InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload), + }; } - return ("🏷 Выберите клуб:", new InlineKeyboardMarkup(rows).AppendBackCancel()); + return new InlineKeyboardMarkup(rows); } - - private static (string, InlineKeyboardMarkup) RenderPublish() => ( - "✨ Опубликовать в витрине сейчас?", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes")) }, - new[] { InlineKeyboardButton.WithCallbackData("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderSingleConfirm(WizardPayload p) - { - var sb = new StringBuilder(); - sb.AppendLine("👀 Проверьте перед созданием:"); - sb.AppendLine(); - sb.AppendLine($"🎲 {p.Title}"); - if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); - if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); - if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); - if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)"); - if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}"); - sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); - return (sb.ToString(), new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Создать", WizardCallbackData.Create()) }, - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - })); - } - - // ── Pool renderers ───────────────────────────────────────────────── - private static (string, InlineKeyboardMarkup) RenderPoolSystemDuration() => ( - "🎲 Выберите систему и длительность пула.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")) }, - new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")) }, - new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")) }, - new[] { InlineKeyboardButton.WithCallbackData("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")) }, - new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolAddSlots(WizardPayload p) => ( - $"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")) }, - new[] { InlineKeyboardButton.WithCallbackData("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolSlotDateTime() => ( - "📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).", - BackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolSlotCapacity() => ( - "👥 Введите лимит мест (1..50) и выберите waitlist.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on")) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolConfirm(WizardPayload p) - { - var sb = new StringBuilder(); - sb.AppendLine("👀 Проверьте пул перед созданием:"); - sb.AppendLine(); - sb.AppendLine($"📝 {p.Title}"); - if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); - if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); - if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); - sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); - sb.AppendLine(); - sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):"); - if (p.Pool is not null) - { - foreach (var s in p.Pool.Slots) - { - sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}"); - } - } - return (sb.ToString(), new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Создать пул", WizardCallbackData.Create()) }, - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - })); - } - - // ── Helpers ──────────────────────────────────────────────────────── - private static InlineKeyboardMarkup BackCancel() => new(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - }); - - private static InlineKeyboardMarkup SkipBackCancel() => new(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")) }, - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - }); - - private static string RenderVisibilityText(WizardVisibility? v) => v switch - { - WizardVisibility.Public => "публичная в общем showcase", - WizardVisibility.Club => "публичная в витрине клуба", - WizardVisibility.Members => "только для членов клуба", - _ => "не задана", - }; -} - -internal static class InlineKeyboardMarkupExtensions -{ - public static InlineKeyboardMarkup AppendBackCancel(this InlineKeyboardMarkup kb) => kb; } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs deleted file mode 100644 index a565e0f..0000000 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; - -public sealed class WizardStorageException : Exception -{ - public WizardStorageException(string message, Exception inner) : base(message, inner) { } -} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index dd05aa3..9b0df35 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -1,4 +1,5 @@ // ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment +using System.Globalization; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Sessions.CreateSession; @@ -16,6 +17,7 @@ using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.ReplyMarkups; +using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard; namespace GmRelay.Bot.Infrastructure.Telegram; @@ -36,7 +38,7 @@ public sealed class UpdateRouter( InitiateRescheduleHandler initiateRescheduleHandler, BotRescheduleTimeInputHandler rescheduleTimeInputHandler, BotRescheduleVoteHandler rescheduleVoteHandler, - GameCreationWizard wizard, + SharedWizard wizard, IWizardDraftRepository drafts, ITelegramBotClient bot, IConfiguration configuration, @@ -47,9 +49,9 @@ public sealed class UpdateRouter( // 1) Wizard delegation. If the GM has an active (non-expired) draft for this // (chat, thread, owner), every update routes to the wizard. The wizard is // responsible for both text input and callback handling. - if (TryGetWizardContext(update, out var chatId, out var threadId, out var ownerId)) + if (TryGetWizardContext(update, out _, out _, out var ownerId)) { - var draft = await drafts.GetActiveAsync(chatId, threadId, ownerId, ct); + var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct); if (draft is not null) { // Resume / Reset / Cancel menu callbacks live in the router because @@ -60,7 +62,10 @@ public sealed class UpdateRouter( return; } - await wizard.HandleUpdateAsync(update, draft, ct); + if (WizardInteractionMapper.TryMap(update, out var interaction)) + { + await wizard.HandleInteractionAsync(interaction, draft, ct); + } // The "✅ Создать" / "✅ Создать пул" button — the wizard only // acknowledges the callback; the actual session creation lives in @@ -157,7 +162,7 @@ public sealed class UpdateRouter( }; private static WizardPayload LoadPayload(WizardDraft draft) => - GameCreationWizard.LoadPayload(draft); + SharedWizard.LoadPayload(draft); internal static string GetCommandText(Message message) => (message.Text ?? message.Caption ?? string.Empty).TrimStart(); @@ -166,30 +171,30 @@ public sealed class UpdateRouter( /// Extracts the (chat, thread, owner) triple from an update for wizard lookups. /// Returns false for updates that carry no usable origin (e.g. inline queries). /// - private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out long ownerId) + private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId) { chatId = 0; messageThreadId = null; - ownerId = 0; + ownerId = string.Empty; switch (update) { case { Message: { From: not null, Chat: { } chat } msg }: chatId = chat.Id; messageThreadId = msg.MessageThreadId; - ownerId = msg.From!.Id; + ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture); return true; case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }: chatId = cbmChat.Id; messageThreadId = cb.Message?.MessageThreadId; - ownerId = cb.From!.Id; + ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture); return true; case { CallbackQuery: { From: not null } cb2 }: // Callback arrived without a message (e.g. from a Mini App). No chat // context → wizard cannot run on this update. - ownerId = cb2.From!.Id; + ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture); return false; default: diff --git a/src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql b/src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql new file mode 100644 index 0000000..f435166 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql @@ -0,0 +1,40 @@ +-- V032: Platform-neutral wizard drafts (issue #112). +-- Adds the platform discriminator and switches owner/chat/thread/message +-- columns from numeric to TEXT so the same table can hold both Telegram +-- ids (long) and Discord snowflakes (ulong). All conversions are safe: +-- the affected columns are nullable except chat_id/owner_telegram_id +-- which we cast via TEXT. + +ALTER TABLE wizard_drafts + ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'; + +-- Convert chat_id: BIGINT → TEXT. Existing rows hold Telegram chat ids +-- which convert losslessly to their decimal string form. +ALTER TABLE wizard_drafts + ALTER COLUMN chat_id TYPE TEXT USING chat_id::TEXT; + +-- Convert message_thread_id: INT (nullable) → TEXT (nullable). +ALTER TABLE wizard_drafts + ALTER COLUMN message_thread_id TYPE TEXT USING message_thread_id::TEXT; + +-- Convert draft_message_id: BIGINT (nullable) → TEXT (nullable). +ALTER TABLE wizard_drafts + ALTER COLUMN draft_message_id TYPE TEXT USING draft_message_id::TEXT; + +-- Rename owner_telegram_id → owner_id (now platform-agnostic) and +-- convert from BIGINT to TEXT. +ALTER TABLE wizard_drafts + RENAME COLUMN owner_telegram_id TO owner_id; + +ALTER TABLE wizard_drafts + ALTER COLUMN owner_id TYPE TEXT USING owner_id::TEXT; + +-- Replace the old owner lookup index with one that uses the new column +-- names and the platform discriminator. +DROP INDEX IF EXISTS idx_wizard_drafts_owner; + +CREATE INDEX idx_wizard_drafts_owner + ON wizard_drafts(platform, owner_id); + +CREATE INDEX idx_wizard_drafts_platform + ON wizard_drafts(platform); diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index f5bfd0a..3a8b0e3 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -73,8 +73,8 @@ builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs new file mode 100644 index 0000000..c5b603c --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Npgsql; + +namespace GmRelay.DiscordBot.Features.Sessions.Wizard; + +/// +/// Small lookup helper for Discord permission checks. The +/// already runs the same SQL +/// inline; this class is here so the wizard slash command can do the +/// same check without duplicating the query string. +/// +internal static class DiscordPermissionLookup +{ + public static async Task> LoadManagerUserIdsAsync( + NpgsqlDataSource dataSource, + ulong guildId, + CancellationToken cancellationToken) + { + const string sql = """ + SELECT CAST(p.external_user_id AS BIGINT) + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + JOIN game_groups g ON g.id = gm.group_id + WHERE g.platform = 'Discord' + AND p.platform = 'Discord' + AND g.external_group_id = @GuildId + """; + + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + var rows = await connection.QueryAsync( + new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken)); + return rows.ToList(); + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs new file mode 100644 index 0000000..ca35ae5 --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs @@ -0,0 +1,214 @@ +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using NetCord; +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; +using Npgsql; + +namespace GmRelay.DiscordBot.Features.Sessions.Wizard; + +/// +/// Slash entry point for the Discord wizard. Mirrors the Telegram +/// /newsession-wizard command: a fresh draft is created on +/// first invocation, the persisted first-step message is re-shown +/// when the user already has an active draft, and the owner/co-GM +/// permission check from is +/// applied before any draft is created. +/// +public sealed class DiscordWizardCommand : ApplicationCommandModule +{ + private readonly DiscordWizardMessenger _messenger; + private readonly DiscordPermissionChecker _permissions; + private readonly IWizardDraftRepository _drafts; + private readonly IWizardContextStore _contextStore; + private readonly NpgsqlDataSource _dataSource; + private readonly ILogger _log; + + public DiscordWizardCommand( + DiscordWizardMessenger messenger, + DiscordPermissionChecker permissions, + IWizardDraftRepository drafts, + IWizardContextStore contextStore, + NpgsqlDataSource dataSource, + ILogger log) + { + _messenger = messenger; + _permissions = permissions; + _drafts = drafts; + _contextStore = contextStore; + _dataSource = dataSource; + _log = log; + } + + [SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")] + public async Task ExecuteAsync( + [SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null) + { + var guildId = Context.Interaction.GuildId + ?? throw new InvalidOperationException("This command can only be used in a guild."); + + var channel = Context.Channel + ?? throw new InvalidOperationException("Channel data not available in interaction."); + var channelId = channel.Id.ToString(CultureInfo.InvariantCulture); + + var member = Context.User as GuildInteractionUser + ?? throw new InvalidOperationException("Guild member data not available in interaction."); + var resolvedPermissions = (ulong)member.Permissions; + var userId = Context.User.Id.ToString(CultureInfo.InvariantCulture); + + // Slash commands don't expose a CancellationToken on the context; + // the REST call already has its own per-request cancellation. We + // pass CancellationToken.None for the DB calls — they're cheap and + // the host's shutdown will tear them down. + var ct = CancellationToken.None; + var ownerId = userId; + ulong guildOwnerId = 0; + try + { + var guild = await Context.Client.Rest.GetGuildAsync(guildId); + guildOwnerId = guild.OwnerId; + } + catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _log.LogWarning( + ex, + "Bot is not a REST member of guild {GuildId}; falling back to permissions from interaction payload.", + guildId); + } + + // Permission check: server owner, guild admin, or DB manager + // for the Discord game_group. + var dbManagerIds = await DiscordPermissionLookup.LoadManagerUserIdsAsync( + _dataSource, guildId, ct); + if (!_permissions.CanManageSchedule(guildOwnerId, Context.User.Id, dbManagerIds, resolvedPermissions)) + { + await Context.Interaction.SendResponseAsync(InteractionCallback.Message( + new InteractionMessageProperties() + .WithContent("⛔ Только owner, администратор или manager могут создавать сессии.") + .WithFlags(MessageFlags.Ephemeral))); + return; + } + + // If there's already a draft, offer Continue / Start over. + var existing = await _drafts.GetActiveAsync("Discord", ownerId, ct); + if (existing is not null && existing.Id != Guid.Empty) + { + await Context.Interaction.SendResponseAsync(InteractionCallback.Message( + new InteractionMessageProperties() + .WithContent("📝 У вас уже есть активный мастер. Продолжить?") + .WithFlags(MessageFlags.Ephemeral) + .WithComponents(BuildResumeRow(existing.Id)))); + return; + } + + var draft = new WizardDraft + { + Id = Guid.NewGuid(), + ChatId = guildId.ToString(CultureInfo.InvariantCulture), + MessageThreadId = null, + OwnerId = ownerId, + Platform = "Discord", + Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool + ? WizardStepNames.Title + : WizardStepNames.Type, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), + }; + // If the user passed `mode=pool` we pre-seed the payload so the + // wizard's own branching lands on the pool flow. + if (NormalizeMode(mode) is { } mt) + { + draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize( + new WizardPayload { Type = mt }, + WizardPayloadJsonContext.Default.WizardPayload); + } + + // Stash the context BEFORE sending so the messenger can both + // send the message and persist the returned message id back. + _contextStore.Set(draft.Id, new DiscordWizardContext( + GuildId: guildId.ToString(CultureInfo.InvariantCulture), + ChannelId: channelId, + MessageId: string.Empty, + ThreadId: null)); + + // Render + send the first step. Defer the response so we can + // show the wizard message in the channel. + await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage( + MessageFlags.Ephemeral)); + + try + { + var payload = LoadPayload(draft); + var (text, actions) = WizardStepViewBuilder.Build(draft, payload); + var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct); + draft.DraftMessageId = msgId; + draft.UpdatedAt = DateTimeOffset.UtcNow; + await _drafts.UpsertAsync(draft, ct); + + await Context.Interaction.ModifyResponseAsync(msg => + { + msg.Content = "🎲 Мастер запущен. См. сообщение ниже."; + msg.Flags = MessageFlags.Ephemeral; + }); + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to start wizard for user {OwnerId} in guild {GuildId}", ownerId, guildId); + _contextStore.Remove(draft.Id); + try + { + await _drafts.DeleteAsync(draft.Id, ct); + } + catch + { + /* best effort */ + } + await Context.Interaction.ModifyResponseAsync(msg => + { + msg.Content = "💥 Не удалось запустить мастер. Попробуйте позже."; + msg.Flags = MessageFlags.Ephemeral; + }); + } + } + + private static WizardCreationType? NormalizeMode(string? mode) => + mode?.ToLowerInvariant() switch + { + "single" or "одну" or "one" => WizardCreationType.Single, + "pool" or "пул" => WizardCreationType.Pool, + _ => null, + }; + + 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 static IReadOnlyList BuildResumeRow(Guid draftId) + { + // Direct format strings (not ChoiceButtonCustomId) — these + // are control actions (resume:cancel), not wizard-step choices. + // The dispatcher's switch matches parts[1] as "resume" or + // "cancel" directly, not as a "choice" prefix. + var row = new ActionRowProperties(); + row.Add(new ButtonProperties( + "wizard:btn:resume:continue", + "▶️ Продолжить", + ButtonStyle.Primary)); + row.Add(new ButtonProperties( + "wizard:btn:resume:restart", + "🔄 Заново", + ButtonStyle.Secondary)); + row.Add(new ButtonProperties( + "wizard:btn:cancel:1", + "❌ Отмена", + ButtonStyle.Danger)); + return new IMessageComponentProperties[] { row }; + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardContextStore.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardContextStore.cs new file mode 100644 index 0000000..5cbd82d --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardContextStore.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Concurrent; + +namespace GmRelay.DiscordBot.Features.Sessions.Wizard; + +/// +/// Snapshot of where the wizard's draft message lives. The messenger +/// needs this to re-send / re-edit the message after a 15-minute +/// interaction token has expired. +/// +/// Discord guild (server) id as a decimal string. +/// Channel id where the draft message was posted. +/// Id of the currently active draft message. +/// Optional thread id; null for top-level channel posts. +public sealed record DiscordWizardContext( + string GuildId, + string ChannelId, + string MessageId, + string? ThreadId); + +/// +/// In-memory store of draft → context lookups. Lives for the lifetime of +/// the process; the wizard's 24-hour expiry is enforced by +/// WizardDraftCleanupService, so this cache is allowed to hold +/// entries until the draft is finalized or explicitly removed. +/// +public interface IWizardContextStore +{ + void Set(Guid draftId, DiscordWizardContext context); + + bool TryGet(Guid draftId, out DiscordWizardContext context); + + void Remove(Guid draftId); +} + +/// +/// Thread-safe in-memory implementation. Concurrent dictionary keyed by +/// is sufficient for a single-process Discord bot. +/// +public sealed class DiscordWizardContextStore : IWizardContextStore +{ + private readonly ConcurrentDictionary store = new(); + + public void Set(Guid draftId, DiscordWizardContext context) => + store[draftId] = context; + + public bool TryGet(Guid draftId, out DiscordWizardContext context) => + store.TryGetValue(draftId, out context!); + + public void Remove(Guid draftId) => store.TryRemove(draftId, out _); +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs new file mode 100644 index 0000000..daf128d --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +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 Npgsql; +using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard; + +namespace GmRelay.DiscordBot.Features.Sessions.Wizard; + +/// +/// Inbound component-interaction handler for the Discord wizard. +/// +/// One class per interaction context type — NetCord's +/// ComponentInteractionModule<TContext> is single-context. All +/// three classes share the same dispatch table (parse customId → load +/// draft → call the shared +/// ) implemented in +/// . +/// +/// Custom-id wire format (see ): +/// +/// wizard:btn:choice:<step>:<value> — choice buttons +/// wizard:btn:cancel, wizard:btn:back, wizard:btn:create +/// wizard:btn:resume:<continue|restart> +/// wizard:select:<step> — StringSelectMenu +/// wizard:modal:<step> — Modal submit +/// +/// +/// 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. +/// +public sealed class DiscordWizardButtonModule : ComponentInteractionModule +{ + 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 +{ + 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 +{ + private readonly WizardInteractionDispatcher _dispatcher; + + public DiscordWizardModalModule(WizardInteractionDispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + [ComponentInteraction("wizard")] + public Task HandleAsync(string args) => + _dispatcher.HandleModalAsync(Context, args); +} + +/// +/// Shared dispatch table for the three wizard interaction modules. +/// Owns all the stateful collaborators (drafts, context store, wizard +/// state machine, submitter, messenger, reply cache) so the three +/// NetCord module shells can stay trivially thin. +/// +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 NpgsqlDataSource _dataSource; + private readonly ILogger _log; + + public WizardInteractionDispatcher( + IWizardDraftRepository drafts, + IWizardContextStore contextStore, + SharedWizard wizard, + DiscordWizardSubmitter submitter, + DiscordWizardMessenger messenger, + DiscordInteractionReplyCache replies, + NpgsqlDataSource dataSource, + ILogger log) + { + _drafts = drafts; + _contextStore = contextStore; + _wizard = wizard; + _submitter = submitter; + _messenger = messenger; + _replies = replies; + _dataSource = dataSource; + _log = log; + } + + /// + /// 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 + /// returns. + /// + private static readonly IReadOnlySet StepsThatOpenModal = new HashSet(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 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::" (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; + } + + // 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: "modal:" — the renderer emits this for the + // "Другое…" free-text buttons on System, Duration, and + // PoolSystemDuration. The click intent is "open a modal for + // free-text input" — NOT "advance the wizard". The wizard's + // state advance happens when the user submits the modal. + if (parts[1] == "modal" && parts.Length >= 3) + { + var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId); + if (modal is not null) + { + await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal)); + } + else + { + await AckWithErrorAsync(context.Interaction, "Не удалось открыть форму"); + } + return; + } + + // Special case: "resume" — the slash command's resume row + // gives the user a chance to keep or restart their active + // draft. The wizard has no built-in resume case, so we + // 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) + { + await AckWithErrorAsync(context.Interaction, "Некорректная кнопка"); + return; + } + callback = WizardCallbackData.Choice(parts[2], parts[3]); + break; + case "back": + callback = WizardCallbackData.Back(); + break; + case "cancel": + callback = WizardCallbackData.Cancel(); + break; + default: + await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка"); + 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'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:" (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; + } + + // NetCord strips the matching ComponentInteraction "wizard" + // prefix from the custom id and passes the remainder as `args`. + // For `wizard:select:Visibility` the args arrive as + // `select:Visibility` (2 parts when split on `:`), so the + // length check uses `< 2` and the step lives at parts[1]. + var parts = args.Split(':', 2); + if (parts.Length < 2 || parts[0] != "select" || context.Interaction.Data.SelectedValues.Count == 0) + { + await AckWithErrorAsync(context.Interaction, "Неизвестный выбор"); + return; + } + var step = parts[1]; + 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) + { + // 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:" (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; + } + + // Same NetCord prefix-stripping as the select handler: + // for `wizard:modal:Title` the args arrive as `modal:Title` + // (2 parts when split on `:`). + var parts = args.Split(':', 2); + if (parts.Length < 2 || parts[0] != "modal" || context.Interaction.Data.Components.Count == 0) + { + await AckWithErrorAsync(context.Interaction, "Некорректный модал"); + return; + } + var step = parts[1]; + + var text = ExtractModalText(context); + if (text is null) + { + await AckWithErrorAsync(context.Interaction, "Модал без ввода"); + return; + } + + // 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 canonical 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 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 + { + 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 ─────────────────────────────────────────────────────── + 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?> LoadClubsAsync(WizardDraft draft, CancellationToken ct) + { + // Inline the same query the messenger would run, so the + // dispatcher's PickClub step sees the owner's real club list + // instead of an empty array. + return await WizardClubLookup.LoadClubsAsync(_dataSource, 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 static async Task AckWithErrorAsync(Interaction interaction, string text) + { + try + { + await interaction.SendResponseAsync(InteractionCallback.Message( + new InteractionMessageProperties() + .WithContent($"⚠️ {text}") + .WithFlags(MessageFlags.Ephemeral))); + } + catch + { + /* best effort */ + } + } +} + +/// +/// Standalone helper that queries the owner-club list without going +/// through . 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). +/// +internal static class WizardClubLookup +{ + public static async Task> LoadClubsAsync( + NpgsqlDataSource dataSource, string ownerId, CancellationToken ct) + { + // Same SQL the messenger runs for the wizard's render path. + // Filter by Owner|CoGm role and group by club to dedupe. + const string sql = """ + SELECT g.id AS ClubId, + g.name AS Name + FROM game_groups g + JOIN group_managers gm ON gm.group_id = g.id + JOIN players p ON p.id = gm.player_id + WHERE p.platform = @Platform + AND p.external_user_id = @OwnerId + AND gm.role IN ('Owner', 'CoGm') + GROUP BY g.id, g.name + ORDER BY g.name + """; + await using var conn = await dataSource.OpenConnectionAsync(ct); + var rows = await conn.QueryAsync( + new CommandDefinition( + sql, + new { Platform = "Discord", OwnerId = ownerId }, + cancellationToken: ct)); + return rows.AsList(); + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs new file mode 100644 index 0000000..8485ef3 --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Platform; +using NetCord; +using NetCord.Rest; +using Npgsql; + +namespace GmRelay.DiscordBot.Features.Sessions.Wizard; + +/// +/// Discord-side implementation of . +/// Translates the platform-neutral wizard contract into NetCord REST +/// calls and ephemeral follow-ups. The messenger has no access to the +/// live interaction context — that lives on the inbound handler — so +/// stashes the toast text in +/// ; the inbound module +/// drains the cache and ships the actual SendResponseAsync call +/// via the existing helper used by DiscordPlatformMessenger. +/// +public sealed class DiscordWizardMessenger : IWizardMessenger +{ + private readonly RestClient _rest; + private readonly NpgsqlDataSource _dataSource; + private readonly DiscordInteractionReplyCache _replies; + private readonly IWizardContextStore _contextStore; + private readonly ILogger? _log; + + public DiscordWizardMessenger( + RestClient rest, + NpgsqlDataSource dataSource, + DiscordInteractionReplyCache replies, + IWizardContextStore contextStore) + : this(rest, dataSource, replies, contextStore, logger: null) + { + } + + public DiscordWizardMessenger( + RestClient rest, + NpgsqlDataSource dataSource, + DiscordInteractionReplyCache replies, + IWizardContextStore contextStore, + ILogger? logger) + { + _rest = rest; + _dataSource = dataSource; + _replies = replies; + _contextStore = contextStore; + _log = logger; + } + + public async Task EditDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct) + { + if (!_contextStore.TryGet(draft.Id, out var ctx)) + { + // No stored context (e.g. service restart, draft from another + // process). Fall back to sending a brand new message — the + // caller will persist the returned id. + return await SendDraftMessageAsync(draft, text, keyboard, ct); + } + + if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) || + !ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)) + { + // Context is corrupt — recreate the message. + return await SendDraftMessageAsync(draft, text, keyboard, ct); + } + + try + { + var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard); + await _rest.ModifyMessageAsync( + channelId, + messageId, + options => + { + options.Embeds = embed is null ? null : new[] { embed }; + options.Components = rows; + }); + return ctx.MessageId; + } + catch (RestException ex) when (IsExpiredOrUnknownMessage(ex)) + { + // Message was deleted or interaction token expired — + // recreate the message in the original channel. + _log?.LogWarning( + ex, + "Edit failed for draft {DraftId} (channel={ChannelId}, message={MessageId}); re-sending.", + draft.Id, + ctx.ChannelId, + ctx.MessageId); + return await SendDraftMessageAsync(draft, text, keyboard, ct); + } + } + + public async Task SendDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct) + { + if (!_contextStore.TryGet(draft.Id, out var ctx)) + { + throw new InvalidOperationException( + $"Cannot send wizard message: no context for draft {draft.Id}."); + } + + if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId)) + { + throw new InvalidOperationException( + $"Wizard draft {draft.Id} has un-parseable channel id '{ctx.ChannelId}'."); + } + + var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard); + var message = await _rest.SendMessageAsync( + channelId, + new MessageProperties() + .WithEmbeds(embed is null ? null : new[] { embed }) + .WithComponents(rows)); + + var newMessageId = message.Id.ToString(CultureInfo.InvariantCulture); + _contextStore.Set(draft.Id, ctx with { MessageId = newMessageId }); + return newMessageId; + } + + public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct) + { + // The wizard's "answer" is just a toast shown to the user. + // Stash it in the existing reply cache; the inbound interaction + // module drains it once the wizard returns. ShowAlert=false so + // it appears as a quiet follow-up rather than a popup. + _replies.Store(new PlatformInteractionReply( + InteractionId: interactionId, + Text: text ?? string.Empty, + ShowAlert: false)); + return Task.CompletedTask; + } + + public async Task> GetOwnerClubsAsync( + string ownerId, CancellationToken ct) + { + // The Telegram messenger enumerates game_groups the owner + // manages as a GM (V008 added group_managers with role + // 'Owner'|'CoGm'). Discord follows the same convention. + const string sql = """ + SELECT g.id AS ClubId, + g.name AS Name + FROM game_groups g + JOIN group_managers gm ON gm.group_id = g.id + JOIN players p ON p.id = gm.player_id + WHERE p.platform = @Platform + AND p.external_user_id = @ExternalId + AND gm.role IN ('Owner', 'CoGm') + GROUP BY g.id, g.name + ORDER BY g.name + """; + await using var conn = await _dataSource.OpenConnectionAsync(ct); + var rows = await conn.QueryAsync( + new CommandDefinition( + sql, + new { Platform = "Discord", ExternalId = ownerId }, + cancellationToken: ct)); + return rows.AsList(); + } + + // ── Embed + component construction ──────────────────────────────── + private (EmbedProperties? embed, IReadOnlyList rows) BuildEmbedAndRows( + WizardDraft draft, + string text, + IReadOnlyList keyboard) + { + // Embeds have a hard 4096-char limit — truncate to 3900 so the + // wizard's own prefix/suffix additions still fit. + var safeText = Truncate(text, 3900); + var rows = BuildActionRowsFromActions(keyboard); + return (BuildEmbed(safeText), rows); + } + + private static EmbedProperties BuildEmbed(string description) => + new EmbedProperties() + .WithTitle("Мастер создания сессии") + .WithDescription(description) + .WithColor(new Color(0x5865F2)); + + private static string Truncate(string text, int max) => + text.Length <= max ? text : text[..max]; + + private static IReadOnlyList BuildActionRowsFromActions( + IReadOnlyList actions) + { + if (actions.Count == 0) + { + return Array.Empty(); + } + var rows = new List(); + // Discord allows up to 5 buttons per ActionRow. Lay them out + // left-to-right, 5 per row. + foreach (var chunk in actions.Chunk(5)) + { + var row = new ActionRowProperties(); + foreach (var action in chunk) + { + var style = action.Style switch + { + WizardActionStyle.Primary => ButtonStyle.Primary, + WizardActionStyle.Success => ButtonStyle.Success, + WizardActionStyle.Danger => ButtonStyle.Danger, + _ => ButtonStyle.Secondary, + }; + var cid = action.Payload; + if (cid.Length > DiscordWizardStep.MaxCustomIdLength) + { + // Truncate-by-omission isn't safe (customId must be + // unique). The wizard's callback format is already + // bounded — if we hit this, it's a bug. + throw new InvalidOperationException( + $"Wizard action custom id '{cid}' exceeds Discord's 100-char limit."); + } + row.Add(new ButtonProperties(cid, action.Label, style)); + } + rows.Add(row); + } + return rows; + } + + private static bool IsExpiredOrUnknownMessage(RestException ex) => + ex.StatusCode == System.Net.HttpStatusCode.NotFound + || ex.StatusCode == System.Net.HttpStatusCode.Unauthorized + || ex.StatusCode == System.Net.HttpStatusCode.Forbidden; +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs new file mode 100644 index 0000000..b9aa1cb --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs @@ -0,0 +1,608 @@ +using System.Collections.Generic; +using System.Linq; +using GmRelay.DiscordBot.Rendering; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using NetCord; +using NetCord.Rest; +using NetCord.Services.ComponentInteractions; + +namespace GmRelay.DiscordBot.Features.Sessions.Wizard; + +/// +/// Renders a wizard step into a Discord embed + components row + an +/// optional modal popup. Lives in the DiscordBot project because the +/// platform-neutral doesn't know +/// about embeds, action rows, or modals. +/// +/// The renderer also exposes so the slash +/// command can return a "popup" response to the user (Telegram's +/// "ForceReply" equivalent). Discord modals are limited to 5 components +/// (typically labels wrapping text inputs) and a 45-character title. +/// +public static class DiscordWizardStep +{ + /// + /// Discord custom-id budget is 100 characters. We pad our own + /// identifiers to stay under it. + /// + public const int MaxCustomIdLength = 100; + + /// + /// Sentinel returned in + /// when the renderer wants the interaction module to open a modal + /// for the given step. The step name is the + /// value (e.g. Title, DateTime). + /// + public sealed record DiscordWizardRender( + string EmbedTitle, + string EmbedDescription, + IReadOnlyList Components, + string? OpenModalStep); + + /// + /// Build the embed + components for a wizard step. The caller is + /// responsible for sending the message via + /// . + /// + public static DiscordWizardRender Render( + WizardDraft draft, + WizardPayload payload, + IReadOnlyList? clubs = null) + { + return draft.Step switch + { + WizardStepNames.Type => RenderType(), + WizardStepNames.Title => RenderTitle(), + WizardStepNames.Description => RenderDescription(), + WizardStepNames.Cover => RenderCover(), + WizardStepNames.System => RenderSystem(), + WizardStepNames.Duration => RenderDuration(), + WizardStepNames.DateTime => RenderDateTime(), + WizardStepNames.Capacity => RenderCapacity(), + WizardStepNames.Visibility => RenderVisibility(), + WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty()), + WizardStepNames.Publish => RenderPublish(), + WizardStepNames.Confirm => RenderConfirm(payload), + + WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(), + WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload), + WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(), + WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(), + WizardStepNames.PoolConfirm => RenderPoolConfirm(payload), + + _ => throw new System.InvalidOperationException($"Unknown wizard step: {draft.Step}"), + }; + } + + // ── Custom-id helpers ───────────────────────────────────────────── + // Three custom-id shapes for buttons, all with the literal "wizard" prefix + // that the NetCord [ComponentInteraction("wizard")] matcher strips off. + // After prefix-strip the dispatcher receives the suffix as `args`. + // + // Choice : wizard:btn:choice:: → wizard's ApplyChoice + // Control : wizard:btn::1 → dispatcher special case + // Modal trig. : wizard:btn:modal: → dispatcher opens modal + // + // The renderer's helpers below enforce these shapes so the dispatcher + // parser and the wizard callbacks stay in lockstep. + public static string ChoiceButtonCustomId(string step, string value) => + $"wizard:btn:choice:{step}:{value}"; + + public static string ControlButtonCustomId(string action) => + $"wizard:btn:{action}:1"; + + public static string ModalTriggerButtonCustomId(string modalStep) => + $"wizard:btn:modal:{modalStep}"; + + public static string SelectCustomId(string step) => $"wizard:select:{step}"; + + public static string ModalCustomId(string step) => $"wizard:modal:{step}"; + + public static bool TryParseButtonCustomId(string customId, out string step, out string value) + { + step = value = string.Empty; + var parts = customId.Split(':', 5); + if (parts.Length < 5 || parts[0] != "wizard" || parts[1] != "btn" || parts[2] != "choice") + { + return false; + } + step = parts[3]; + value = parts[4]; + return true; + } + + public static bool TryParseSelectCustomId(string customId, out string step) + { + step = string.Empty; + var parts = customId.Split(':', 3); + return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "select" && (step = parts[2]) is not null; + } + + public static bool TryParseModalCustomId(string customId, out string step) + { + step = string.Empty; + var parts = customId.Split(':', 3); + return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "modal" && (step = parts[2]) is not null; + } + + // ── Helpers ─────────────────────────────────────────────────────── + // Three button factories, one per custom-id shape (see ChoiceButtonCustomId + // comment above). RenderX() uses these to build rows; the call site + // determines which kind of button each row needs. + private static ButtonProperties ChoiceBtn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary) + { + var cid = ChoiceButtonCustomId(step, value); + EnsureCustomIdFits(cid); + return new ButtonProperties(cid, label, style); + } + + private static ButtonProperties ControlBtn(string label, string action, ButtonStyle style = ButtonStyle.Secondary) + { + var cid = ControlButtonCustomId(action); + EnsureCustomIdFits(cid); + return new ButtonProperties(cid, label, style); + } + + private static ButtonProperties ModalTriggerBtn(string label, string modalStep, ButtonStyle style = ButtonStyle.Secondary) + { + var cid = ModalTriggerButtonCustomId(modalStep); + EnsureCustomIdFits(cid); + return new ButtonProperties(cid, label, style); + } + + private static ActionRowProperties Row(params IActionRowComponentProperties[] components) + { + var row = new ActionRowProperties(); + foreach (var c in components) + { + row.Add(c); + } + return row; + } + + /// + /// Wrap a list of top-level message components (action rows and + /// select menus) into a single + /// for MessageProperties.WithComponents. + /// + private static IReadOnlyList Comps(params IMessageComponentProperties[] items) => + items; + + private static void EnsureCustomIdFits(string customId) + { + if (customId.Length > MaxCustomIdLength) + { + throw new System.InvalidOperationException( + $"Custom id '{customId}' is {customId.Length} chars; Discord limit is {MaxCustomIdLength}."); + } + } + + // ── Single-game steps ───────────────────────────────────────────── + private static DiscordWizardRender RenderType() => new( + "🎲 Создание игровой сессии", + "Выберите тип: одна игра или пул.", + new IMessageComponentProperties[] { Row(ChoiceBtn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary), + ChoiceBtn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: null); + + private static DiscordWizardRender RenderTitle() => new( + "📝 Название", + "Введите название игры в модальном окне.", + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: WizardStepNames.Title); + + private static DiscordWizardRender RenderDescription() => new( + "📄 Описание", + "Введите описание (или «-», чтобы пропустить).", + new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Description, "-"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: WizardStepNames.Description); + + private static DiscordWizardRender RenderCover() => new( + "🖼 Обложка", + "Введите URL картинки (или «-», чтобы пропустить).", + new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Cover, "-"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: WizardStepNames.Cover); + + private static DiscordWizardRender RenderSystem() => new( + "🎲 Система", + "Выберите систему.", + new[] + { + Row(ChoiceBtn("D&D 5e", WizardStepNames.System, "Dnd5e"), + ChoiceBtn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"), + ChoiceBtn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"), + ChoiceBtn("GURPS", WizardStepNames.System, "GURPS"), + ChoiceBtn("Fate", WizardStepNames.System, "Fate")), + Row(ModalTriggerBtn("Другое… ✏️", "SystemFreeText", ButtonStyle.Primary), + ChoiceBtn("⏭ Пропустить", WizardStepNames.System, "_skip"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + private static DiscordWizardRender RenderDuration() => new( + "⏱ Длительность", + "Выберите длительность (или «Другое…»).", + new[] + { + Row(ChoiceBtn("3 часа", WizardStepNames.Duration, "180"), + ChoiceBtn("4 часа", WizardStepNames.Duration, "240"), + ChoiceBtn("5 часов", WizardStepNames.Duration, "300"), + ChoiceBtn("6 часов", WizardStepNames.Duration, "360")), + Row(ModalTriggerBtn("Другое… ✏️", "DurationFreeText", ButtonStyle.Primary), + ChoiceBtn("⏭ Пропустить", WizardStepNames.Duration, "_skip"), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + private static DiscordWizardRender RenderDateTime() => new( + "📅 Дата и время", + "Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).", + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: WizardStepNames.DateTime); + + private static DiscordWizardRender RenderCapacity() => new( + "👥 Лимит мест", + "Введите лимит (1..50) и выберите waitlist.", + new[] + { + Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success), + ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: WizardStepNames.Capacity); + + private static DiscordWizardRender RenderVisibility() => new( + "🔒 Видимость", + "Выберите, кто увидит сессию.", + new IMessageComponentProperties[] + { + BuildSelectMenu( + SelectCustomId(WizardStepNames.Visibility), + "Выберите видимость…", + new[] { new StringMenuSelectOptionProperties("🌐 Публичная в общем showcase", "public"), + new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"), + new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"), + }), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + private static DiscordWizardRender RenderPickClub(IReadOnlyList clubs) + { + if (clubs.Count == 0) + { + return new DiscordWizardRender( + "🏷 Выбор клуба", + "У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.", + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: null); + } + var options = new List(clubs.Count); + foreach (var c in clubs.Take(25)) + { + options.Add(new StringMenuSelectOptionProperties(c.Name, c.ClubId.ToString())); + } + return new DiscordWizardRender( + "🏷 Выбор клуба", + "Выберите клуб из списка.", + new IMessageComponentProperties[] + { + BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + } + + private static DiscordWizardRender RenderPublish() => new( + "✨ Публикация", + "Опубликовать в витрине сейчас?", + new[] + { + Row(ChoiceBtn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success), + ChoiceBtn("📝 Только в чате", WizardStepNames.Publish, "no")), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + private static DiscordWizardRender RenderConfirm(WizardPayload p) => new( + "👀 Проверьте перед созданием", + BuildConfirmDescription(p), + new[] + { + Row(ControlBtn("✅ Создать", "create", ButtonStyle.Success), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + // ── Pool steps ──────────────────────────────────────────────────── + private static DiscordWizardRender RenderPoolSystemDuration() => new( + "🎲 Система и длительность пула", + "Выберите пресет или «Другое…».", + new IMessageComponentProperties[] + { + BuildSelectMenu( + SelectCustomId(WizardStepNames.PoolSystemDuration), + "Выберите пресет…", + new[] { new StringMenuSelectOptionProperties("D&D 5e · 4 ч", "Dnd5e:240"), + new StringMenuSelectOptionProperties("Pathfinder 2e · 4 ч", "Pathfinder2e:240"), + new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"), + new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"), + }), + Row(ModalTriggerBtn("Другое… ✏️", "PoolSystemDurationFreeText", ButtonStyle.Primary), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + private static DiscordWizardRender RenderPoolAddSlots(WizardPayload p) => new( + $"📅 Слоты пула «{p.Title}»", + $"Добавлено: {p.Pool?.Slots.Count ?? 0}.", + new[] + { + Row(ChoiceBtn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary), + ChoiceBtn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + private static DiscordWizardRender RenderPoolSlotDateTime() => new( + "📅 Дата/время слота", + "Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).", + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: WizardStepNames.PoolSlotDateTime); + + private static DiscordWizardRender RenderPoolSlotCapacity() => new( + "👥 Лимит слотов", + "Введите лимит (1..50) и выберите waitlist.", + new[] + { + Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success), + ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: WizardStepNames.PoolSlotCapacity); + + private static DiscordWizardRender RenderPoolConfirm(WizardPayload p) => new( + "👀 Проверьте пул перед созданием", + BuildPoolConfirmDescription(p), + new[] + { + Row(ControlBtn("✅ Создать пул", "create", ButtonStyle.Success), + ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + // ── Builders for embed descriptions and selects ─────────────────── + private static StringMenuProperties BuildSelectMenu( + string customId, + string placeholder, + IReadOnlyList options) + { + EnsureCustomIdFits(customId); + return new StringMenuProperties(customId, options) + { + Placeholder = placeholder, + }; + } + + private static string BuildConfirmDescription(WizardPayload p) + { + var sb = new System.Text.StringBuilder(); + sb.Append("🎲 ").AppendLine(p.Title); + if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description); + if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System); + if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч"); + if (p.Single?.ScheduledAt is { } at) sb.Append("📅 ").AppendLine(at.FormatMoscow()); + if (p.Single?.MaxPlayers is { } mp) + { + sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine(); + } + sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility)); + return sb.ToString(); + } + + private static string BuildPoolConfirmDescription(WizardPayload p) + { + var sb = new System.Text.StringBuilder(); + sb.Append("📝 ").AppendLine(p.Title); + if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description); + if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System); + if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч"); + sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility)); + sb.Append("Слоты (").Append(p.Pool?.Slots.Count ?? 0).AppendLine("):"); + if (p.Pool is not null) + { + foreach (var s in p.Pool.Slots) + { + sb.Append(" • ").Append(s.ScheduledAt.FormatMoscow()) + .Append(" — мест ").Append(s.MaxPlayers) + .Append(", waitlist ").Append(s.Waitlist ? "вкл" : "выкл") + .AppendLine(); + } + } + return sb.ToString(); + } + + private static string RenderVisibilityText(WizardVisibility? v) => v switch + { + WizardVisibility.Public => "публичная в общем showcase", + WizardVisibility.Club => "публичная в витрине клуба", + WizardVisibility.Members => "только для членов клуба", + _ => "не задана", + }; + + // ── Modal builders ──────────────────────────────────────────────── + /// + /// Build a for the given wizard step. + /// The wizard step's openModal value drives which modal we + /// emit. Returns null if no modal is required for the step. + /// + public static ModalProperties? BuildModal(string step, string? draftTitle) + { + return step switch + { + WizardStepNames.Title => new ModalProperties( + ModalCustomId(WizardStepNames.Title), + "📝 Название игры", + new IModalComponentProperties[] + { + new LabelProperties( + "Название", + new TextInputProperties(ModalCustomId(WizardStepNames.Title), TextInputStyle.Short) + { + Placeholder = "Например: D&D 5e, Проклятие Страда", + MinLength = 1, + MaxLength = WizardStepLimits.MaxTitleLength, + Required = true, + }), + }), + WizardStepNames.Description => new ModalProperties( + ModalCustomId(WizardStepNames.Description), + "📄 Описание", + new IModalComponentProperties[] + { + new LabelProperties( + "Описание", + new TextInputProperties(ModalCustomId(WizardStepNames.Description), TextInputStyle.Paragraph) + { + Placeholder = "Опишите сценарий / сеттинг. «-» чтобы пропустить.", + MaxLength = WizardStepLimits.MaxDescriptionLength, + Required = true, + }), + }), + WizardStepNames.Cover => new ModalProperties( + ModalCustomId(WizardStepNames.Cover), + "🖼 Обложка (URL)", + new IModalComponentProperties[] + { + new LabelProperties( + "URL картинки", + new TextInputProperties(ModalCustomId(WizardStepNames.Cover), TextInputStyle.Short) + { + Placeholder = "https://… или «-» чтобы пропустить", + MaxLength = 500, + Required = true, + }), + }), + "SystemFreeText" => new ModalProperties( + ModalCustomId("SystemFreeText"), + "🎲 Другая система", + new IModalComponentProperties[] + { + new LabelProperties( + "Система", + new TextInputProperties(ModalCustomId("SystemFreeText"), TextInputStyle.Short) + { + Placeholder = "Свободное название системы", + MaxLength = WizardStepLimits.MaxSystemLength, + Required = true, + }), + }), + "DurationFreeText" => new ModalProperties( + ModalCustomId("DurationFreeText"), + "⏱ Длительность (часы)", + new IModalComponentProperties[] + { + new LabelProperties( + "Часы", + new TextInputProperties(ModalCustomId("DurationFreeText"), TextInputStyle.Short) + { + Placeholder = "1..12", + MaxLength = 4, + Required = true, + }), + }), + WizardStepNames.DateTime => new ModalProperties( + ModalCustomId(WizardStepNames.DateTime), + "📅 Дата и время", + new IModalComponentProperties[] + { + new LabelProperties( + "Когда", + new TextInputProperties(ModalCustomId(WizardStepNames.DateTime), TextInputStyle.Short) + { + Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ", + MaxLength = 32, + Required = true, + }), + }), + WizardStepNames.Capacity => new ModalProperties( + ModalCustomId(WizardStepNames.Capacity), + "👥 Лимит мест", + new IModalComponentProperties[] + { + new LabelProperties( + "Max players", + new TextInputProperties(ModalCustomId(WizardStepNames.Capacity), TextInputStyle.Short) + { + Placeholder = "1..50", + MaxLength = 3, + Required = true, + }), + }), + WizardStepNames.PoolSlotDateTime => new ModalProperties( + ModalCustomId(WizardStepNames.PoolSlotDateTime), + "📅 Дата/время слота", + new IModalComponentProperties[] + { + new LabelProperties( + "Когда", + new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotDateTime), TextInputStyle.Short) + { + Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ", + MaxLength = 32, + Required = true, + }), + }), + WizardStepNames.PoolSlotCapacity => new ModalProperties( + ModalCustomId(WizardStepNames.PoolSlotCapacity), + "👥 Лимит слотов", + new IModalComponentProperties[] + { + new LabelProperties( + "Max players", + new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotCapacity), TextInputStyle.Short) + { + Placeholder = "1..50", + MaxLength = 3, + Required = true, + }), + }), + "PoolSystemDurationFreeText" => new ModalProperties( + ModalCustomId("PoolSystemDurationFreeText"), + "🎲 Другая система пула", + new IModalComponentProperties[] + { + new LabelProperties( + "Система и длительность", + new TextInputProperties(ModalCustomId("PoolSystemDurationFreeText"), TextInputStyle.Short) + { + Placeholder = "Dnd5e:240 или «Pathfinder 2e:180»", + MaxLength = 32, + Required = true, + }), + }), + _ => null, + }; + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs new file mode 100644 index 0000000..ec2c965 --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; +using NetCord; +using NetCord.Rest; + +namespace GmRelay.DiscordBot.Features.Sessions.Wizard; + +/// +/// Finalises a wizard draft by calling the shared +/// . On success the original draft +/// message is overwritten with a "✅ Создано" confirmation; on failure +/// the user is offered Retry / Cancel buttons so the same draft can be +/// re-submitted without re-entering the wizard from scratch. +/// +public sealed class DiscordWizardSubmitter +{ + private const int MaxRetries = 3; + + private readonly CreateSessionHandler _shared; + private readonly RestClient _rest; + private readonly IWizardDraftRepository _drafts; + private readonly IWizardContextStore _contextStore; + private readonly ILogger _log; + + public DiscordWizardSubmitter( + CreateSessionHandler shared, + RestClient rest, + IWizardDraftRepository drafts, + IWizardContextStore contextStore, + ILogger log) + { + _shared = shared; + _rest = rest; + _drafts = drafts; + _contextStore = contextStore; + _log = log; + } + + /// + /// Submit the draft to the shared handler. On a 1-shot failure we + /// edit the draft message to show "retry / cancel" affordances and + /// bump the in-payload retry counter; after + /// consecutive failures the draft is deleted. + /// + public async Task SubmitAsync(WizardDraft draft, CancellationToken ct) + { + var payload = LoadPayload(draft); + if (!IsComplete(payload, out var missing)) + { + await EditDraftMessageAsync( + draft, + $"❌ Не заполнены поля: {missing}", + RetryCancelActions(), + ct); + return; + } + + try + { + var commands = BuildCommands(draft, payload); + foreach (var cmd in commands) + { + await _shared.HandleAsync(cmd, ct); + } + var totalSessions = commands.Sum(c => c.ScheduledTimes.Count); + await EditDraftMessageAsync( + draft, + $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}", + Array.Empty(), + ct); + await _drafts.DeleteAsync(draft.Id, ct); + _contextStore.Remove(draft.Id); + } + catch (Exception ex) + { + _log.LogError(ex, "Submit failed for draft {DraftId}", draft.Id); + payload.RetryCount += 1; + SavePayload(draft, payload); + if (payload.RetryCount >= MaxRetries) + { + await EditDraftMessageAsync( + draft, + "💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.", + Array.Empty(), + ct); + await _drafts.DeleteAsync(draft.Id, ct); + _contextStore.Remove(draft.Id); + return; + } + draft.UpdatedAt = DateTimeOffset.UtcNow; + await _drafts.UpsertAsync(draft, ct); + // The full exception (with stack trace, Postgres constraint + // name, sometimes partial SQL) is already logged server-side + // on line 86. Show the user a generic message — never leak + // internal error strings to the Discord channel. + await EditDraftMessageAsync( + draft, + $"💥 Не удалось создать сессию. Попробуйте ещё раз (попытка {payload.RetryCount}/{MaxRetries}).", + RetryCancelActions(), + ct); + } + } + + // ── Build shared commands ──────────────────────────────────────── + // Same shape as the Telegram submitter: pool → one command with N + // times, single → one command with one time. + private static List BuildCommands(WizardDraft draft, WizardPayload p) + { + if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0) + { + return new List + { + BuildCommand( + draft, + p, + pool.Slots.Select(s => s.ScheduledAt).ToList(), + MaxPlayersForPool(pool), + isOneShot: false), + }; + } + return new List + { + BuildCommand( + draft, + p, + new[] { p.Single?.ScheduledAt ?? default }, + p.Single?.MaxPlayers ?? 0, + isOneShot: true), + }; + } + + private static int MaxPlayersForPool(WizardPoolInput pool) => + pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers); + + private static CreateSessionCommand BuildCommand( + WizardDraft draft, + WizardPayload p, + IReadOnlyList scheduledTimes, + int maxPlayers, + bool isOneShot) + { + var user = new PlatformUser( + PlatformKind.Discord, + draft.OwnerId, + DisplayName: string.Empty, + ExternalUsername: null); + var group = new PlatformGroup( + PlatformKind.Discord, + draft.ChatId, + DisplayName: string.Empty, + ExternalChannelId: null, + ExternalThreadId: draft.MessageThreadId); + return new CreateSessionCommand( + User: user, + Group: group, + Title: p.Title ?? string.Empty, + Link: string.Empty, + ScheduledTimes: scheduledTimes, + MaxPlayers: maxPlayers, + ImageReference: p.ImageFileId ?? p.ImageUrl, + System: ParseSystem(p.System), + Description: p.Description, + Format: null, + DurationMinutes: p.DurationMinutes, + IsOneShot: isOneShot); + } + + private static GameSystem? ParseSystem(string? code) + { + if (string.IsNullOrWhiteSpace(code)) return null; + return Enum.TryParse(code, ignoreCase: true, out var sys) ? sys : null; + } + + // ── Validation ─────────────────────────────────────────────────── + private static bool IsComplete(WizardPayload p, out string missing) + { + var missingFields = new List(); + if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название"); + if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система"); + if (!p.DurationMinutes.HasValue) missingFields.Add("длительность"); + if (p.Visibility is null) missingFields.Add("видимость"); + + if (p.Type == WizardCreationType.Single) + { + if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время"); + if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест"); + } + else + { + if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты"); + } + missing = string.Join(", ", missingFields); + return missingFields.Count == 0; + } + + // ── Payload I/O ────────────────────────────────────────────────── + private static WizardPayload LoadPayload(WizardDraft draft) + { + if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload(); + return System.Text.Json.JsonSerializer.Deserialize( + draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload(); + } + + private static void SavePayload(WizardDraft draft, WizardPayload p) + { + draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize( + p, WizardPayloadJsonContext.Default.WizardPayload); + } + + // ── Embed editing ──────────────────────────────────────────────── + private async Task EditDraftMessageAsync( + WizardDraft draft, string text, IReadOnlyList actions, CancellationToken ct) + { + if (!_contextStore.TryGet(draft.Id, out var ctx)) + { + return; + } + if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) || + !ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)) + { + return; + } + try + { + var embed = new EmbedProperties() + .WithTitle("Мастер создания сессии") + .WithDescription(Truncate(text, 3900)) + .WithColor(new Color(0x5865F2)); + var rows = BuildActionRowsFromActions(actions); + await _rest.ModifyMessageAsync( + channelId, + messageId, + options => + { + options.Embeds = new[] { embed }; + options.Components = rows; + }); + } + catch (RestException ex) + { + _log.LogWarning(ex, "Failed to edit wizard message for draft {DraftId}", draft.Id); + } + } + + private static IReadOnlyList BuildActionRowsFromActions( + IReadOnlyList actions) + { + if (actions.Count == 0) + { + return Array.Empty(); + } + var rows = new List(); + foreach (var chunk in actions.Chunk(5)) + { + var row = new ActionRowProperties(); + foreach (var action in chunk) + { + var style = action.Style switch + { + WizardActionStyle.Primary => ButtonStyle.Primary, + WizardActionStyle.Success => ButtonStyle.Success, + WizardActionStyle.Danger => ButtonStyle.Danger, + _ => ButtonStyle.Secondary, + }; + row.Add(new ButtonProperties(action.Payload, action.Label, style)); + } + rows.Add(row); + } + return rows; + } + + private static string Truncate(string text, int max) => + text.Length <= max ? text : text[..max]; + + private static IReadOnlyList RetryCancelActions() => new[] + { + new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary), + new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }; +} diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 28a00f4..7989a7b 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -1,5 +1,6 @@ using GmRelay.DiscordBot; using GmRelay.DiscordBot.Features.Sessions; +using GmRelay.DiscordBot.Features.Sessions.Wizard; using GmRelay.DiscordBot.Infrastructure; using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Health; @@ -10,6 +11,7 @@ using GmRelay.Shared.Features.Notifications; using GmRelay.Shared.Features.Reminders.SendJoinLink; using GmRelay.Shared.Features.Reminders.SendOneHourReminder; using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Infrastructure.Scheduling; using GmRelay.Shared.Platform; @@ -82,6 +84,23 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +// ── Wizard services (issue #112) ────────────────────────────────────── +// The Discord wizard reuses the platform-neutral state machine in +// GmRelay.Shared (GameCreationWizard, IWizardMessenger, +// IWizardDraftRepository) and only adds a Discord-specific messenger, +// step renderer, slash command, and submitter on top. The wizard's +// cleanup service is shared with the Telegram bot and is not +// registered here — it would compete on the same drafts table. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services .AddDiscordGateway(options => { @@ -90,6 +109,8 @@ builder.Services }) .AddApplicationCommands() .AddComponentInteractions() + .AddComponentInteractions() + .AddComponentInteractions() .AddGatewayHandlers(typeof(Program).Assembly); var host = builder.Build(); diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs similarity index 79% rename from src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs rename to src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs index dc870f1..6f88dfd 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -3,25 +3,27 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using GmRelay.Shared.Domain; -using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging; -using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; /// -/// Central state machine for the game/pool creation wizard. +/// Central state machine for the game/pool creation wizard. Lives in +/// GmRelay.Shared so it can be driven from any platform +/// messenger. Platform-specific code (Telegram.Bot, +/// NetCord, …) lives in the corresponding adapter and converts +/// its native update type into a before +/// calling . /// public sealed class GameCreationWizard { private readonly IWizardDraftRepository _drafts; - private readonly ITelegramWizardMessenger _messenger; + private readonly IWizardMessenger _messenger; private readonly ILogger _log; public GameCreationWizard( IWizardDraftRepository drafts, - ITelegramWizardMessenger messenger, + IWizardMessenger messenger, ILogger log) { _drafts = drafts; @@ -29,44 +31,61 @@ public sealed class GameCreationWizard _log = log; } - /// Handle a text or callback update from the owning GM. - public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct) + /// + /// Handle a single user interaction with the wizard. Adapters should + /// map their native event (Telegram Update, Discord + /// interaction, …) into a first. + /// + public async Task HandleInteractionAsync( + WizardInteraction interaction, + WizardDraft draft, + CancellationToken ct) { try { - if (update.CallbackQuery is { } cb) + if (interaction.CallbackPayload is not null) { - await HandleCallbackAsync(draft, cb, ct); + await HandleCallbackAsync(draft, interaction, ct); } - else if (update.Message is { } msg) + else { - await HandleTextAsync(draft, msg, ct); + await HandleTextAsync(draft, interaction, ct); } } catch (WizardStorageException) { - // Surface storage failure; do not crash the update loop. - if (update.CallbackQuery is { } cb2) + if (interaction.CallbackPayload is not null) { - await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct); + await _messenger.AnswerInteractionAsync( + interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct); } } catch (Exception ex) { - _log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id); - if (update.CallbackQuery is { } cb3) + _log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id); + if (interaction.CallbackPayload is not null) { - try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); } - catch { /* swallow — we're already in error path */ } + try + { + await _messenger.AnswerInteractionAsync( + interaction.InteractionId, "⚠️ Ошибка", ct); + } + catch + { + /* swallow — we're already in error path */ + } } } } - private async Task HandleCallbackAsync(WizardDraft draft, CallbackQuery cb, CancellationToken ct) + private async Task HandleCallbackAsync( + WizardDraft draft, + WizardInteraction interaction, + CancellationToken ct) { - if (!WizardCallbackData.TryParse(cb.Data, out var action, out var step, out var choice)) + if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice)) { - await _messenger.AnswerCallbackAsync(cb.Id, "Неизвестная команда", ct); + await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct); return; } @@ -74,37 +93,39 @@ public sealed class GameCreationWizard { case "cancel": await _drafts.DeleteAsync(draft.Id, ct); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, - "❌ Мастер отменён.", EmptyKeyboard, ct); - await _messenger.AnswerCallbackAsync(cb.Id, null, ct); + await _messenger.EditDraftMessageAsync( + draft, "❌ Мастер отменён.", Array.Empty(), ct); + await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct); return; case "back": ApplyBack(draft, step); - await PersistAndRenderAsync(draft, cb.Id, ct); + await PersistAndRenderAsync(draft, interaction.InteractionId, ct); return; case "create": - // Routed by CreateSessionHandler, not here. - await _messenger.AnswerCallbackAsync(cb.Id, null, ct); + // Routed by the platform's CreateSessionHandler, not here. + await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct); return; default: // For "Choice" callbacks, action == step. - await ApplyChoiceAsync(draft, step, choice, cb.Id, ct); + await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct); return; } } - private async Task HandleTextAsync(WizardDraft draft, Message msg, CancellationToken ct) + private async Task HandleTextAsync( + WizardDraft draft, + WizardInteraction interaction, + CancellationToken ct) { - if (msg.Text is not { } text) + if (interaction.Text is not { } text) { // Photo or other non-text — handle cover step only. - if (msg.Photo is { Length: > 0 } && draft.Step == WizardStepNames.Cover) + if (interaction.PhotoFileId is { } fileId && + draft.Step == WizardStepNames.Cover) { - var fileId = msg.Photo[^1].FileId; ApplyCoverPhoto(draft, fileId); await PersistAndRenderAsync(draft, null, ct); } @@ -113,13 +134,12 @@ public sealed class GameCreationWizard var (nextStep, error, payload) = ApplyText(draft, text); if (payload is { } p) SavePayload(draft, p); - if (error is { } errMsg && draft.DraftMessageId is { } mid) + if (error is { } errMsg) { // Re-render the same step with ⚠️ prefix. - var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, mid, - "⚠️ " + errMsg + "\n\n" + rendered, kb, ct); + var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft)); + await _messenger.EditDraftMessageAsync( + draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct); return; } @@ -130,12 +150,13 @@ public sealed class GameCreationWizard await PersistAndRenderAsync(draft, null, ct); } - private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct) + private async Task ApplyChoiceAsync( + WizardDraft draft, string step, string choice, string interactionId, CancellationToken ct) { var (nextStep, error, payload) = ApplyChoice(draft, step, choice); if (error is { } err) { - await _messenger.AnswerCallbackAsync(callbackId, err, ct); + await _messenger.AnswerInteractionAsync(interactionId, err, ct); return; } if (payload is { } p) SavePayload(draft, p); @@ -143,10 +164,10 @@ public sealed class GameCreationWizard { draft.Step = s; } - await PersistAndRenderAsync(draft, callbackId, ct); + await PersistAndRenderAsync(draft, interactionId, ct); } - private async Task PersistAndRenderAsync(WizardDraft draft, string? callbackId, CancellationToken ct) + private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct) { draft.UpdatedAt = DateTimeOffset.UtcNow; await _drafts.UpsertAsync(draft, ct); @@ -154,15 +175,13 @@ public sealed class GameCreationWizard IReadOnlyList? clubs = null; if (draft.Step == WizardStepNames.PickClub) { - clubs = await _messenger.GetGmClubsAsync(draft.OwnerTelegramId, ct); + clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct); } - var (text, kb) = WizardStep.Render(draft, payload, clubs); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, - text, kb, ct); - if (callbackId is { } id) + var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs); + await _messenger.EditDraftMessageAsync(draft, text, actions, ct); + if (interactionId is { } id) { - await _messenger.AnswerCallbackAsync(id, null, ct); + await _messenger.AnswerInteractionAsync(id, null, ct); } } @@ -173,13 +192,13 @@ public sealed class GameCreationWizard switch (draft.Step) { case WizardStepNames.Title: - return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title) + return ValidateText(input, WizardStepLimits.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title) ? (WizardStepNames.Description, SetTitle(payload, title), payload) : (null, title, payload); case WizardStepNames.Description: if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload); - return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc) + return ValidateText(input, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc) ? (WizardStepNames.Cover, SetDescription(payload, desc), payload) : (null, desc, payload); @@ -191,7 +210,7 @@ public sealed class GameCreationWizard case WizardStepNames.System when payload.System is null: // "Other" branch — only active if free-text was offered. - return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys) + return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys) ? (WizardStepNames.Duration, SetSystem(payload, sys), payload) : (null, sys, payload); @@ -206,12 +225,12 @@ public sealed class GameCreationWizard : (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: - return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity + return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity ? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload) : (null, "Лимит должен быть 1..50", payload); case WizardStepNames.PoolSystemDuration when payload.System is null: - return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) + return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload) : (null, psys, payload); @@ -226,7 +245,7 @@ public sealed class GameCreationWizard : (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.PoolSlotCapacity: - return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity + return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity ? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload) : (null, "Лимит должен быть 1..50", payload); @@ -367,7 +386,7 @@ public sealed class GameCreationWizard }; // ── Payload I/O ─────────────────────────────────────────────────── - internal static WizardPayload LoadPayload(WizardDraft draft) + public static WizardPayload LoadPayload(WizardDraft draft) { if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload(); return System.Text.Json.JsonSerializer.Deserialize( @@ -495,10 +514,8 @@ public sealed class GameCreationWizard if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false; - if (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false; + if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false; minutes = (int)Math.Round(hours * 60); return true; } - - private static readonly InlineKeyboardMarkup EmptyKeyboard = new(Array.Empty()); } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs deleted file mode 100644 index a3aa7bd..0000000 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; - -/// -/// Storage contract for wizard drafts. Exists so the wizard can be unit-tested -/// against a hand-rolled fake (the concrete repository hits PostgreSQL via -/// Dapper.AOT and is therefore unsuitable for fast in-process tests). -/// -public interface IWizardDraftRepository -{ - Task GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct); - - Task UpsertAsync(WizardDraft draft, CancellationToken ct); - - Task DeleteAsync(Guid id, CancellationToken ct); - - Task DeleteExpiredAsync(CancellationToken ct); -} diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs new file mode 100644 index 0000000..3ffb709 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// Visual style for a wizard button. The platform adapter maps this to its +/// own native styling (Telegram currently ignores it; Discord uses it for +/// primary/danger/success button colors). +/// +public enum WizardActionStyle +{ + Primary, + Secondary, + Success, + Danger, +} + +/// +/// A single button on a wizard keyboard. is the +/// platform-neutral callback token — usually produced by +/// but adapters are free to interpret +/// any string. +/// +public sealed record WizardAction( + string Label, + string Payload, + WizardActionStyle Style = WizardActionStyle.Secondary); + +/// +/// One row of buttons on a wizard keyboard. The platform adapter is +/// responsible for laying out rows; the wizard core returns a flat list +/// of actions and trusts the adapter to split them into rows. +/// +public sealed record WizardKeyboard(IReadOnlyList Actions); + +/// +/// A user-owned group/club selectable from the visibility step. Moved +/// from GmRelay.Bot so the wizard can ask for the list without +/// taking a dependency on Telegram. +/// +public sealed record WizardClubOption(Guid ClubId, string Name); + +/// +/// Platform-neutral user interaction with the wizard. Adapters convert +/// their native event (Telegram Update, Discord interaction, …) +/// into one of these before handing it to . +/// +public sealed record WizardInteraction( + string OwnerId, + string? Text, + string? CallbackPayload, + string? PhotoFileId, + string? PhotoUrl, + string InteractionId); + +/// +/// Storage contract for wizard drafts. Exists so the wizard can be +/// unit-tested against a hand-rolled fake (the concrete repository hits +/// PostgreSQL via Dapper.AOT and is therefore unsuitable for fast +/// in-process tests). +/// +public interface IWizardDraftRepository +{ + Task GetActiveAsync(string platform, string ownerId, CancellationToken ct); + + Task UpsertAsync(WizardDraft draft, CancellationToken ct); + + Task DeleteAsync(Guid id, CancellationToken ct); + + Task DeleteExpiredAsync(CancellationToken ct); +} + +/// +/// Contract the wizard core uses to talk to the chat platform. Each +/// platform supplies its own implementation (Telegram today, Discord in +/// a follow-up task). +/// +public interface IWizardMessenger +{ + /// + /// Edit the message that currently represents the wizard draft. + /// Returns the new message id as a string — Telegram exposes + /// int32, Discord uses 64-bit snowflakes, both fit in + /// for cross-platform uniformity. + /// + Task EditDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct); + + /// + /// Post a fresh wizard draft message and return its id. + /// + Task SendDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct); + + /// + /// Acknowledge a callback / interaction. + /// is an optional toast the user sees briefly. + /// + Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct); + + /// + /// List the clubs/groups the owner manages. The platform + /// implementation decides how to query the database — the wizard + /// core only needs a list of (id, name) pairs. + /// + Task> GetOwnerClubsAsync(string ownerId, CancellationToken ct); +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs similarity index 66% rename from src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs rename to src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs index fb208ed..8e1d9b7 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs @@ -1,14 +1,24 @@ using System; -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +/// +/// Wire format for wizard callback data. The format is shared by all +/// platforms (Telegram today, Discord in a follow-up task) and must +/// stay stable because it is persisted in chat histories and slash-command +/// autocomplete. Token is wizard to keep the namespace separate +/// from the rest of the bot's command callbacks. +/// public static class WizardCallbackData { public const string Prefix = "wizard"; public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}"; + public static string Back() => $"{Prefix}:back"; + public static string Cancel() => $"{Prefix}:cancel"; + public static string Create() => $"{Prefix}:create"; public static bool TryParse(string? data, out string action, out string step, out string choice) diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs index 9125db6..4c79b29 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs @@ -5,13 +5,47 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; public sealed class WizardDraft { public Guid Id { get; set; } - public long ChatId { get; set; } - public int? MessageThreadId { get; set; } - public long OwnerTelegramId { get; set; } + + /// + /// Stable string id of the chat/guild/channel this draft lives in. + /// Stored as TEXT to fit both Telegram's long chat ids + /// and Discord's snowflakes. + /// + public string ChatId { get; set; } = string.Empty; + + /// + /// Optional thread/topic id within the chat. Telegram's + /// message_thread_id, Discord's thread snowflake, null + /// when the chat has no sub-thread concept. + /// + public string? MessageThreadId { get; set; } + + /// + /// Platform-specific user id of the wizard owner. Telegram uses + /// long, Discord uses snowflakes — both fit in a string. + /// + public string OwnerId { get; set; } = string.Empty; + + /// + /// Which messenger platform owns this draft. Defaults to + /// "Telegram" for backward compatibility with pre-V032 rows. + /// + public string Platform { get; set; } = "Telegram"; + public string Step { get; set; } = string.Empty; + public string PayloadJson { get; set; } = "{}"; - public long? DraftMessageId { get; set; } + + /// + /// Id of the message that the wizard last edited. Stored as + /// TEXT to fit both Telegram's int32 ids and Discord's + /// 64-bit snowflakes. + /// + public string? DraftMessageId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs index 3f2a592..20b777d 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs @@ -8,14 +8,14 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository { - public async Task GetActiveAsync( - long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct) + public async Task GetActiveAsync(string platform, string ownerId, CancellationToken ct) { const string sql = """ SELECT id AS Id, chat_id AS ChatId, message_thread_id AS MessageThreadId, - owner_telegram_id AS OwnerTelegramId, + owner_id AS OwnerId, + platform AS Platform, step AS Step, payload::text AS PayloadJson, draft_message_id AS DraftMessageId, @@ -23,17 +23,18 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard updated_at AS UpdatedAt, expires_at AS ExpiresAt FROM wizard_drafts - WHERE chat_id = @ChatId - AND (message_thread_id = @ThreadId OR (@ThreadId IS NULL AND message_thread_id IS NULL)) - AND owner_telegram_id = @OwnerId + WHERE platform = @Platform + AND owner_id = @OwnerId AND expires_at > NOW() + ORDER BY updated_at DESC LIMIT 1 """; await using var connection = await dataSource.OpenConnectionAsync(ct); return await connection.QuerySingleOrDefaultAsync( - new CommandDefinition(sql, - new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId }, + new CommandDefinition( + sql, + new { Platform = platform, OwnerId = ownerId }, cancellationToken: ct)); } @@ -41,9 +42,9 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard { const string sql = """ INSERT INTO wizard_drafts - (id, chat_id, message_thread_id, owner_telegram_id, step, payload, draft_message_id, created_at, updated_at, expires_at) + (id, chat_id, message_thread_id, owner_id, platform, step, payload, draft_message_id, created_at, updated_at, expires_at) VALUES - (@Id, @ChatId, @MessageThreadId, @OwnerTelegramId, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt) + (@Id, @ChatId, @MessageThreadId, @OwnerId, @Platform, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt) ON CONFLICT (id) DO UPDATE SET step = EXCLUDED.step, payload = EXCLUDED.payload, diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs new file mode 100644 index 0000000..2cacb2e --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs @@ -0,0 +1,17 @@ +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// Limits and bounds used by the wizard's input validation. Kept here +/// (rather than on the Telegram-only WizardStep) so the state +/// machine can reference them without pulling in a platform dependency. +/// +public static class WizardStepLimits +{ + public const int MaxTitleLength = 200; + public const int MaxDescriptionLength = 4000; + public const int MaxSystemLength = 100; + public const int MaxCapacity = 50; + public const int MinCapacity = 1; + public const int MinDurationHours = 1; + public const int MaxDurationHours = 12; +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs similarity index 71% rename from src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs rename to src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs index 200fdc2..725e6ac 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs @@ -1,5 +1,11 @@ -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +/// +/// Symbolic step identifiers used by and +/// the payload. Strings (rather than an +/// enum) so that future platforms can extend the set without breaking +/// the wire format stored in PostgreSQL. +/// public static class WizardStepNames { public const string Type = "Type"; diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs new file mode 100644 index 0000000..fac6fae --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Text; +using GmRelay.Shared.Domain; + +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// Produces a (text, list of s) pair for each +/// wizard step. This is the "view builder" half of ADR-002: the same +/// builder is used by every platform messenger, and each messenger is +/// responsible for converting the action list into its native UI +/// (Telegram's InlineKeyboardMarkup today, Discord components +/// later). +/// +public static class WizardStepViewBuilder +{ + public static (string Text, IReadOnlyList Actions) Build( + WizardDraft draft, + WizardPayload payload, + IReadOnlyList? clubs = null) + { + return draft.Step switch + { + WizardStepNames.Type => BuildType(), + WizardStepNames.Title => BuildTitle(), + WizardStepNames.Description => BuildDescription(), + WizardStepNames.Cover => BuildCover(), + WizardStepNames.System => BuildSystem(), + WizardStepNames.Duration => BuildDuration(), + WizardStepNames.DateTime => BuildDateTime(), + WizardStepNames.Capacity => BuildCapacity(), + WizardStepNames.Visibility => BuildVisibility(), + WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty()), + WizardStepNames.Publish => BuildPublish(), + WizardStepNames.Confirm => BuildSingleConfirm(payload), + + WizardStepNames.PoolSystemDuration => BuildPoolSystemDuration(), + WizardStepNames.PoolAddSlots => BuildPoolAddSlots(payload), + WizardStepNames.PoolSlotDateTime => BuildPoolSlotDateTime(), + WizardStepNames.PoolSlotCapacity => BuildPoolSlotCapacity(), + WizardStepNames.PoolConfirm => BuildPoolConfirm(payload), + + _ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"), + }; + } + + // ── Single-game views ────────────────────────────────────────────── + private static (string, IReadOnlyList) BuildType() => ( + "🎲 Создание новой игровой сессии\n\nЧто создаём?", + new[] + { + new WizardAction("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single"), WizardActionStyle.Primary), + new WizardAction("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool"), WizardActionStyle.Primary), + new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }); + + private static (string, IReadOnlyList) BuildTitle() => ( + "📝 Введите название игры одним сообщением.", + BackCancel()); + + private static (string, IReadOnlyList) BuildDescription() => ( + "📄 Введите описание (или «-», чтобы пропустить).", + SkipBackCancel()); + + private static (string, IReadOnlyList) BuildCover() => ( + "🖼 Пришлите картинку как вложение или URL (или «-»).", + SkipBackCancel()); + + private static (string, IReadOnlyList) BuildSystem() => ( + "🎲 Выберите систему.", + new List + { + new("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")), + new("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")), + new("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")), + new("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")), + new("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")), + new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")), + new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")), + }); + + private static (string, IReadOnlyList) BuildDuration() => ( + "⏱ Выберите длительность.", + new List + { + new("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")), + new("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")), + new("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")), + new("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")), + new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")), + new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")), + }); + + private static (string, IReadOnlyList) BuildDateTime() => ( + "📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).", + BackCancel()); + + private static (string, IReadOnlyList) BuildCapacity() => ( + "👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.", + new List + { + new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success), + new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger), + }); + + private static (string, IReadOnlyList) BuildVisibility() => ( + "🔒 Выберите видимость.", + new List + { + new("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public"), WizardActionStyle.Primary), + new("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club"), WizardActionStyle.Primary), + new("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")), + new("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")), + }); + + private static (string, IReadOnlyList) BuildPickClub(IReadOnlyList clubs) + { + if (clubs.Count == 0) + { + return ( + "🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.", + BackCancel()); + } + var actions = new List(clubs.Count); + foreach (var club in clubs) + { + actions.Add(new WizardAction( + club.Name, + WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString()))); + } + return ("🏷 Выберите клуб:", actions); + } + + private static (string, IReadOnlyList) BuildPublish() => ( + "✨ Опубликовать в витрине сейчас?", + new List + { + new("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes"), WizardActionStyle.Success), + new("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")), + }); + + private static (string, IReadOnlyList) BuildSingleConfirm(WizardPayload p) + { + var sb = new StringBuilder(); + sb.AppendLine("👀 Проверьте перед созданием:"); + sb.AppendLine(); + sb.AppendLine($"🎲 {p.Title}"); + if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); + if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); + if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); + if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)"); + if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}"); + sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); + return ( + sb.ToString(), + new List + { + new("✅ Создать", WizardCallbackData.Create(), WizardActionStyle.Success), + new("⬅️ Назад", WizardCallbackData.Back()), + new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }); + } + + // ── Pool views ───────────────────────────────────────────────────── + private static (string, IReadOnlyList) BuildPoolSystemDuration() => ( + "🎲 Выберите систему и длительность пула.", + new List + { + new("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")), + new("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")), + new("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")), + new("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")), + new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")), + }); + + private static (string, IReadOnlyList) BuildPoolAddSlots(WizardPayload p) => ( + $"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}", + new List + { + new("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), WizardActionStyle.Primary), + new("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), WizardActionStyle.Success), + }); + + private static (string, IReadOnlyList) BuildPoolSlotDateTime() => ( + "📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).", + BackCancel()); + + private static (string, IReadOnlyList) BuildPoolSlotCapacity() => ( + "👥 Введите лимит мест (1..50) и выберите waitlist.", + new List + { + new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"), WizardActionStyle.Success), + new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"), WizardActionStyle.Danger), + }); + + private static (string, IReadOnlyList) BuildPoolConfirm(WizardPayload p) + { + var sb = new StringBuilder(); + sb.AppendLine("👀 Проверьте пул перед созданием:"); + sb.AppendLine(); + sb.AppendLine($"📝 {p.Title}"); + if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); + if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); + if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); + sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); + sb.AppendLine(); + sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):"); + if (p.Pool is not null) + { + foreach (var s in p.Pool.Slots) + { + sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}"); + } + } + return ( + sb.ToString(), + new List + { + new("✅ Создать пул", WizardCallbackData.Create(), WizardActionStyle.Success), + new("⬅️ Назад", WizardCallbackData.Back()), + new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }); + } + + // ── Helpers ──────────────────────────────────────────────────────── + private static IReadOnlyList BackCancel() => new[] + { + new WizardAction("⬅️ Назад", WizardCallbackData.Back()), + new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }; + + private static IReadOnlyList SkipBackCancel() => new[] + { + new WizardAction("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")), + new WizardAction("⬅️ Назад", WizardCallbackData.Back()), + new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }; + + private static string RenderVisibilityText(WizardVisibility? v) => v switch + { + WizardVisibility.Public => "публичная в общем showcase", + WizardVisibility.Club => "публичная в витрине клуба", + WizardVisibility.Members => "только для членов клуба", + _ => "не задана", + }; +} diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs new file mode 100644 index 0000000..46baf22 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs @@ -0,0 +1,16 @@ +using System; + +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// Raised when the wizard's persistence layer fails. The wizard catches +/// this specifically so the user sees a friendly message instead of a +/// raw stack trace. +/// +public sealed class WizardStorageException : Exception +{ + public WizardStorageException(string message, Exception inner) + : base(message, inner) + { + } +} diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 4ec7bd2..10142ed 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -82,7 +82,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs new file mode 100644 index 0000000..c77dd27 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs @@ -0,0 +1,279 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GmRelay.DiscordBot.Features.Sessions.Wizard; + +namespace GmRelay.Bot.Tests.Discord; + +/// +/// 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. +/// +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 for each + // supported component type. + Assert.Contains("ComponentInteractionModule", source, StringComparison.Ordinal); + Assert.Contains("ComponentInteractionModule", source, StringComparison.Ordinal); + Assert.Contains("ComponentInteractionModule", 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 + // per supported interaction type. The wizard needs all three: + // buttons, StringSelectMenus, and modal submits. + Assert.Contains("AddComponentInteractions", source, StringComparison.Ordinal); + Assert.Contains("AddComponentInteractions", source, StringComparison.Ordinal); + Assert.Contains("AddComponentInteractions", 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); + } + + /// + /// Roundtrip the renderer output through the dispatcher's parser to + /// prove the wire formats agree. This is a real behavioural test + /// (not a string-grep) — it actually constructs the ButtonProperties + /// that NetCord would send, strips the [ComponentInteraction("wizard")] + /// prefix exactly as NetCord does, and asserts the dispatcher's + /// switch would route the click to the right branch. Catches the + /// class of "renderer and dispatcher disagree on the wire format" + /// regressions that the string-grep tests above cannot detect. + /// + [Fact] + public void Renderer_And_Dispatcher_Agree_On_Wire_Format() + { + // Choice button: dispatcher expects `btn:choice::`. + var choice = DiscordWizardStep.ChoiceButtonCustomId("Type", "single"); + Assert.Equal("wizard:btn:choice:Type:single", choice); + var choiceArgs = StripWizardPrefix(choice); + var choiceParts = choiceArgs.Split(':', 4); + Assert.Equal("btn", choiceParts[0]); + Assert.Equal("choice", choiceParts[1]); + Assert.Equal("Type", choiceParts[2]); + Assert.Equal("single", choiceParts[3]); + + // Control button: dispatcher expects `btn::1`. + var cancel = DiscordWizardStep.ControlButtonCustomId("cancel"); + Assert.Equal("wizard:btn:cancel:1", cancel); + var cancelArgs = StripWizardPrefix(cancel); + var cancelParts = cancelArgs.Split(':', 3); + Assert.Equal("btn", cancelParts[0]); + Assert.Equal("cancel", cancelParts[1]); + + // Modal trigger: dispatcher expects `btn:modal:`. + var modal = DiscordWizardStep.ModalTriggerButtonCustomId("SystemFreeText"); + Assert.Equal("wizard:btn:modal:SystemFreeText", modal); + var modalArgs = StripWizardPrefix(modal); + var modalParts = modalArgs.Split(':', 3); + Assert.Equal("btn", modalParts[0]); + Assert.Equal("modal", modalParts[1]); + Assert.Equal("SystemFreeText", modalParts[2]); + + // All customIds must fit Discord's 100-char limit. + Assert.All( + new[] { choice, cancel, modal }, + cid => Assert.True( + cid.Length <= DiscordWizardStep.MaxCustomIdLength, + $"CustomId '{cid}' exceeds 100 chars: {cid.Length}")); + } + + /// + /// The Create/Back/Cancel/Resume control buttons in the renderer + /// (and in BuildResumeRow) must emit the format the dispatcher's + /// switch matches directly — NOT the choice-button format. This + /// test parses every button's customId and asserts the dispatcher + /// would route it to the right branch. + /// + [Fact] + public void ControlButtons_Are_Parsed_As_Control_Not_Choice() + { + // Real customIds the renderer / BuildResumeRow emit for control actions. + var controlIds = new[] + { + DiscordWizardStep.ControlButtonCustomId("back"), + DiscordWizardStep.ControlButtonCustomId("cancel"), + "wizard:btn:create:1", + "wizard:btn:resume:continue", + "wizard:btn:resume:restart", + }; + + foreach (var cid in controlIds) + { + var parts = StripWizardPrefix(cid).Split(':', 3); + Assert.Equal("btn", parts[0]); + // The dispatcher's switch matches these as parts[1] == "back"|"cancel"|"create"|"resume". + // They must NOT be tagged as "choice" (that would route through the wizard + // with a nonsensical step name). + Assert.NotEqual("choice", parts[1]); + } + } + + /// Mirror NetCord's [ComponentInteraction("wizard")] prefix strip. + private static string StripWizardPrefix(string customId) + { + const string prefix = "wizard:"; + return customId.StartsWith(prefix, StringComparison.Ordinal) ? customId[prefix.Length..] : customId; + } + + 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; + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs index ca40308..872d4ae 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs @@ -2,7 +2,6 @@ using System; using System.Threading; using System.Threading.Tasks; using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging.Abstractions; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -39,7 +38,7 @@ public sealed class CreateSessionHandlerSubmitMissingFieldsTests // The wizard message is edited to surface the missing-field error. Assert.Single(messenger.Edits); var edit = messenger.Edits[0]; - Assert.Equal(draft.ChatId, edit.ChatId); + Assert.Equal(long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture), edit.ChatId); Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase); } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs index ea75164..134a935 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs @@ -2,7 +2,6 @@ using System; using System.Threading; using System.Threading.Tasks; using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging.Abstractions; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs index 1f8d3a4..428ace6 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs @@ -1,5 +1,4 @@ using System; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -20,7 +19,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Cancel(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Contains(draft.Id, drafts.DeletedIds); Assert.Single(messenger.Edits); @@ -36,7 +35,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); // Title is the first step, so Back is a no-op. Assert.Equal(WizardStepNames.Title, draft.Step); @@ -51,7 +50,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Title, draft.Step); } @@ -65,7 +64,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Description, draft.Step); } @@ -79,7 +78,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Cover, draft.Step); } @@ -93,7 +92,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step); } @@ -108,7 +107,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Create(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Confirm, draft.Step); Assert.Contains("cb-1", messenger.AnsweredCallbacks); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs index 5d10078..6ec571b 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs @@ -1,5 +1,4 @@ using System; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -7,8 +6,8 @@ using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTest namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; /// -/// Verifies the pool-specific branch of the wizard: the AddSlots flow that -/// builds up slot metadata through date and capacity steps. +/// Verifies the pool-specific branch of the wizard: the AddSlots flow +/// that builds up slot metadata through date and capacity steps. /// public sealed class GameCreationWizardPoolSlotTests { @@ -28,7 +27,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"); - await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(addData, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step); } @@ -49,7 +48,7 @@ public sealed class GameCreationWizardPoolSlotTests var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow(); var dtString = future.ToString("dd.MM.yyyy HH:mm"); - await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(dtString, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step); } @@ -61,7 +60,7 @@ public sealed class GameCreationWizardPoolSlotTests var draft = NewDraft(WizardStepNames.PoolSlotDateTime); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step); } @@ -74,7 +73,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"); - await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(noWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); } @@ -87,7 +86,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"); - await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(yesWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); } @@ -107,7 +106,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); } @@ -132,7 +131,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolConfirm, draft.Step); } @@ -150,10 +149,10 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); // "add" then "done" — no date/capacity supplied in between. - await wizard.HandleUpdateAsync(CallbackUpdate( - WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None); - await wizard.HandleUpdateAsync(CallbackUpdate( - WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction( + WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), ownerId: draft.OwnerId), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction( + WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), ownerId: draft.OwnerId), draft, CancellationToken.None); // The wizard sees the in-memory slot count > 0 and advances to confirm. Assert.Equal(WizardStepNames.PoolConfirm, draft.Step); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs index a6a69cb..b9c1ef5 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs @@ -1,6 +1,5 @@ using System; using System.Text.Json; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -41,7 +40,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(fromStep, choice); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(expectedStep, draft.Step); Assert.NotEmpty(drafts.Upserts); // was persisted @@ -60,7 +59,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Visibility, draft.Step); using var doc = JsonDocument.Parse(draft.PayloadJson); @@ -84,7 +83,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Visibility, draft.Step); } @@ -110,7 +109,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString()); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); // Wizard acknowledged the callback and re-rendered the (still PickClub) step. Assert.NotEmpty(messenger.Edits); @@ -132,7 +131,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PickClub, draft.Step); } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs index cd58ea7..d33ec9f 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs @@ -1,6 +1,5 @@ using System; using System.Text.Json; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -20,7 +19,7 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.Title); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Title, draft.Step); } @@ -32,8 +31,8 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.Title); drafts.Seed(draft); - var tooLong = new string('a', WizardStep.MaxTitleLength + 1); - await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None); + var tooLong = new string('a', WizardStepLimits.MaxTitleLength + 1); + await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Title, draft.Step); } @@ -53,7 +52,7 @@ public sealed class GameCreationWizardValidationTests drafts.Seed(draft); // 2020-01-01 is firmly in the past - await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.DateTime, draft.Step); } @@ -65,7 +64,7 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.DateTime); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.DateTime, draft.Step); } @@ -83,7 +82,7 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.Cover, payload); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Cover, draft.Step); } @@ -96,7 +95,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.System, draft.Step); } @@ -109,7 +108,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.System, draft.Step); } @@ -132,7 +131,7 @@ public sealed class GameCreationWizardValidationTests }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Capacity, draft.Step); } @@ -148,7 +147,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Duration, draft.Step); } @@ -161,7 +160,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Cover, draft.Step); } @@ -178,7 +177,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Duration, draft.Step); } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs index 108465b..b223b4d 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs @@ -1,7 +1,7 @@ using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Bot.Infrastructure.Telegram; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -30,7 +30,7 @@ public sealed class UpdateRouterDelegationTests var draft = NewDraft(WizardStepNames.Title); drafts.Seed(draft); - var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId); + var update = TextUpdate("Curse of Strahd", ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture)); await sut.RouteAsync(update, CancellationToken.None); @@ -49,7 +49,7 @@ public sealed class UpdateRouterDelegationTests // "wizard:cancel" — wizard owns the cancel callback. The router // delegates control-callbacks (resume/reset) but lets the wizard // handle wizard:* callbacks. - var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId); + var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture)); await sut.RouteAsync(update, CancellationToken.None); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs index 2ca8e91..22c2239 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs @@ -1,7 +1,7 @@ using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Bot.Infrastructure.Telegram; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -36,8 +36,8 @@ public sealed class UpdateRouterResetsDraftOnStaleCommandTests Message = new Message { Text = "/newsession", - Chat = new Chat { Id = draft.ChatId }, - From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" }, + Chat = new Chat { Id = long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture) }, + From = new User { Id = long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture), FirstName = "GM" }, }, }; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs index 9634262..9b6a8ac 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs @@ -49,22 +49,23 @@ public sealed class WizardDraftRepositoryFixture : IAsyncLifetime """ CREATE TABLE wizard_drafts ( id UUID PRIMARY KEY, - chat_id BIGINT NOT NULL, - message_thread_id INT, - owner_telegram_id BIGINT NOT NULL, + chat_id TEXT NOT NULL, + message_thread_id TEXT, + owner_id TEXT NOT NULL, + platform TEXT NOT NULL DEFAULT 'Telegram', step TEXT NOT NULL, payload JSONB NOT NULL, - draft_message_id BIGINT, + draft_message_id TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_wizard_drafts_owner - ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id); + ON wizard_drafts(platform, owner_id); - CREATE INDEX idx_wizard_drafts_expires - ON wizard_drafts(expires_at); + CREATE INDEX idx_wizard_drafts_platform + ON wizard_drafts(platform); """, connection); await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs index d2c15c4..f4d5046 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Threading; +using System.Threading.Tasks; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Npgsql; @@ -20,7 +23,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1); await sut.UpsertAsync(draft, CancellationToken.None); - var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None); + var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None); Assert.NotNull(loaded); Assert.Equal("Title", loaded!.Step); } @@ -35,7 +38,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1)); await sut.UpsertAsync(draft, CancellationToken.None); - var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None); + var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None); Assert.Null(loaded); } @@ -49,7 +52,9 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1)); await sut.UpsertAsync(draft, CancellationToken.None); - var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, ownerTelegramId: draft.OwnerTelegramId + 1, CancellationToken.None); + var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1) + .ToString(System.Globalization.CultureInfo.InvariantCulture); + var loaded = await sut.GetActiveAsync(draft.Platform, otherOwner, CancellationToken.None); Assert.Null(loaded); } @@ -69,16 +74,17 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt var deleted = await sut.DeleteExpiredAsync(CancellationToken.None); Assert.Equal(1, deleted); - var loadedFresh = await sut.GetActiveAsync(fresh.ChatId, fresh.MessageThreadId, fresh.OwnerTelegramId, CancellationToken.None); + var loadedFresh = await sut.GetActiveAsync(fresh.Platform, fresh.OwnerId, CancellationToken.None); Assert.NotNull(loadedFresh); } private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new() { Id = Guid.NewGuid(), - ChatId = 42, + ChatId = "42", MessageThreadId = null, - OwnerTelegramId = 100, + OwnerId = "100", + Platform = "Telegram", Step = step, PayloadJson = "{}", CreatedAt = DateTimeOffset.UtcNow, diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs new file mode 100644 index 0000000..0202d23 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs @@ -0,0 +1,126 @@ +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +using Telegram.Bot.Types; +using Xunit; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; + +/// +/// Verifies the Telegram UpdateWizardInteraction mapping +/// that exposes. The mapper is the +/// single bridge between Telegram's native update type and the +/// platform-neutral wizard core, so its contract needs to be locked +/// down: callback queries carry the data payload, text messages carry +/// their text, and photos carry the largest photo's FileId. +/// +public sealed class WizardInteractionMapperTests +{ + [Fact] + public void CallbackUpdate_ProducesCallbackInteraction_WithPayloadAndOwner() + { + var update = new Update + { + CallbackQuery = new CallbackQuery + { + Id = "cb-42", + Data = "wizard:choice:Type:single", + From = new User { Id = 100, FirstName = "GM" }, + Message = new Message { Chat = new Chat { Id = 42 } }, + }, + }; + + var ok = WizardInteractionMapper.TryMap(update, out var interaction); + + Assert.True(ok); + Assert.Equal("100", interaction.OwnerId); + Assert.Null(interaction.Text); + Assert.Equal("wizard:choice:Type:single", interaction.CallbackPayload); + Assert.Null(interaction.PhotoFileId); + Assert.Null(interaction.PhotoUrl); + Assert.Equal("cb-42", interaction.InteractionId); + } + + [Fact] + public void TextUpdate_ProducesTextInteraction_WithTextAndNoCallback() + { + var update = new Update + { + Message = new Message + { + Text = "My Game Title", + Chat = new Chat { Id = 42 }, + From = new User { Id = 200, FirstName = "GM" }, + }, + }; + + var ok = WizardInteractionMapper.TryMap(update, out var interaction); + + Assert.True(ok); + Assert.Equal("200", interaction.OwnerId); + Assert.Equal("My Game Title", interaction.Text); + Assert.Null(interaction.CallbackPayload); + Assert.Null(interaction.PhotoFileId); + Assert.Equal("msg", interaction.InteractionId); + } + + [Fact] + public void PhotoUpdate_ProducesPhotoInteraction_WithLargestFileId() + { + var update = new Update + { + Message = new Message + { + Chat = new Chat { Id = 42 }, + From = new User { Id = 300, FirstName = "GM" }, + Photo = new[] + { + new PhotoSize { FileId = "small-id", Width = 90, Height = 60 }, + new PhotoSize { FileId = "medium-id", Width = 320, Height = 240 }, + new PhotoSize { FileId = "large-id", Width = 800, Height = 600 }, + }, + }, + }; + + var ok = WizardInteractionMapper.TryMap(update, out var interaction); + + Assert.True(ok); + Assert.Equal("300", interaction.OwnerId); + Assert.Null(interaction.Text); + Assert.Null(interaction.CallbackPayload); + Assert.Equal("large-id", interaction.PhotoFileId); + } + + [Fact] + public void CaptionedPhoto_ProducesPhotoInteraction_AndKeepsCaptionOutOfText() + { + // Telegram sometimes attaches a caption to a photo message. The + // mapper treats it as a non-text interaction (cover-step uses + // PhotoFileId, not caption). This test pins that distinction. + var update = new Update + { + Message = new Message + { + Caption = "ignored", + Chat = new Chat { Id = 42 }, + From = new User { Id = 400 }, + Photo = new[] + { + new PhotoSize { FileId = "only-id", Width = 100, Height = 100 }, + }, + }, + }; + + var ok = WizardInteractionMapper.TryMap(update, out var interaction); + + Assert.True(ok); + Assert.Equal("only-id", interaction.PhotoFileId); + } + + [Fact] + public void EmptyUpdate_ReturnsFalse() + { + var ok = WizardInteractionMapper.TryMap(new Update(), out var interaction); + + Assert.False(ok); + Assert.Null(interaction); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs index 3ae4255..99d2993 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs @@ -248,7 +248,7 @@ public sealed class WizardStepRenderTests private static WizardDraft NewDraft(string step) => new() { Id = Guid.NewGuid(), - ChatId = 42, + ChatId = "42", Step = step, PayloadJson = "{}", }; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs index ace85e3..1636517 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs @@ -1,25 +1,26 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging.Abstractions; using Telegram.Bot.Types; using Telegram.Bot.Types.ReplyMarkups; -using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard; -using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger; +using WizardBot = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; /// -/// Hand-rolled test doubles and helpers for wizard unit tests. The project -/// convention is to use fakes (not a mocking framework) so the suite stays -/// AOT-friendly and the production code doesn't grow virtual members just -/// for tests. +/// Hand-rolled test doubles and helpers for wizard unit tests. The +/// project convention is to use fakes (not a mocking framework) so the +/// suite stays AOT-friendly and the production code doesn't grow +/// virtual members just for tests. /// internal static class WizardTestFakes { + public const string PlatformName = "Telegram"; + public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger) { drafts = new FakeWizardDraftRepository(); @@ -30,11 +31,12 @@ internal static class WizardTestFakes public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new() { Id = Guid.NewGuid(), - ChatId = 42, + ChatId = "42", MessageThreadId = null, - OwnerTelegramId = ownerId, + OwnerId = ownerId.ToString(CultureInfo.InvariantCulture), + Platform = PlatformName, Step = step, - DraftMessageId = 7, + DraftMessageId = "7", PayloadJson = System.Text.Json.JsonSerializer.Serialize( payload ?? new WizardPayload(), WizardPayloadJsonContext.Default.WizardPayload), @@ -43,6 +45,63 @@ internal static class WizardTestFakes ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), }; + /// + /// Build the platform-neutral the + /// wizard now consumes. Pre-V112 callers passed + /// Telegram.Bot.Types.Update directly; tests now build the + /// neutral interaction via the same mapper the production code uses. + /// + public static WizardInteraction CallbackInteraction( + string data, string ownerId = "100", string callbackId = "cb-1") + { + return new WizardInteraction( + OwnerId: ownerId, + Text: null, + CallbackPayload: data, + PhotoFileId: null, + PhotoUrl: null, + InteractionId: callbackId); + } + + /// + /// Build a text-style mirroring what + /// WizardInteractionMapper would produce for a Telegram text + /// message. + /// + public static WizardInteraction TextInteraction( + string text, string ownerId = "100", int messageId = 1) + { + return new WizardInteraction( + OwnerId: ownerId, + Text: text, + CallbackPayload: null, + PhotoFileId: null, + PhotoUrl: null, + InteractionId: $"msg-{messageId}"); + } + + /// + /// Build a photo-style mirroring + /// what WizardInteractionMapper would produce for a Telegram + /// photo message. + /// + public static WizardInteraction PhotoInteraction( + string fileId, string ownerId = "100", int messageId = 1) + { + return new WizardInteraction( + OwnerId: ownerId, + Text: null, + CallbackPayload: null, + PhotoFileId: fileId, + PhotoUrl: null, + InteractionId: $"msg-{messageId}"); + } + + /// + /// Build a Telegram carrying a callback query. + /// Used by router-level tests that exercise + /// UpdateRouter.RouteAsync end-to-end. + /// public static Update CallbackUpdate(string data, long ownerId = 100) => new() { CallbackQuery = new CallbackQuery @@ -57,6 +116,11 @@ internal static class WizardTestFakes }, }; + /// + /// Build a Telegram carrying a text message. + /// Used by router-level tests that exercise + /// UpdateRouter.RouteAsync end-to-end. + /// public static Update TextUpdate(string text, long ownerId = 100) => new() { Message = new Message @@ -69,9 +133,9 @@ internal static class WizardTestFakes } /// -/// Records every call the wizard makes against the draft repository. Backed by -/// an in-memory dictionary so tests can pre-seed an "active" draft for the -/// wizard to mutate. +/// Records every call the wizard makes against the draft repository. +/// Backed by an in-memory dictionary so tests can pre-seed an "active" +/// draft for the wizard to mutate. /// internal sealed class FakeWizardDraftRepository : IWizardDraftRepository { @@ -85,13 +149,12 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository public void Seed(WizardDraft draft) => store[draft.Id] = draft; - public Task GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct) + public Task GetActiveAsync(string platform, string ownerId, CancellationToken ct) { foreach (var d in store.Values) { - if (d.ChatId == chatId && - d.MessageThreadId == messageThreadId && - d.OwnerTelegramId == ownerTelegramId && + if (d.Platform == platform && + d.OwnerId == ownerId && d.ExpiresAt > DateTimeOffset.UtcNow) { return Task.FromResult(d); @@ -108,7 +171,8 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository Id = draft.Id, ChatId = draft.ChatId, MessageThreadId = draft.MessageThreadId, - OwnerTelegramId = draft.OwnerTelegramId, + OwnerId = draft.OwnerId, + Platform = draft.Platform, Step = draft.Step, PayloadJson = draft.PayloadJson, DraftMessageId = draft.DraftMessageId, @@ -136,11 +200,14 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository } /// -/// Records every call the wizard makes against the messenger. Default return -/// values (empty clubs, message-id 1) match what the wizard expects to see -/// in steady state. +/// Records every call the wizard makes against the messenger. Default +/// return values (empty clubs, message-id 99) match what the wizard +/// expects to see in steady state. The recorded tuple shapes match +/// the old ITelegramWizardMessenger recorders so existing test +/// assertions (edit.ChatId, edit.Text, …) keep working +/// after the refactor. /// -internal sealed class FakeWizardMessenger : ITelegramWizardMessenger +internal sealed class FakeWizardMessenger : IWizardMessenger { public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new(); @@ -148,37 +215,44 @@ internal sealed class FakeWizardMessenger : ITelegramWizardMessenger public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new(); + public List<(string OwnerId, IReadOnlyList Actions)> EditActions { get; } = new(); + public IReadOnlyList Clubs { get; set; } = Array.Empty(); - public Task EditMessageTextAsync( - long chatId, - int? messageThreadId, - long messageId, + public Task EditDraftMessageAsync( + WizardDraft draft, string text, - InlineKeyboardMarkup keyboard, + IReadOnlyList keyboard, CancellationToken ct) { - Edits.Add((chatId, messageThreadId, messageId, text)); - return Task.FromResult(messageId); + Edits.Add(( + long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0, + int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null, + long.TryParse(draft.DraftMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var msgId) ? msgId : 0, + text)); + EditActions.Add((draft.OwnerId, keyboard)); + return Task.FromResult(draft.DraftMessageId ?? "0"); } - public Task SendGroupMessageAsync( - long chatId, - int? messageThreadId, + public Task SendDraftMessageAsync( + WizardDraft draft, string text, - InlineKeyboardMarkup keyboard, + IReadOnlyList keyboard, CancellationToken ct) { - Sends.Add((chatId, messageThreadId, text)); - return Task.FromResult(99L); + Sends.Add(( + long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0, + int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null, + text)); + return Task.FromResult("99"); } - public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct) + public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct) { - AnsweredCallbacks.Add(callbackId); + AnsweredCallbacks.Add(interactionId); return Task.CompletedTask; } - public Task> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct) + public Task> GetOwnerClubsAsync(string ownerId, CancellationToken ct) => Task.FromResult(Clubs); } diff --git a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs index cb97c5f..783012f 100644 --- a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs @@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests public async Task NavMenu_ShouldExposeCurrentProjectVersion() { var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); - Assert.Contains("v3.7.1", navMenu, StringComparison.Ordinal); + Assert.Contains("v3.9.0", navMenu, StringComparison.Ordinal); } [Fact]