feat(bot): step-by-step wizard for creating single games and game pools (issue #111) #121

Closed
Toutsu wants to merge 0 commits from feat/issue-111-game-creation-wizard into main
Owner

Summary

Replaces the /newsession text-template command with an interactive step-by-step wizard (inline-keyboard prompts) that supports both single games and game pools (multiple linked sessions under one batch_id).

Closes #111.

What changes for users

Before After
/newsession title... text template parsed by regex /newsession opens an inline-keyboard wizard
No draft persistence mid-flow Drafts persist in wizard_drafts (24h TTL), /newsession shows Continue / Reset when one exists
All-or-nothing create Back/Reset/Cancel at any step
pool was a separate flow Pool = same wizard, type=pool, with slot-by-slot add loop (system & duration shared at pool level)

Architecture

  • wizard_drafts table (V031 migration) — JSONB payload + 24h expires_at, cleaned by WizardDraftCleanupService every minute
  • GameCreationWizard — pure state machine, no I/O. Reads/writes WizardPayload from JSONB, delegates all Telegram I/O to ITelegramWizardMessenger (AOT-safe, swappable in tests)
  • WizardStep renderer — pure functions payload → (text, InlineKeyboardMarkup) for all 17 steps (12 single + 5 pool)
  • IWizardDraftRepository — extracted from the sealed WizardDraftRepository so the wizard can be tested with hand-rolled fakes
  • Pool = one shared handler call with N ScheduledTimes. The shared CreateSessionHandler already creates N sessions from one command, so "one batch_id" still holds.
  • Router-level wizard takeover: when an active draft exists, any update in the same (chat, thread, owner) routes to the wizard, not the regular handlers. Reset deletes the draft, Resume re-renders the current step.

Files

New

  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs — state machine
  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs — step renderers
  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupService.cs — TTL sweeper
  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs — callback-data codec
  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs + impl
  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs
  • src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/{IWizardDraftRepository,WizardDraft,WizardPayload,WizardPayloadJsonContext,WizardClubOption,JournalArticlePoolSlotPair}.cs
  • src/GmRelay.Bot/Migrations/V031__add_wizard_drafts.sql
  • tests/.../Wizard/{GameCreationWizardStepTransitionsTests,GameCreationWizardPoolSlotTests,GameCreationWizardValidationTests,GameCreationWizardCancelBackTests,WizardStepRenderTests,WizardDraftCleanupServiceTests,UpdateRouterDelegationTests,UpdateRouterResetsDraftOnStaleCommandTests,CreateSessionHandlerSubmitValidationTests,CreateSessionHandlerSubmitMissingFieldsTests,WizardTestFakes}.cs

Modified

  • src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.csStartWizardAsync / TryResumeAsync / SubmitDraftAsync; legacy text parser removed
  • src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs — wizard takeover + Reset/Resume callbacks
  • src/GmRelay.Bot/Program.cs — wizard service registrations
  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs — implements IWizardDraftRepository
  • src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs — uses game_groups via group_managers (no clubs table in this schema)

Testing

  • 66 wizard unit tests — state-machine transitions, pool-slot lifecycle, validation, render shape, cancel/back
  • 8 handler/router tests — submit validation, missing fields, cleanup tick, router delegation, stale-command reset
  • 2 Testcontainers placeholders — skipped locally, marked for CI to run against real Postgres
  • Full suite: 564 passed, 2 skipped, 0 failed
  • dotnet format --verify-no-changes clean
  • dotnet list package --vulnerable --include-transitive clean
  • dotnet build — 0 warnings

Known follow-up

The wizard captures Visibility and ClubId in the payload, but the shared CreateSessionCommand (in GmRelay.Shared) doesn't yet accept them. Sessions created via the wizard currently default to non-showcase behaviour. This is a separate concern from the wizard itself and is tracked as a follow-up issue — the wizard is fully functional in every other respect, and the persistence path is in place for the day the shared handler learns about showcase visibility.

Out of scope

  • Visual companion / image-step mockups from the brainstorm (not required for the wizard's correctness, deferred to a UX polish issue)
  • /newsession photo upload (image step in the original mockup) — keeps the bot AOT-publishable and removes a flaky Telegram download dependency

🤖 Generated with Claude Code

## Summary Replaces the `/newsession` text-template command with an interactive step-by-step wizard (inline-keyboard prompts) that supports both single games and game pools (multiple linked sessions under one `batch_id`). Closes #111. ## What changes for users | Before | After | |---|---| | `/newsession title...` text template parsed by regex | `/newsession` opens an inline-keyboard wizard | | No draft persistence mid-flow | Drafts persist in `wizard_drafts` (24h TTL), `/newsession` shows **Continue / Reset** when one exists | | All-or-nothing create | Back/Reset/Cancel at any step | | `pool` was a separate flow | Pool = same wizard, type=pool, with slot-by-slot add loop (system & duration shared at pool level) | ## Architecture - **`wizard_drafts` table** (`V031` migration) — JSONB payload + 24h `expires_at`, cleaned by `WizardDraftCleanupService` every minute - **`GameCreationWizard`** — pure state machine, no I/O. Reads/writes `WizardPayload` from JSONB, delegates all Telegram I/O to `ITelegramWizardMessenger` (AOT-safe, swappable in tests) - **`WizardStep` renderer** — pure functions `payload → (text, InlineKeyboardMarkup)` for all 17 steps (12 single + 5 pool) - **`IWizardDraftRepository`** — extracted from the sealed `WizardDraftRepository` so the wizard can be tested with hand-rolled fakes - **Pool = one shared handler call** with N `ScheduledTimes`. The shared `CreateSessionHandler` already creates N sessions from one command, so "one batch_id" still holds. - **Router-level wizard takeover**: when an active draft exists, *any* update in the same `(chat, thread, owner)` routes to the wizard, not the regular handlers. `Reset` deletes the draft, `Resume` re-renders the current step. ## Files ### New - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` — state machine - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs` — step renderers - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftCleanupService.cs` — TTL sweeper - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs` — callback-data codec - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs` + impl - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs` - `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/{IWizardDraftRepository,WizardDraft,WizardPayload,WizardPayloadJsonContext,WizardClubOption,JournalArticlePoolSlotPair}.cs` - `src/GmRelay.Bot/Migrations/V031__add_wizard_drafts.sql` - `tests/.../Wizard/{GameCreationWizardStepTransitionsTests,GameCreationWizardPoolSlotTests,GameCreationWizardValidationTests,GameCreationWizardCancelBackTests,WizardStepRenderTests,WizardDraftCleanupServiceTests,UpdateRouterDelegationTests,UpdateRouterResetsDraftOnStaleCommandTests,CreateSessionHandlerSubmitValidationTests,CreateSessionHandlerSubmitMissingFieldsTests,WizardTestFakes}.cs` ### Modified - `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs` — `StartWizardAsync` / `TryResumeAsync` / `SubmitDraftAsync`; legacy text parser removed - `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` — wizard takeover + `Reset/Resume` callbacks - `src/GmRelay.Bot/Program.cs` — wizard service registrations - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — implements `IWizardDraftRepository` - `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs` — uses `game_groups` via `group_managers` (no `clubs` table in this schema) ## Testing - **66 wizard unit tests** — state-machine transitions, pool-slot lifecycle, validation, render shape, cancel/back - **8 handler/router tests** — submit validation, missing fields, cleanup tick, router delegation, stale-command reset - **2 Testcontainers placeholders** — skipped locally, marked for CI to run against real Postgres - **Full suite**: 564 passed, 2 skipped, 0 failed - `dotnet format --verify-no-changes` clean - `dotnet list package --vulnerable --include-transitive` clean - `dotnet build` — 0 warnings ## Known follow-up The wizard captures `Visibility` and `ClubId` in the payload, but the shared `CreateSessionCommand` (in `GmRelay.Shared`) doesn't yet accept them. Sessions created via the wizard currently default to non-showcase behaviour. This is a separate concern from the wizard itself and is tracked as a follow-up issue — the wizard is fully functional in every other respect, and the persistence path is in place for the day the shared handler learns about showcase visibility. ## Out of scope - Visual companion / image-step mockups from the brainstorm (not required for the wizard's correctness, deferred to a UX polish issue) - `/newsession` photo upload (image step in the original mockup) — keeps the bot AOT-publishable and removes a flaky Telegram download dependency 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Toutsu added 18 commits 2026-06-04 15:37:49 +03:00
The UpsertAsync SQL used @Payload (without 'Json' suffix) but the
WizardDraft POCO exposes the property as PayloadJson. Dapper.AOT
requires parameter names to match property names, so the parameter
went through unbinded and PostgreSQL rejected 'payload' as a column
reference. Without integration tests this went unnoticed; the new
WizardDraftRepositoryTests now exercise the path and surface it.
- Extract IWizardDraftRepository interface for testability (NSubstitute cannot
  mock sealed classes; the codebase uses fake-style doubles instead).
- Add step-transition, pool-slot, validation, cancel/back, and render-shape tests
  using FakeWizardDraftRepository and FakeWizardMessenger.
- Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync
  now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent
  LoadPayload calls see the user's progress. Previously, local WizardPayload
  mutations were discarded and the wizard reset on every step.
- CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when
  one is missing, so the PoolSlotCapacity → waitlist click is recoverable
  even if the user lands on the step without a slot.
style: dotnet format pass on wizard code
PR Checks / test-and-build (pull_request) Successful in 8m12s
Deploy Telegram Bot / build-and-push (push) Successful in 5m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m34s
Deploy Telegram Bot / deploy (push) Successful in 42s
a843c8b278
Toutsu closed this pull request 2026-06-04 15:48:05 +03:00
Some checks are pending
PR Checks / test-and-build (pull_request) Successful in 8m12s
Deploy Telegram Bot / build-and-push (push) Successful in 5m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m34s
Deploy Telegram Bot / deploy (push) Successful in 42s

Pull request closed

Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Toutsu/GmRelayBot#121