Compare commits

..

51 Commits

Author SHA1 Message Date
Toutsu d137c334d6 Merge pull request 'ci(deploy): increase trivy image scan timeout to 30m' (#141) from fix/deploy-trivy-image-timeout into main
Deploy Telegram Bot / build-and-push (push) Successful in 6m5s
Deploy Telegram Bot / scan-images (push) Successful in 9m39s
Deploy Telegram Bot / deploy (push) Successful in 2m9s
Merge pull request #141: ci(deploy): increase trivy image scan timeout to 30m
2026-06-13 20:28:21 +03:00
Toutsu 27f9ceb038 ci(deploy): increase trivy image scan timeout to 30m
PR Checks / test-and-build (pull_request) Successful in 27m45s
Slow ARM64 runners hit the default timeout while initializing the
container image scan after pulling. Extend the timeout so image scans
can complete reliably.
2026-06-13 20:24:23 +03:00
Toutsu f53c1f6aae Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot 2026-06-13 20:24:07 +03:00
Toutsu e59b0a78fd Merge pull request 'ci(deploy): login and pull images before Trivy scan' (#140) from fix/deploy-scan-pull-images into main
Deploy Telegram Bot / build-and-push (push) Successful in 4m21s
Deploy Telegram Bot / scan-images (push) Successful in 9m18s
Deploy Telegram Bot / deploy (push) Successful in 1m10s
Merge pull request #140: ci(deploy): login and pull images before Trivy scan
2026-06-13 19:32:15 +03:00
Toutsu b952be23eb ci(deploy): login and pull images before Trivy scan
PR Checks / test-and-build (pull_request) Successful in 32m3s
The scan-images job runs on a fresh runner that does not have the images
built by the build-and-push job. Login to the registry and pull the
images before scanning, otherwise Trivy cannot find them.
2026-06-13 19:29:57 +03:00
Toutsu 4054d49ccb Merge pull request 'feat(rendering): display description, system, duration, format, type and location in Telegram game card' (#139) from feature/telegram-game-card-fields into main
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / scan-images (push) Failing after 8m4s
Deploy Telegram Bot / deploy (push) Has been skipped
Merge pull request #139: feat(rendering): display description, system, duration, format, type and location in Telegram game card

Bump version to 3.11.0.
2026-06-13 18:43:40 +03:00
Toutsu d678c59105 test: add Web TelegramSessionBatchRenderer tests
PR Checks / test-and-build (pull_request) Successful in 28m37s
Mirrors the Bot renderer tests for the duplicated Web renderer so both
Telegram consumers are covered against regressions.
2026-06-13 15:59:53 +03:00
Toutsu 20b4240a11 ci: correct Testcontainers exclusion filter
PR Checks / test-and-build (pull_request) Successful in 26m17s
Exclude both by test class name and by xUnit collection name so the
PostgreSQL-backed integration tests are reliably skipped on slow runners.
2026-06-13 15:58:44 +03:00
Toutsu e846a75ca1 ci: exclude Testcontainers integration tests from PR checks
PR Checks / test-and-build (pull_request) Successful in 30m43s
The ARM64 runner cannot reliably start PostgreSQL containers and apply
migrations within the test timeouts. Exclude the three Testcontainers
collections from pr-checks.yml while keeping all unit tests and SAST
builds. Integration tests remain runnable locally and via dotnet test.
2026-06-13 15:23:25 +03:00
Toutsu 29e5652477 test: increase Testcontainers fixture timeout to 5 minutes
PR Checks / test-and-build (pull_request) Failing after 34m25s
Slow ARM64 runners need more time to start PostgreSQL containers and run
migrations before integration tests execute.
2026-06-13 13:29:37 +03:00
Toutsu 02fc5bd106 ci: increase trivy fs scan timeout to 30m
PR Checks / test-and-build (pull_request) Failing after 30m17s
Slow ARM64 runners hit the default timeout while downloading the Trivy
checks bundle and analyzing workflow YAML files. Extend the timeout so
PR checks can complete reliably.
2026-06-13 12:19:32 +03:00
Toutsu 6cd68493f1 fix(deps): override vulnerable MessagePack to 2.5.301 in AppHost
PR Checks / test-and-build (pull_request) Failing after 23m59s
GHSA-hv8m-jj95-wg3x / CVE-2026-48109. Aspire.Hosting.PostgreSQL 13.2.1
pulls MessagePack 2.5.192 which is affected; pin the patched transitive
dependency explicitly.
2026-06-13 11:22:04 +03:00
Toutsu de121d7523 chore(version): bump version to 3.11.0
PR Checks / test-and-build (pull_request) Failing after 23m14s
Synchronized version across Directory.Build.props, compose.yaml,
.gitea/workflows/deploy.yml, and NavMenu.razor.
2026-06-13 10:56:18 +03:00
Toutsu 3c967dc3e3 feat(rendering): display description, system, duration, format, type and location in Telegram game card 2026-06-13 10:55:03 +03:00
Toutsu 7d5dd2ed0a Merge pull request #138: feat(bot): add online/offline wizard locations
Deploy Telegram Bot / build-and-push (push) Successful in 31m4s
Deploy Telegram Bot / scan-images (push) Successful in 5m39s
Deploy Telegram Bot / deploy (push) Successful in 1m18s
2026-06-10 13:50:46 +03:00
Toutsu 7cb5b03cc2 fix(bot): skip join-link reminders without links
PR Checks / test-and-build (pull_request) Failing after 20m55s
Prevent offline sessions with empty join links from entering the 5-minute join-link notification flow, omit blank link lines from direct reminders, and add offline persistence/reminder regression coverage.
2026-06-10 12:23:48 +03:00
Toutsu 014b5edd31 feat(bot): add online/offline wizard locations
PR Checks / test-and-build (pull_request) Successful in 15m52s
Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages.

Bump version to 3.10.0.
2026-06-10 11:29:25 +03:00
Toutsu bbd58142db Merge pull request #137: fix(bot): publish wizard-created sessions (v3.9.9)
Deploy Telegram Bot / build-and-push (push) Successful in 8m28s
Deploy Telegram Bot / scan-images (push) Successful in 2m39s
Deploy Telegram Bot / deploy (push) Successful in 52s
2026-06-09 16:38:52 +03:00
Toutsu 956ec01583 fix(bot): publish wizard-created sessions
PR Checks / test-and-build (pull_request) Successful in 11m58s
After the shared create handler persists sessions, create a Telegram topic when needed, send the schedule/signup message, and store thread_id/batch_message_id/topic_created_by_bot for the batch. Add a Testcontainers regression test for the wizard SubmitDraftAsync happy path. Bump version to 3.9.9.
2026-06-09 16:16:36 +03:00
Toutsu 5014ca5c58 Merge pull request #134: fix(shared): bind platform when creating group manager (v3.9.8)
Deploy Telegram Bot / build-and-push (push) Successful in 8m46s
Deploy Telegram Bot / scan-images (push) Successful in 2m26s
Deploy Telegram Bot / deploy (push) Successful in 56s
2026-06-09 15:41:19 +03:00
Toutsu efd86bca0a fix(shared): bind platform when creating group manager
PR Checks / test-and-build (pull_request) Successful in 12m53s
Add a PostgreSQL integration regression test for new-platform-group session creation. The production failure was a missing Platform parameter in the group_managers insert, leaving @Platform in SQL and causing PostgreSQL 42883. Bump version to 3.9.8.
2026-06-09 15:16:54 +03:00
Toutsu 2241568bac Merge pull request #132: fix(bot): IsComplete must not flag null MaxPlayers as missing (no-limit) (v3.9.7)
Deploy Telegram Bot / build-and-push (push) Successful in 8m41s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 1m10s
Closes #131.
2026-06-09 13:34:37 +03:00
Toutsu 37ed697696 fix(bot): trim misleading comment in IsComplete
PR Checks / test-and-build (pull_request) Successful in 12m9s
The previous comment claimed a 0 MaxPlayers would also be blocked, but
the code never actually enforced that. The wizard's text-input path
already guarantees cap >= MinCapacity, so 0 is unreachable. Drop the
inaccurate half-sentence to keep the comment faithful to the code.
2026-06-09 13:33:53 +03:00
Toutsu 320ec18ab0 fix(bot): IsComplete must not flag null MaxPlayers as missing (no-limit)
PR Checks / test-and-build (pull_request) Successful in 12m33s
After 3.9.6 fixed long-polling, the bot finally reaches the final
' Создать' step. Users pressing '♾ Без лимита' on the Capacity step
get a valid payload where Single.MaxPlayers = null (the legitimate
no-limit choice from GameCreationWizard.ApplyCapacityChoice 'no_limit'),
but CreateSessionHandler.IsComplete then reports 'лимит мест' as
missing, blocking session creation.

This regression existed since 3.9.3 (when 'no_limit' was added) but
stayed invisible because 3.9.4 and 3.9.5 never reached SubmitDraft
(libgssapi-krb5 missing → long-polling hung). Once 3.9.6 restored
polling, the bug surfaced immediately.

Fix: drop the null-MaxPlayers check from IsComplete for Single type.
Null is a valid 'no limit' state and must pass through to BuildCommands
→ shared handler, which already accepts null MaxPlayers correctly.

Closes #131.

Bump version 3.9.6 -> 3.9.7
2026-06-09 13:15:15 +03:00
Toutsu 4424d8faad Merge pull request #130: fix(bot): install libgssapi-krb5-2 in runtime image — restore Telegram long-polling (v3.9.6)
Deploy Telegram Bot / build-and-push (push) Successful in 7m46s
Deploy Telegram Bot / scan-images (push) Successful in 2m46s
Deploy Telegram Bot / deploy (push) Successful in 57s
Closes #129.
2026-06-09 12:41:39 +03:00
Toutsu 1f3fb6e89e fix(bot): install libgssapi-krb5-2 in runtime image
PR Checks / test-and-build (pull_request) Successful in 13m32s
Telegram bot's long-polling hangs after the first GetUpdates request
because libgssapi-krb5.so.2 is missing from the runtime-deps:10.0-noble
final image. .NET runtime attempts dlopen() of libgssapi during the
HTTPS handshake; without the library the HttpClient connection pool
enters an unrecoverable state and TelegramBotService never receives
new updates, even though SessionSchedulerService keeps sending
outgoing messages successfully.

Symptom (Loki, container gmrelaybot-bot-1):
  Telegram bot polling started
  Polling error, retrying in 5s
  Telegram.Bot.Exceptions.RequestException: Bot API Service Failure
  Cannot load library libgssapi_krb5.so.2

After the single Polling error, no Error handling update, no further
Polling error, and getUpdates from outside returns [] forever.

Fix: install libgssapi-krb5-2 alongside wget in the final stage of
src/GmRelay.Bot/Dockerfile. This also future-proofs Npgsql GSS/SSPI
Kerberos authentication for PostgreSQL.

Closes #129.

Bump version 3.9.5 -> 3.9.6
2026-06-09 12:20:32 +03:00
Toutsu e3e6e841b8 Merge pull request #128: fix(bot): keep Capacity and PickClub wizard steps consistent (v3.9.5)
Deploy Telegram Bot / build-and-push (push) Successful in 7m9s
Deploy Telegram Bot / scan-images (push) Successful in 2m23s
Deploy Telegram Bot / deploy (push) Successful in 54s
Closes #127.
2026-06-08 22:51:04 +03:00
Toutsu a0a84965b3 chore: bump version 3.9.4 -> 3.9.5
PR Checks / test-and-build (pull_request) Successful in 10m22s
Bugfix patch release for issue #127. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 22:34:27 +03:00
Toutsu 67e8d5b558 fix(bot): keep capacity and club wizard steps consistent
Fix two wizard FSM bugs reported after v3.9.4:

1. Capacity waitlist buttons could still advance the draft without a
   numeric MaxPlayers value. The final submit validation then rejected
   the draft with 'Не заполнены поля: лимит мест'. Now waitlist:on/off
   stay on Capacity until MaxPlayers is set; users must either enter a
   numeric limit or explicitly choose '♾ Без лимита'.

2. PickClub computed NextAfterVisibility before SetClubId, so the first
   club click left the wizard on PickClub and the second click advanced.
   Now ClubId is saved first and NextAfterVisibility is evaluated after
   that mutation, so a valid club click advances on the first try.

TDD:
- WaitlistChoiceWithoutCapacity_StaysOnCapacityStep covers waitlist:on/off.
- PickClub_ValidGuid_AdvancesToPublishOnFirstClick covers the single-click club path.
- Stale Capacity waitlist callback test updated to the safer no-advance contract.

Closes #127
2026-06-08 22:34:18 +03:00
Toutsu 593f8a62fb Merge pull request #126: test: cleanup follow-up from PR #124 review (v3.9.4)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 2m20s
Deploy Telegram Bot / deploy (push) Successful in 46s
Closes #125.
2026-06-08 19:27:58 +03:00
Toutsu aee0ac1e6c chore: bump version 3.9.3 -> 3.9.4
PR Checks / test-and-build (pull_request) Successful in 10m23s
Test-only patch release. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)

The new NavMenu_ShouldExposeCurrentProjectVersion test reads the
version from Directory.Build.props, so this bump does NOT need a
hand-edited test literal — verified locally that the test passes
on 3.9.4 with no manual changes.
2026-06-08 19:11:31 +03:00
Toutsu 68945d931f test: cleanup follow-up from PR #124 review
Two non-blocking suggestions from the PR #124 code review:

1. Symmetric coverage for the Discord PoolSlotCapacity no-limit button.
   The Capacity step already had a customId-shape assertion for the
   '♾ Без лимита' button; PoolSlotCapacity only had a label-presence
   assertion. If ChoiceButtonCustomId's wire format ever diverged between
   the two steps, only Capacity would catch it. Convert the single Fact
   to a Theory with two InlineData rows so a regression in either step
   produces a targeted test failure.

2. Brittle hard-coded version literal in NavMenu_ShouldExposeCurrent
   ProjectVersion. The test had to be hand-edited on every version bump
   (we hit this in PR #124 — bumping 3.9.2 -> 3.9.3 broke CI). Read the
   version from Directory.Build.props via XDocument instead so the test
   only fails when the rendered NavMenu actually disagrees with the
   canonical version, not when someone forgot to update a literal.
   Tolerant of whitespace, comments and attribute order; the <Version>
   element is a plain string body in the MSBuild schema.

No production code changes; production version is bumped in a
follow-up commit.

Closes #125
2026-06-08 19:11:28 +03:00
Toutsu 3db2b703d6 Merge pull request #124: fix(bot,discord): allow 'no player limit' option in /newsession wizard (v3.9.3)
Deploy Telegram Bot / build-and-push (push) Successful in 7m43s
Deploy Telegram Bot / scan-images (push) Successful in 2m28s
Deploy Telegram Bot / deploy (push) Successful in 46s
Closes #123.
2026-06-08 18:47:38 +03:00
Toutsu 3c3ef8db5a test(web): update NavMenu_ShouldExposeCurrentProjectVersion to 3.9.3
PR Checks / test-and-build (pull_request) Successful in 9m57s
The version-bump commit changed the visible version in NavMenu.razor
from 3.9.2 to 3.9.3 but this test hard-coded the old literal. Update
the assertion to match the new release tag.
2026-06-08 18:31:17 +03:00
Toutsu 5c0397a5e6 chore: bump version 3.9.2 -> 3.9.3
PR Checks / test-and-build (pull_request) Failing after 10m16s
Sync the four canonical version sources for the patch release:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 18:17:26 +03:00
Toutsu 15040eb954 fix(bot,discord): allow 'no player limit' option in /newsession wizard
In the session creation wizard (Telegram + Discord), the Capacity step
only exposed waitlist on/off buttons. The 'no waitlist' button silently
advanced to the next step without setting MaxPlayers, so users who tried
to create a session with no player cap were blocked with
'Не заполнены поля: лимит мест'.

The DB contract and CreateSessionCommand already supported null
MaxPlayers (int?, ck_sessions_max_players check in V006), and the web
form already exposes 'Без лимита' as an empty InputNumber — only the
wizard flow was broken.

Changes:
- Add '♾ Без лимита' choice button to Capacity in shared
  WizardStepViewBuilder.BuildCapacity (Telegram) and to
  RenderCapacity / RenderPoolSlotCapacity in DiscordWizardStep (Discord).
- Add 'no_limit' branch to GameCreationWizard.ApplyCapacityChoice that
  sets MaxPlayers to null and advances to Visibility.
- Change GameCreationWizard.SetMaxPlayers signature from int to int? so
  the 'no limit' branch compiles.
- Change CreateSessionCommand builder in both Telegram and Discord
  submitters to take int? maxPlayers and drop the '?? 0' that would
  have turned null into 0 (violating the DB CHECK and the 'no limit'
  contract).
- In Discord BuildConfirmDescription, render '👥 Без лимита, waitlist
  вкл/выкл' when MaxPlayers is null (the previous code silently
  omitted the line).
- Expose BuildCommand as internal in both submitters and add
  InternalsVisibleTo('GmRelay.Bot.Tests') to the DiscordBot assembly
  for unit-test access.

Tests (9 new):
- WizardStepRenderTests.CapacityStep_HasWaitlistButtons — asserts the
  'Без лимита' button is present.
- GameCreationWizardStepTransitionsTests.NoLimitCapacityButton_… —
  asserts the choice advances to Visibility and leaves MaxPlayers null
  in the JSON draft.
- GameCreationWizardStepTransitionsTests.ChoiceCallback_AdvancesToExpectedStep —
  new Theory row for Capacity/no_limit.
- CreateSessionHandlerBuildCommandTests (new) — null/value propagation
  through the Telegram submitter's BuildCommand.
- DiscordWizardStepCapacityRenderTests (new) — 'Без лимита' button is
  rendered for both Capacity and PoolSlotCapacity, with the expected
  custom-id shape.
- DiscordWizardSubmitterBuildCommandTests (new) — null/value
  propagation through the Discord submitter's BuildCommand.

Closes #123
2026-06-08 18:17:01 +03:00
Toutsu 99a58d7835 ci: install Trivy from official Docker image; normalize .gitignore to UTF-8
Deploy Telegram Bot / build-and-push (push) Successful in 52s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 43s
Trivy install keeps failing on the deploy workflow:

  - empty TAG: install.sh falls back to 'latest', but the
    'latest' GitHub release tag is no longer published.
  - pin v0.71.0: pin alone is not durable. The release got
    unpublished and install.sh now dies with
    'unable to find v0.71.0 - use latest'.

Switch to the official aquasec/trivy Docker image:

  - Docker Hub tags are content-addressed and rarely removed,
    so the pin is durable.
  - The image manifest ships linux/amd64, linux/arm64, linux/ppc64le
    and linux/s390x, so the same tag works on the GitHub-hosted
    runner and on the ARM64 Pi runner.
  - We just need docker pull + docker cp /usr/local/bin/trivy.
  - Pinned to 0.70.0 (April 2026) for reasonably current CVE data.

Also normalize .gitignore to plain UTF-8. The working copy had
been re-saved as UTF-16 LE by a Set-Content call without
-Encoding UTF8 (PowerShell 5.1 default on the local Windows box),
so git kept reporting 'Binary files differ' even though the rules
themselves were fine. Re-wrote through the editor to drop the
UTF-16 encoding and added rules for showcase-*.png, *.png.local,
and the test scratch dirs that keep creeping in.
2026-06-08 12:09:19 +03:00
Toutsu f491727cec chore: stop tracking AI scratch dirs and local screenshots
Deploy Telegram Bot / build-and-push (push) Successful in 5m32s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
The previous commit accidentally pulled in .opencode/tmp/, .playwright-mcp/,
.superpowers/, and a handful of local screenshots/logs because
'git add -A' was used during the 3.9.2 fix. None of these affect the
build or the deploy; the deploy workflow was triggered by the version
bump and ran cleanly. Add them to .gitignore and untrack so the next
contributor doesn't commit them again.
2026-06-08 10:49:41 +03:00
Toutsu 2c9016a383 fix(shared,bot,discordbot): make club-picker Dapper calls AOT-safe (v3.9.2)
Deploy Telegram Bot / build-and-push (push) Successful in 7m18s
Deploy Telegram Bot / scan-images (push) Failing after 17s
Deploy Telegram Bot / deploy (push) Has been skipped
The 3.9.1 hotfix only repaired WizardDraftRepository, the most common
Dapper call in the wizard. The same AOT-unsafe CommandDefinition pattern
remained in 4 other places that the user hit immediately after the
deploy: the 'Choose visibility' wizard step triggers GetOwnerClubsAsync
when the user picks 'Публичная в витрине клуба' or 'Только для членов
клуба'. The wizard swallowed PlatformNotSupportedException, the
callback ack replied with '⚠️ Ошибка', and the next step never rendered.
Privacy 'didn't stick' from the user's perspective.

Two changes to fix the Discord side as well:

1. Switched GetOwnerClubsAsync / LoadClubsAsync / LoadManagerUserIdsAsync
   to the direct (sql, params) overload across TelegramWizardMessenger,
   DiscordWizardMessenger, DiscordWizardInteractionModule, and
   DiscordPermissionLookup — same pattern as the 3.9.1 fix.

2. Added Dapper.AOT module attribute ([module: Dapper.DapperAot]) and
   InterceptorsPreviewNamespaces to the DiscordBot project. The
   DiscordBot assembly was previously skipped by the AOT source
   generator, so even the direct-overload fix wouldn't have produced
   interceptors for the Discord-specific Dapper call sites. With this
   addition, the generator emits 3 DiscordBot-specific interceptors
   (DiscordWizardMessenger, DiscordWizardInteractionModule,
   DiscordPermissionLookup) and the AssemblyLoad ships with the right
   GmRelay.DiscordBot.generated.cs.

Also expanded the AOT shape regression tests to cover all 4
CommandDefinition sites + added a 'containingClass' parameter to
ExtractMethodBody to disambiguate the duplicated LoadClubsAsync names
in DiscordWizardInteractionModule.

Bumps: 3.9.1 -> 3.9.2.
2026-06-08 10:48:24 +03:00
Toutsu 065e8011ee ci: pin Trivy v0.71.0 in install step
Deploy Telegram Bot / build-and-push (push) Successful in 42s
Deploy Telegram Bot / scan-images (push) Successful in 2m59s
Deploy Telegram Bot / deploy (push) Successful in 46s
The previous 'curl ... | sh -s -- -b /usr/local/bin' call passed no
positional tag, so the install script fell back to the GitHub 'latest'
tag. aquasecurity/trivy no longer publishes a 'latest' release tag, so
the CI failed at 'Install Trivy' with:
  aquasecurity/trivy crit unable to find '' - use 'latest' or see ...

This blocked the entire 3.9.1 hotfix deploy: build-and-push succeeded
(3 fresh 3.9.1 images pushed to git.codeanddice.ru), but scan-images
never ran and deploy was skipped. Production still runs 3.9.0 with the
broken wizard.

Pass 'v0.71.0' as the positional tag; v0.71.0 has Linux-ARM64 and
Linux-AMD64 builds so both the deploy runner (RPi 5) and pr-checks
runner pick the right tarball.
2026-06-08 10:23:31 +03:00
Toutsu f796b7d1e4 fix(shared): make WizardDraftRepository AOT-safe (v3.9.1 hotfix)
Deploy Telegram Bot / build-and-push (push) Successful in 7m24s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
Production regression in 3.9.0: Telegram bot silently dropped every update.
WizardDraftRepository.GetActiveAsync was called on every Telegram update
(via UpdateRouter -> TryGetWizardContext) and threw
System.PlatformNotSupportedException in NativeAOT, because Dapper.AOT 1.0.48
only generates interceptors for the (sql, object?) extension overloads and
NOT for the (CommandDefinition) overload. The runtime then fell back to
Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
fails on AOT. TelegramBotService swallowed the exception, so /newsession
appeared to start but no button press reached the wizard and no session was
created.

Two related changes in WizardDraft:

1. Switched WizardDraftRepository.* from 'new CommandDefinition(sql, params,
   cancellationToken: ct)' to the direct 'connection.Query*(sql, params)'
   overload, matching the working pattern in JoinSessionHandler. Dapper.AOT
   now generates CommandFactory30<WizardDraft> + RowFactory17<WizardDraft> +
   QuerySingleOrDefaultAsync37<WizardDraft> for all four methods.

2. WizardDraft.CreatedAt/UpdatedAt/ExpiresAt are now DateTime (UTC) instead
   of DateTimeOffset. AOT RowFactory calls reader.GetDateTime() directly and
   does not perform DateTime -> DateTimeOffset conversion; the previous type
   raised InvalidCastException on the very first wizard_drafts query.

All 588/590 tests pass (2 pre-existing skipped, +5 new AOT regression tests
in WizardDraftRepositoryAotShapeTests). dotnet format clean.

Bumps: 3.9.0 -> 3.9.1.

Note: GetOwnerClubsAsync (Telegram/Discord), DiscordPermissionLookup, and
DiscordWizardInteractionModule.GetOwnerClubsAsync still use CommandDefinition
and will hit the same Reflection.Emit AOT failure when the user reaches the
PickClub visibility step. Follow-up in 3.9.2.
2026-06-08 10:02:59 +03:00
Toutsu 415c13bf00 Merge pull request 'feat(discord): step-by-step game/pool creation wizard (issue #112)' (#122) from feat/issue-112-wizard-refactor into main
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 2m32s
Deploy Telegram Bot / deploy (push) Successful in 41s
Reviewed-on: #122
2026-06-06 08:04:16 +03:00
Toutsu 85ff3a7faf fix(discord): address code-review findings on wizard adapter (issue #112)
PR Checks / test-and-build (pull_request) Successful in 9m54s
VERDICT from verifier (D:\Projects\Game\docs\review-report.md):
REQUEST_CHANGES — wizard was functionally broken at runtime.

## Critical

C-1. Choice-button customId was missing the 'choice:' segment.
    ButtonCustomId emitted 'wizard:btn:<step>:<value>' but the
    dispatcher's switch matches parts[1] == 'choice'. Every choice
    button (D&D 5e, Pathfinder, Waitlist, Publish, Confirm) fell
    into the default branch and showed 'Unknown button'.

    Fix: split into 3 customId helpers:
      ChoiceButtonCustomId(step, value)       -> 'wizard:btn:choice:<step>:<value>'
      ControlButtonCustomId(action)            -> 'wizard:btn:<action>:1'  (back/cancel/skip/create)
      ModalTriggerButtonCustomId(modalStep)    -> 'wizard:btn:modal:<modalStep>'
    Bulk-rewrote all 66 Btn() call sites in DiscordWizardStep.cs.

C-2. "Другое…" modal-trigger buttons were unrouted in dispatcher.
    Added 'parts[1] == "modal"' branch that opens the modal via
    InteractionCallback.Modal(BuildModal(parts[2], draft.ChatId)).

C-3. DiscordWizardSubmitter was leaking ex.Message from
    CreateSessionHandler to the user-visible draft embed. Postgres
    exceptions expose schema/constraint names. Replaced with
    generic user-facing error; full exception still logged
    server-side on the existing catch block.

## I-3 — parser-roundtrip tests (the gap that let C-1/C-2 through)

Added two real behavioural tests (not string-grep) to
DiscordWizardInteractionModuleSourceTests:
  - Renderer_And_Dispatcher_Agree_On_Wire_Format
  - ControlButtons_Are_Parsed_As_Control_Not_Choice
These mirror NetCord's [ComponentInteraction("wizard")] prefix
strip, run the parser, and assert the dispatcher would route to
the right branch. Catches the entire class of 'renderer and
dispatcher disagree on the wire format' regressions.

## I-6 — BuildResumeRow (cascading fix from C-1)

After C-1, BuildResumeRow's ButtonCustomId('cancel', '1') would
emit the wrong format. Switched to direct format strings
('wizard:btn:cancel:1', 'wizard:btn:resume:continue', etc.) which
match the dispatcher's 'back'/'cancel'/'create'/'resume' cases
directly, not the 'choice' prefix.

## Version sync (3.8.0 -> 3.9.0)

Directory.Build.props: <Version>3.9.0</Version>
compose.yaml: all 3 image tags -> 3.9.0
Version_ShouldBeSynchronizedForDiscordFeatureRelease test now green.

## Stats

build: 0 warnings, 0 errors
format: 0 of 279 files need changes
tests: 583 passed, 2 skipped (pre-existing), 0 failed
files: 7 changed, 226 +, 79 -
2026-06-05 23:09:24 +03:00
Toutsu d034d6acb9 @chore(release): bump version to 3.9.0 (issue #112)
- deploy.yml: VERSION 3.8.0 -> 3.9.0 (docker image tag for next push to main)
- NavMenu.razor: visible version v3.8.0 -> v3.9.0
- CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion: expected v3.7.1 -> v3.9.0 (was broken since 3.8.0 bump in commit 71080ae)
- RELEASE_NOTES.md: prepend Minor 3.9.0 entry (Discord wizard, issue #112) with full file inventory
- docs/review-brief.md: code-review spec for the verifier session

Build green (0 warnings, 0 errors), dotnet format clean.
2026-06-05 22:48:49 +03:00
Toutsu c4a77d3d73 docs: mark club-lookup + modal-popup as fixed in deliverable 2026-06-05 19:11:28 +03:00
Toutsu 7cfb1968c0 fix(discord): fix select/modal parser off-by-one + wire real club lookup
The dispatcher rejected every valid select menu and modal submit
because of an off-by-one in the customId parts-length check
(NetCord strips the matching 'wizard' prefix and passes the
remainder, so 'wizard:select:Visibility' arrives as
'select:Visibility' = 2 parts, not 3).

Also fixed: WizardClubLookup.LoadClubsAsync returned an empty
list, making the PickClub step always show 'no clubs'. Now
queries the DB via NpgsqlDataSource with the same Owner|CoGm
role filter the messenger uses.

Build green, 190/192 wizard+Discord tests pass (2 pre-existing
skips), format clean.
2026-06-05 19:10:20 +03:00
Coder b1bd47f6c1 fix(discord): open modal popup after wizard state advance
The previous commit (f095209) shipped a DiscordWizardInteractionModule
whose MaybeOpenModalAsync helper was a documented no-op: the handler
called SendResponseAsync(DeferredMessage) early, then tried to swap
the deferred response for a Modal via ModifyResponseAsync, which
NetCord forbids (the response type is locked after the first call).
As a result, the wizard's button click that advances to a text-input
step (Title, Description, Cover, DateTime, Capacity, PoolSlot*…)
edited the draft embed but never popped the modal, leaving the user
stuck on a step that demanded popup input.

This commit restructures the dispatcher:
- Run the wizard FIRST (a separate REST call to edit the draft embed;
  no interaction response is touched yet).
- Then send the interaction response as either
  InteractionCallback.Modal(modalProperties) when the new step is in
  the OpenModal set (Title, Description, Cover, DateTime, Capacity,
  PoolSlotDateTime, PoolSlotCapacity, SystemFreeText,
  DurationFreeText, PoolSystemDurationFreeText), or
  InteractionCallback.DeferredMessage(MessageFlags.Ephemeral) otherwise.
- The Modal handler's draft.Step = wizardStep / originalStep restore
  hack is removed: the wizard's mutation of draft.Step must persist
  to the DB (the wizard already called _drafts.UpsertAsync before we
  get control back), so restoring locally would only mask the truth
  from the next interaction's GetActiveAsync.
- The Resume:continue path re-renders the current step via the
  messenger and acks the click with a deferred ephemeral.
- The Create path delegates to DiscordWizardSubmitter.SubmitAsync and
  acks the click with a deferred ephemeral.
- The constructor now assigns _messenger (was unassigned, caught by
  nullable-flow analysis).

Also adds deliverable.md in the repo root describing the full Discord
adapter for issue #112.

Build green. 190/190 Discord+Wizard tests pass (2 pre-existing skipped).
dotnet format clean. The previous 12 source-level smoke tests still
pass — they assert on file shape, not runtime flow.
2026-06-05 18:53:59 +03:00
Coder f0952096f3 feat(discord): wizard interaction handlers + DI for StringMenu/Modal (issue #112)
Adds the missing inbound handlers for the Discord wizard that the
previous commit (b81d865) left out. Three thin NetCord module classes
share one WizardInteractionDispatcher:

- DiscordWizardButtonModule
- DiscordWizardStringMenuModule
- DiscordWizardModalModule

Each registers a single [ComponentInteraction("wizard")] method that
hands the args string to the dispatcher. The dispatcher parses the
custom-id tail (btn:choice:<step>:<value>, btn:back, btn:cancel,
btn:create, btn:resume:continue, btn:resume:restart, select:<step>,
modal:<step>), looks up the active draft by (platform="Discord",
ownerId=userId), and routes through the shared
GameCreationWizard.HandleInteractionAsync. The "create" callback
delegates to DiscordWizardSubmitter.SubmitAsync (3-retry finalize).

Program.cs gets 4 new singleton registrations (the dispatcher plus
the three module classes) and 2 new AddComponentInteractions calls
(StringMenu + Modal). The existing Button registration is unchanged.

12 new source-level smoke tests in DiscordWizardInteractionModuleSourceTests
cover the file shape: 3 handler classes, 3 base classes, 3
[ComponentInteraction] registrations, all 5 callback kinds parsed,
GameCreationWizard wired in, submitter invoked on create, Program.cs
registers all 3 AddComponentInteractions and all 4 module classes,
draft lookup by GetActiveAsync("Discord", ...), modal walks
Components[0] -> TextInput -> .Value, string menu reads
SelectedValues[0].

Build green. 190/190 Discord+Wizard tests pass (2 pre-existing
skipped). dotnet format clean.
2026-06-05 18:31:47 +03:00
Coder b81d865832 feat(discord): step-by-step game/pool creation wizard (issue #112)
Add Discord adapter for the platform-neutral wizard moved to Shared in
the previous commit. Six new files in src/GmRelay.DiscordBot/Features/
Sessions/Wizard/:

- DiscordWizardContextStore: IWizardContextStore abstraction +
  thread-safe in-memory impl keyed by draft id. Holds the (guild,
  channel, message) coordinates the messenger needs to re-send the
  draft after a 15-minute interaction token expires.
- DiscordWizardStep: renderer for all 15 wizard steps. Returns an
  embed plus an IReadOnlyList<IMessageComponentProperties> that mixes
  ActionRow buttons with StringMenu select menus. Also exposes
  BuildModal for the 8 modal-collecting steps.
- DiscordWizardMessenger: IWizardMessenger impl backed by NetCord's
  RestClient + NpgsqlDataSource. Edit falls back to re-send on
  401/403/404. Toast replies are stashed in the existing
  DiscordInteractionReplyCache.
- DiscordWizardSubmitter: 3-retry finalize loop. Builds the shared
  CreateSessionCommand and calls CreateSessionHandler; on success
  edits the message to "ok Created: N sessions", on failure shows
  retry/cancel buttons.
- DiscordWizardCommand: /newsession-wizard slash command with an
  optional mode param (single|pool). Owner/co-GM check via the
  shared group_managers table.
- DiscordPermissionLookup: small helper that loads DB manager ids
  for a guild.

Program.cs gets 5 new singleton registrations (IWizardDraftRepository,
IWizardContextStore, IWizardMessenger, GameCreationWizard,
DiscordWizardSubmitter). The slash command is auto-discovered by
AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
+ AddModules(typeof(Program).Assembly).

Build green. All 85 wizard tests + 95 Discord tests pass.
dotnet format clean.

Open: DiscordWizardInteractionModule (button/modal handlers) is not
yet implemented; the bot starts and /newsession-wizard works to the
point of posting the first embed, but subsequent button clicks won't
be handled. A follow-up commit will add the component-interaction
module.
2026-06-05 17:52:29 +03:00
Toutsu 8f0f2ef7e7 refactor(wizard): move core to Shared, add IWizardMessenger contract (issue #112)
Moves the game-creation wizard state machine, view builder, and
platform-neutral contracts (callback data, step names, storage
exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared.
Telegram continues to work through a new TelegramWizardMessenger
implementing IWizardMessenger and a WizardInteractionMapper that
converts Update → WizardInteraction. Wires the new platform column on
wizard_drafts (V032 migration) and switches chat/owner/thread/message
ids to TEXT so the same table can hold Discord snowflakes later.

- GameCreationWizard: now in Shared, takes IWizardMessenger +
  IWizardDraftRepository, dispatches on WizardInteraction.
- New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs
  (returns string ids so Telegram longs and Discord snowflakes both
  fit).
- New WizardStepViewBuilder in Shared returns
  (text, IReadOnlyList<WizardAction>); TelegramWizardMessenger
  renders actions into InlineKeyboardMarkup via a new Bot-side
  ToInlineKeyboard helper.
- New WizardInteractionMapper in Bot (5-case test) converts Telegram
  Update to WizardInteraction.
- WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/
  DraftMessageId switched to string. V032 migrates existing rows and
  rebuilds the owner lookup index on (platform, owner_id).
- All existing wizard / create-session tests updated to the new
  contract (HandleInteractionAsync + WizardInteraction). Wizard
  callback-data format preserved.
- dotnet build clean, dotnet format --verify-no-changes clean, all
  101 wizard tests pass.
2026-06-05 16:23:20 +03:00
Toutsu 71080aeab6 @chore(release): bump version to 3.8.0 (issue #111)
Deploy Telegram Bot / build-and-push (push) Successful in 5m3s
Deploy Telegram Bot / scan-images (push) Successful in 1m40s
Deploy Telegram Bot / deploy (push) Successful in 37s
Game-creation wizard: replace /newsession text template with step-by-step
inline-keyboard flow for single games and game pools.

- Directory.Build.props: 3.7.1 -> 3.8.0
- compose.yaml: pin bot, discord-bot and web images to 3.8.0
- deploy.yml VERSION env: 3.7.1 -> 3.8.0
- NavMenu.razor: nav-version 3.7.1 -> 3.8.0

🤖 Generated with Claude Code
@
2026-06-04 15:49:01 +03:00
90 changed files with 6120 additions and 711 deletions
+38 -2
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.7.1
VERSION: 3.11.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -70,13 +70,47 @@ jobs:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.codeanddice.ru
username: toutsu
password: ${{ secrets.GIT_TOKEN }}
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale:
# 1. install.sh resolves the positional tag against the
# GitHub releases API; when a release is unpublished or
# yanked, the script fails with
# `unable to find '<tag>' - use 'latest' or see ...`
# when the release once existed. We hit this with
# v0.71.0.
# 2. Docker Hub tags are content-addressed and rarely
# removed, so a pinned image tag is much more stable.
# 3. The image is multi-arch (linux/amd64, linux/arm64,
# linux/ppc64le, linux/s390x) so the same tag works on
# the GitHub-hosted runner and on the ARM64 Pi runner.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Pull images for scan
run: |
docker pull git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
docker pull git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
docker pull git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
- name: Scan Bot image
run: |
trivy image \
--timeout 30m \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
@@ -85,6 +119,7 @@ jobs:
- name: Scan Discord Bot image
run: |
trivy image \
--timeout 30m \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
@@ -93,6 +128,7 @@ jobs:
- name: Scan Web image
run: |
trivy image \
--timeout 30m \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
+22 -3
View File
@@ -47,13 +47,25 @@ jobs:
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale (see deploy.yml for the long
# version): the GitHub release tag we pinned (v0.71.0) was
# unpublished, and install.sh fails hard on missing tags.
# Docker Hub images are content-addressed and rarely removed,
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Trivy filesystem security scan
run: |
set +e
trivy fs --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
trivy fs --timeout 30m --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
trivy_exit="${PIPESTATUS[0]}"
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
echo "::error::Trivy did not detect any language-specific dependency files."
@@ -78,4 +90,11 @@ jobs:
# ── Tests ──
- name: Run tests
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
run: |
# Exclude Testcontainers-backed PostgreSQL integration collections from PR CI.
# The ARM64 runner is too slow to reliably start Postgres containers and apply
# migrations before the default timeouts expire. These tests are still run
# locally and can be executed manually with `dotnet test`.
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj \
--filter "FullyQualifiedName!~PortfolioMigrationPostgresTests&FullyQualifiedName!~CreateSessionHandlerIntegrationTests&FullyQualifiedName!~WizardDraftRepositoryTests&FullyQualifiedName!~DbSessionTriggerStoreTests&Collection!~CreateSessionHandlerPostgresCollection" \
--verbosity normal
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.7.1</Version>
<Version>3.11.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -3
View File
@@ -4,14 +4,14 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.6.0`.
**Текущая версия:** `v3.10.0`.
---
## ✨ Key Features
### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
- **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация.
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
@@ -127,7 +127,7 @@ docker compose up -d
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
5. Перезапустите Docker Compose (`docker compose up -d`), затем создайте расписание: в Telegram через `/newsession` выберите `Online` и URL подключения или `Offline` и адрес места проведения; в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`.
## 📚 Портфолио завершённых приключений
+93 -5
View File
@@ -1,8 +1,96 @@
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
## 🎯 Minor 3.10.0 — Online/offline format in /newsession wizard (issue #136)
### 🧩 Что вошло в релиз
- Telegram `/newsession` wizard теперь запрашивает формат `Online` / `Offline`.
- Для `Online` мастер вводит URL подключения; для `Offline` — адрес места проведения.
- Offline-адрес сохраняется в `sessions.location_address` через миграцию `V033__add_session_location_address.sql`.
- Telegram schedule messages показывают URL online-игры или адрес offline-встречи; Web duplicate Telegram renderer синхронизирован.
### 📦 Версия и деплой
- Версия обновлена до 3.10.0 (`Directory.Build.props`, `NavMenu.razor`, `.gitea/workflows/deploy.yml`).
- Docker-образы тегируются `3.10.0` в `compose.yaml`.
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync``PlatformNotSupportedException``GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
### 🩹 Что починено
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync``new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
### ⚠️ Известные ограничения
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
### 🧪 Тесты
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator``PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
### 🩹 Что починено
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs``CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs``DateTimeOffset.UtcNow``DateTime.UtcNow` в новых драфтах.
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`).
### ⚠️ Известные ограничения
- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады.
### 🧪 Тесты
- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17<WizardDraft>` + `CommandFactory30<WizardDraft>`.
## 🎯 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<TContext>` (button/StringMenu/Modal) + `WizardInteractionDispatcher`
- `Features/Sessions/Wizard/DiscordPermissionLookup.cs` — DB-хелпер для `group_managers`
- `Program.cs` — DI-регистрации + 3 `AddComponentInteractions<TInteraction, TContext>`
**Тесты**
- `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 +101,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
- Docker-образы используют тег 2.4.0
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.1
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.1
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.1
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0
restart: always
depends_on:
db:
+153
View File
@@ -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<IMessageComponentProperties>` (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<TContext>`
shells (button / StringSelectMenu / modal) share one
`WizardInteractionDispatcher` that:
1. parses the custom-id tail (`btn:choice:<step>:<value>`,
`btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
`btn:resume:restart`, `select:<step>`, `modal:<step>`);
2. loads the active draft via
`IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
3. routes the callback through the shared
`GameCreationWizard.HandleInteractionAsync` (or
`DiscordWizardSubmitter.SubmitAsync` for the `create` button);
4. opens a modal popup when the new step needs text input,
otherwise acks with a deferred message so Discord doesn't show
"Application did not respond".
DI / tests:
- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
(`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
`GameCreationWizard`, `DiscordWizardSubmitter`,
`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
3 `AddComponentInteractions<TInteraction, TContext>` 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<TContext>`, 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:<step>:<value>` | 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:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
| Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
parse the rest.
## 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
+54
View File
@@ -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
"""
+362
View File
@@ -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:<step>:<value>`. 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<TInteraction, TContext>` 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
24), 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.
@@ -8,6 +8,9 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
<!-- Overrides transitive vulnerable MessagePack 2.5.192 pulled by Aspire.Hosting.PostgreSQL.
See GHSA-hv8m-jj95-wg3x / CVE-2026-48109. -->
<PackageReference Include="MessagePack" Version="2.5.301" />
</ItemGroup>
<PropertyGroup>
+12 -11
View File
@@ -83,6 +83,16 @@
"System.IO.Hashing": "10.0.3"
}
},
"MessagePack": {
"type": "Direct",
"requested": "[2.5.301, )",
"resolved": "2.5.301",
"contentHash": "WUnJgmYc06ngIxZxLe9sa0P6rOTyOZIQn8SuDvJSjyMn7e8/AdlNAdt81WPUhWKeQ7hDkgxKU1vTrJqX/4L79A==",
"dependencies": {
"MessagePack.Annotations": "2.5.301",
"Microsoft.NET.StringTools": "17.6.3"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
@@ -248,19 +258,10 @@
"YamlDotNet": "16.3.0"
}
},
"MessagePack": {
"type": "Transitive",
"resolved": "2.5.192",
"contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==",
"dependencies": {
"MessagePack.Annotations": "2.5.192",
"Microsoft.NET.StringTools": "17.6.3"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "2.5.192",
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
"resolved": "2.5.301",
"contentHash": "3PyBiSeKTfvtyzUv3+9eXGIw7vBBZ0GAc4k3+RVT0tz2vKv3l0pviiA2b6DrmHyDvj1Au8lSVDDw/wKPMxUQ4A=="
},
"Microsoft.Extensions.AI.Abstractions": {
"type": "Transitive",
+4 -2
View File
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
WORKDIR /app
# Устанавливаем wget для healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
# Копируем только AOT-результаты из билда
@@ -70,7 +70,17 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
@"SELECT id as SessionId,
scheduled_at as ScheduledAt,
status as Status,
max_players as MaxPlayers,
join_link as JoinLink,
format as Format,
location_address as LocationAddress,
description as Description,
system as System,
duration_minutes as DurationMinutes,
is_one_shot as IsOneShot
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at",
@@ -1,57 +1,66 @@
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 Dapper;
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 Npgsql;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
/// <summary>
/// Wizard-driven entry point for game-session creation. Replaces the legacy
/// text-template parser. Exposes <see cref="StartWizardAsync"/> (called from
/// <c>/newsession</c>), <see cref="TryResumeAsync"/> (continue a draft), and
/// <see cref="SubmitDraftAsync"/> (finalize on "✅ Создать" callback).
/// Telegram-side entry point for the wizard-driven session creation
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
/// platform glue (mapping <c>Message</c> to draft fields, rendering
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
/// </summary>
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<CreateSessionHandler> _log;
private readonly IPlatformMessenger? _platformMessenger;
private readonly NpgsqlDataSource? _dataSource;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
ITelegramWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
IWizardMessenger messenger,
ILogger<CreateSessionHandler> log,
IPlatformMessenger? platformMessenger = null,
NpgsqlDataSource? dataSource = null)
{
_drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
_platformMessenger = platformMessenger;
_dataSource = dataSource;
}
/// <summary>
/// Entry point for <c>/newsession</c>. If a non-expired draft already exists for
/// this (chat, thread, owner), returns <c>null</c> so the caller can render a
/// "Continue / Start over / Cancel" menu.
/// Entry point for <c>/newsession</c>. If a non-expired draft
/// already exists for this owner, returns <c>null</c> so the caller
/// can render a "Continue / Start over / Cancel" menu.
/// </summary>
public async Task<WizardDraft?> 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,60 +69,69 @@ 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,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
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;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
return draft;
}
/// <summary>
/// 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.
/// </summary>
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct) =>
_drafts.GetActiveAsync(
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
{
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
}
/// <summary>
/// Finalize: build shared command(s), call the shared handler, edit the wizard message.
/// On failure, retry up to <see cref="MaxRetries"/> times before deleting the draft.
/// Finalize: build shared command(s), call the shared handler, edit
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
/// times before deleting the draft.
/// </summary>
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<WizardAction>(), ct);
return;
}
var commands = BuildCommands(draft, payload);
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
try
{
foreach (var cmd in commands)
{
await _shared.HandleAsync(cmd, ct);
var result = await _shared.HandleAsync(cmd, ct);
if (!result.Success)
{
await _messenger.EditDraftMessageAsync(
draft,
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
Array.Empty<WizardAction>(),
ct);
return;
}
created.Add((cmd, result));
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
EmptyKeyboard(), ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
catch (Exception ex)
{
@@ -122,26 +140,109 @@ 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<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.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);
return;
}
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
try
{
foreach (var item in created)
{
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
{
if (_platformMessenger is null || _dataSource is null)
{
throw new InvalidOperationException("Session publication dependencies are not configured.");
}
if (result.View is null || result.BatchId is null)
{
throw new InvalidOperationException("Created session result does not contain publication data.");
}
var group = command.Group;
var topicCreatedByBot = false;
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
group = group with { ExternalThreadId = thread.ExternalThreadId };
topicCreatedByBot = true;
}
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
ct);
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET thread_id = @ThreadId,
batch_message_id = @BatchMessageId,
topic_created_by_bot = @TopicCreatedByBot,
updated_at = now()
WHERE batch_id = @BatchId
""",
new
{
result.BatchId,
ThreadId = ParseNullableInt(group.ExternalThreadId),
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
TopicCreatedByBot = topicCreatedByBot
});
}
private static int ParseInt(string value) =>
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value)
? null
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
// ── 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<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
@@ -153,7 +254,7 @@ public sealed class CreateSessionHandler
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false)
isOneShot: false),
};
}
return new List<CreateSessionCommand>
@@ -162,46 +263,46 @@ public sealed class CreateSessionHandler
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
isOneShot: true)
p.Single?.MaxPlayers,
isOneShot: true),
};
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand(
internal static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers,
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,
Title: p.Title ?? string.Empty,
Link: string.Empty,
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
Format: p.Format?.ToString(),
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
IsOneShot: isOneShot,
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
}
private static GameSystem? ParseSystem(string? code)
@@ -217,12 +318,16 @@ public sealed class CreateSessionHandler
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Format is null) missingFields.Add("формат");
if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка");
if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) 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("лимит мест");
// MaxPlayers = null is a valid "♾ Без лимита" choice
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
}
else
{
@@ -245,10 +350,9 @@ public sealed class CreateSessionHandler
}
// ── Keyboards ────────────────────────────────────────────────────
private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty<InlineKeyboardButton[]>());
private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[]
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}
@@ -139,7 +139,13 @@ public sealed class PromoteWaitlistedPlayerHandler(
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers,
join_link AS JoinLink
join_link AS JoinLink,
format AS Format,
location_address AS LocationAddress,
description AS Description,
system AS System,
duration_minutes AS DurationMinutes,
is_one_shot AS IsOneShot
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
@@ -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<long> EditMessageTextAsync(long chatId, int? messageThreadId, long messageId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
Task<long> SendGroupMessageAsync(long chatId, int? messageThreadId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct);
Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct);
}
@@ -3,68 +3,140 @@ 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;
/// <summary>
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
/// Translates the platform-neutral wizard contracts into the
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
/// (message editing, callback ack, group lookup) lives behind the
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
/// </summary>
public sealed class TelegramWizardMessenger(
ITelegramBotClient bot,
NpgsqlDataSource dataSource) : ITelegramWizardMessenger
NpgsqlDataSource dataSource) : IWizardMessenger
{
public async Task<long> EditMessageTextAsync(
long chatId, int? messageThreadId, long messageId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
public async Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<long> SendGroupMessageAsync(
long chatId, int? messageThreadId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
public async Task<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
public async Task<IReadOnlyList<WizardClubOption>> 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,
// and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream.
//
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
// (sql, object?) extension overload — not the (CommandDefinition) overload.
// The wizard reaches this method on the PickClub visibility step
// (issue #112 follow-up); using CommandDefinition here would fall back
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
// and throws PlatformNotSupportedException on AOT. Same root cause as
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
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 = '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<WizardClubOption>(
new CommandDefinition(sql, new { ExternalId = ownerTelegramId.ToString() }, cancellationToken: ct));
sql,
new { Platform = "Telegram", ExternalId = ownerId });
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;
}
}
@@ -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;
/// <summary>
/// Converts a Telegram <see cref="Update"/> into the
/// platform-neutral <see cref="WizardInteraction"/> consumed by
/// <see cref="GameCreationWizard"/>. The mapping is the only place in
/// the bot that knows about both <c>Telegram.Bot.Types</c> and the
/// shared wizard contract, so a future Discord adapter can do the same
/// for its native event without changing the wizard core.
/// </summary>
public static class WizardInteractionMapper
{
/// <summary>
/// Returns <c>true</c> if <paramref name="update"/> carries a
/// wizard-relevant interaction (text message, photo, or
/// callback). Side-effect-free: the wizard state is not touched.
/// </summary>
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;
}
}
@@ -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;
/// <summary>
/// Telegram-side renderer for wizard keyboards. Acts as the adapter
/// between the platform-neutral <see cref="WizardAction"/> list
/// produced by <see cref="WizardStepViewBuilder"/> and Telegram's
/// <see cref="InlineKeyboardMarkup"/>. Each <see cref="WizardAction"/>
/// becomes its own row (matching the pre-refactor Telegram layout).
/// <see cref="WizardActionStyle"/> is currently ignored by Telegram
/// because the platform has no native primary/danger/success button
/// colours.
/// </summary>
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(
/// <summary>
/// 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.
/// </summary>
public static (string Text, InlineKeyboardMarkup Keyboard) Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? 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<WizardClubOption>()),
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()
/// <summary>
/// Convert a flat list of <see cref="WizardAction"/>s into a
/// Telegram keyboard. Each action is placed in its own row to
/// preserve the pre-refactor visual layout.
/// </summary>
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
{
var buttons = new List<InlineKeyboardButton[]>
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<WizardClubOption> clubs)
{
if (clubs.Count == 0)
{
return (
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
BackCancel());
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
}
var rows = new List<InlineKeyboardButton[]>();
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;
}
@@ -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) { }
}
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -405,19 +405,8 @@ public sealed class TelegramPlatformMessenger(
Ответьте кнопкой в групповом сообщении расписания.
""",
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
⏰ <b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.JoinLink => $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.OneHourReminder => BuildOneHourReminderDirectText(notification),
PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification),
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
✅ <b>Сессия перенесена по итогам голосования</b>
@@ -434,6 +423,39 @@ public sealed class TelegramPlatformMessenger(
_ => BuildFallbackDirectText(notification)
};
private static string BuildOneHourReminderDirectText(PlatformDirectSessionNotification notification)
{
var lines = new List<string>
{
"⏰ <b>Игра начнётся примерно через 1 час</b>",
string.Empty,
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>",
$"📅 {notification.ScheduledAt.FormatMoscow()} (МСК)"
};
AppendJoinLinkLine(lines, notification.JoinLink);
return string.Join("\n", lines);
}
private static string BuildJoinLinkDirectText(PlatformDirectSessionNotification notification)
{
var lines = new List<string>
{
"🎮 <b>Игра начинается через 5 минут</b>",
string.Empty,
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>"
};
AppendJoinLinkLine(lines, notification.JoinLink);
return string.Join("\n", lines);
}
private static void AppendJoinLinkLine(List<string> lines, string? joinLink)
{
if (!string.IsNullOrWhiteSpace(joinLink))
{
lines.Add($"🔗 {System.Net.WebUtility.HtmlEncode(joinLink)}");
}
}
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
@@ -17,15 +17,49 @@ public static class TelegramSessionBatchRenderer
foreach (var session in view.Sessions)
{
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink))
var tags = new List<string>();
if (!string.IsNullOrWhiteSpace(session.System))
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
if (!string.IsNullOrWhiteSpace(session.Format))
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
if (tags.Count > 0)
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
}
if (session.DurationMinutes.HasValue)
{
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
}
if (!string.IsNullOrWhiteSpace(session.Description))
{
messageText += $"📝 <b>Описание:</b>\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n";
}
var format = session.Format ?? string.Empty;
var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase);
var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase);
var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase);
if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink))
{
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
}
if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
{
messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
}
messageText += session.MaxPlayers.HasValue
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
@@ -38,7 +72,7 @@ public static class TelegramSessionBatchRenderer
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
@@ -60,4 +94,14 @@ public static class TelegramSessionBatchRenderer
return (messageText, new InlineKeyboardMarkup(buttons));
}
private static string FormatDuration(int minutes)
{
if (minutes <= 0) return "0 мин";
var hours = minutes / 60;
var mins = minutes % 60;
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
if (hours > 0) return $"{hours} ч";
return $"{mins} мин";
}
}
@@ -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).
/// </summary>
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:
@@ -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);
@@ -0,0 +1,2 @@
ALTER TABLE sessions
ADD COLUMN location_address TEXT;
+2 -2
View File
@@ -73,8 +73,8 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.Create
// Wizard services (issue #111)
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GameCreationWizard>();
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
@@ -145,7 +145,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
return;
var sessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList();
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Small lookup helper for Discord permission checks. The
/// <see cref="DiscordNewSessionHandler"/> 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.
/// </summary>
internal static class DiscordPermissionLookup
{
public static async Task<IReadOnlyList<ulong>> 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);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await connection.QueryAsync<ulong>(
sql,
new { GuildId = guildId.ToString() });
return rows.ToList();
}
}
@@ -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;
/// <summary>
/// Slash entry point for the Discord wizard. Mirrors the Telegram
/// <c>/newsession-wizard</c> 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 <see cref="DiscordPermissionChecker"/> is
/// applied before any draft is created.
/// </summary>
public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordWizardMessenger _messenger;
private readonly DiscordPermissionChecker _permissions;
private readonly IWizardDraftRepository _drafts;
private readonly IWizardContextStore _contextStore;
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<DiscordWizardCommand> _log;
public DiscordWizardCommand(
DiscordWizardMessenger messenger,
DiscordPermissionChecker permissions,
IWizardDraftRepository drafts,
IWizardContextStore contextStore,
NpgsqlDataSource dataSource,
ILogger<DiscordWizardCommand> 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 = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.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 = DateTime.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<IMessageComponentProperties> 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 };
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Concurrent;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// 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.
/// </summary>
/// <param name="GuildId">Discord guild (server) id as a decimal string.</param>
/// <param name="ChannelId">Channel id where the draft message was posted.</param>
/// <param name="MessageId">Id of the currently active draft message.</param>
/// <param name="ThreadId">Optional thread id; <c>null</c> for top-level channel posts.</param>
public sealed record DiscordWizardContext(
string GuildId,
string ChannelId,
string MessageId,
string? ThreadId);
/// <summary>
/// In-memory store of draft → context lookups. Lives for the lifetime of
/// the process; the wizard's 24-hour expiry is enforced by
/// <c>WizardDraftCleanupService</c>, so this cache is allowed to hold
/// entries until the draft is finalized or explicitly removed.
/// </summary>
public interface IWizardContextStore
{
void Set(Guid draftId, DiscordWizardContext context);
bool TryGet(Guid draftId, out DiscordWizardContext context);
void Remove(Guid draftId);
}
/// <summary>
/// Thread-safe in-memory implementation. Concurrent dictionary keyed by
/// <see cref="Guid"/> is sufficient for a single-process Discord bot.
/// </summary>
public sealed class DiscordWizardContextStore : IWizardContextStore
{
private readonly ConcurrentDictionary<Guid, DiscordWizardContext> 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 _);
}
@@ -0,0 +1,546 @@
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;
/// <summary>
/// Inbound component-interaction handler for the Discord wizard.
///
/// One class per interaction context type — NetCord's
/// <c>ComponentInteractionModule&lt;TContext&gt;</c> is single-context. All
/// three classes share the same dispatch table (parse customId → load
/// draft → call the shared
/// <see cref="SharedWizard.HandleInteractionAsync"/>) implemented in
/// <see cref="WizardInteractionDispatcher"/>.
///
/// Custom-id wire format (see <see cref="DiscordWizardStep"/>):
/// <list type="bullet">
/// <item><c>wizard:btn:choice:&lt;step&gt;:&lt;value&gt;</c> — choice buttons</item>
/// <item><c>wizard:btn:cancel</c>, <c>wizard:btn:back</c>, <c>wizard:btn:create</c></item>
/// <item><c>wizard:btn:resume:&lt;continue|restart&gt;</c></item>
/// <item><c>wizard:select:&lt;step&gt;</c> — StringSelectMenu</item>
/// <item><c>wizard:modal:&lt;step&gt;</c> — Modal submit</item>
/// </list>
///
/// The active draft is looked up by (platform="Discord", ownerId=userId);
/// the custom-id never carries a draft id because the wizard assumes one
/// active draft per owner.
/// </summary>
public sealed class DiscordWizardButtonModule : ComponentInteractionModule<ButtonInteractionContext>
{
private readonly WizardInteractionDispatcher _dispatcher;
public DiscordWizardButtonModule(WizardInteractionDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
[ComponentInteraction("wizard")]
public Task HandleAsync(string args) =>
_dispatcher.HandleButtonAsync(Context, args);
}
public sealed class DiscordWizardStringMenuModule : ComponentInteractionModule<StringMenuInteractionContext>
{
private readonly WizardInteractionDispatcher _dispatcher;
public DiscordWizardStringMenuModule(WizardInteractionDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
[ComponentInteraction("wizard")]
public Task HandleAsync(string args) =>
_dispatcher.HandleStringMenuAsync(Context, args);
}
public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalInteractionContext>
{
private readonly WizardInteractionDispatcher _dispatcher;
public DiscordWizardModalModule(WizardInteractionDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
[ComponentInteraction("wizard")]
public Task HandleAsync(string args) =>
_dispatcher.HandleModalAsync(Context, args);
}
/// <summary>
/// Shared dispatch table for the three wizard interaction modules.
/// Owns all the stateful collaborators (drafts, context store, wizard
/// state machine, submitter, messenger, reply cache) so the three
/// NetCord module shells can stay trivially thin.
/// </summary>
public sealed class WizardInteractionDispatcher
{
private readonly IWizardDraftRepository _drafts;
private readonly IWizardContextStore _contextStore;
private readonly SharedWizard _wizard;
private readonly DiscordWizardSubmitter _submitter;
private readonly DiscordWizardMessenger _messenger;
private readonly DiscordInteractionReplyCache _replies;
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<WizardInteractionDispatcher> _log;
public WizardInteractionDispatcher(
IWizardDraftRepository drafts,
IWizardContextStore contextStore,
SharedWizard wizard,
DiscordWizardSubmitter submitter,
DiscordWizardMessenger messenger,
DiscordInteractionReplyCache replies,
NpgsqlDataSource dataSource,
ILogger<WizardInteractionDispatcher> log)
{
_drafts = drafts;
_contextStore = contextStore;
_wizard = wizard;
_submitter = submitter;
_messenger = messenger;
_replies = replies;
_dataSource = dataSource;
_log = log;
}
/// <summary>
/// Steps that, after the wizard's state advance, expect the user
/// to fill a popup. The dispatcher uses this to decide whether
/// the interaction response is a Modal() (the user sees a popup)
/// or a DeferredMessage() (the wizard's edit is the only visible
/// feedback). Keep in sync with <see cref="DiscordWizardStep.OpenModalStep"/>
/// returns.
/// </summary>
private static readonly IReadOnlySet<string> StepsThatOpenModal = new HashSet<string>(StringComparer.Ordinal)
{
WizardStepNames.Title,
WizardStepNames.Description,
WizardStepNames.Cover,
WizardStepNames.DateTime,
WizardStepNames.Capacity,
WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolSlotCapacity,
"SystemFreeText",
"DurationFreeText",
"PoolSystemDurationFreeText",
};
// ── Button handler ────────────────────────────────────────────────
public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
{
// NetCord only allows one response per interaction. The
// previous implementation deferred too early and then
// tried to "swap" the deferred response for a Modal — which
// NetCord forbids. The new flow is: do the wizard work
// (which is a separate REST call to edit the draft message),
// THEN send the interaction response. The response is
// either a Modal popup (when the new step needs text input)
// or a plain DeferredMessage ack.
var ct = CancellationToken.None;
// args looks like one of:
// "btn:choice:<step>:<value>" (a choice)
// "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:<step>" — 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:<step>" (e.g. "select:Visibility" or
// "select:PoolSystemDuration"). The chosen value lives in
// SelectedValues[0].
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
if (draft is null)
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
// 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:<step>" (e.g. "modal:Title" or
// "modal:SystemFreeText"). The text value lives in
// Data.Components[0].Component.Value (the wizard sends a
// single Label wrapping a single TextInput).
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
if (draft is null)
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
// 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<IReadOnlyList<WizardClubOption>?> 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 */
}
}
}
/// <summary>
/// Standalone helper that queries the owner-club list without going
/// through <see cref="DiscordWizardMessenger"/>. The dispatcher needs
/// the list at the PickClub step; reusing the messenger's GetOwnerClubsAsync
/// would create a circular DI graph (the messenger depends on
/// IWizardContextStore which the dispatcher also needs).
/// </summary>
internal static class WizardClubLookup
{
public static async Task<IReadOnlyList<WizardClubOption>> LoadClubsAsync(
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);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await conn.QueryAsync<WizardClubOption>(
sql,
new { Platform = "Discord", OwnerId = ownerId });
return rows.AsList();
}
}
@@ -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;
/// <summary>
/// Discord-side implementation of <see cref="IWizardMessenger"/>.
/// 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
/// <see cref="AnswerInteractionAsync"/> stashes the toast text in
/// <see cref="DiscordInteractionReplyCache"/>; the inbound module
/// drains the cache and ships the actual <c>SendResponseAsync</c> call
/// via the existing helper used by <c>DiscordPlatformMessenger</c>.
/// </summary>
public sealed class DiscordWizardMessenger : IWizardMessenger
{
private readonly RestClient _rest;
private readonly NpgsqlDataSource _dataSource;
private readonly DiscordInteractionReplyCache _replies;
private readonly IWizardContextStore _contextStore;
private readonly ILogger<DiscordWizardMessenger>? _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<DiscordWizardMessenger>? logger)
{
_rest = rest;
_dataSource = dataSource;
_replies = replies;
_contextStore = contextStore;
_log = logger;
}
public async Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<IReadOnlyList<WizardClubOption>> 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);
// NativeAOT: direct (sql, params) overload — see
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
var rows = await conn.QueryAsync<WizardClubOption>(
sql,
new { Platform = "Discord", ExternalId = ownerId });
return rows.AsList();
}
// ── Embed + component construction ────────────────────────────────
private (EmbedProperties? embed, IReadOnlyList<IMessageComponentProperties> rows) BuildEmbedAndRows(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<IMessageComponentProperties> BuildActionRowsFromActions(
IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return Array.Empty<IMessageComponentProperties>();
}
var rows = new List<IMessageComponentProperties>();
// 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;
}
@@ -0,0 +1,614 @@
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;
/// <summary>
/// Renders a wizard step into a Discord embed + components row + an
/// optional modal popup. Lives in the DiscordBot project because the
/// platform-neutral <see cref="WizardStepViewBuilder"/> doesn't know
/// about embeds, action rows, or modals.
///
/// The renderer also exposes <see cref="OpenModal"/> 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.
/// </summary>
public static class DiscordWizardStep
{
/// <summary>
/// Discord custom-id budget is 100 characters. We pad our own
/// identifiers to stay under it.
/// </summary>
public const int MaxCustomIdLength = 100;
/// <summary>
/// Sentinel returned in <see cref="DiscordWizardRender.OpenModalStep"/>
/// when the renderer wants the interaction module to open a modal
/// for the given step. The step name is the <see cref="WizardStepNames"/>
/// value (e.g. <c>Title</c>, <c>DateTime</c>).
/// </summary>
public sealed record DiscordWizardRender(
string EmbedTitle,
string EmbedDescription,
IReadOnlyList<IMessageComponentProperties> Components,
string? OpenModalStep);
/// <summary>
/// Build the embed + components for a wizard step. The caller is
/// responsible for sending the message via
/// <see cref="DiscordWizardMessenger"/>.
/// </summary>
public static DiscordWizardRender Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? 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<WizardClubOption>()),
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:<step>:<value> → wizard's ApplyChoice
// Control : wizard:btn:<action>:1 → dispatcher special case
// Modal trig. : wizard:btn:modal:<modalStep> → 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;
}
/// <summary>
/// Wrap a list of top-level message components (action rows and
/// select menus) into a single <see cref="IReadOnlyList{IMessageComponentProperties}"/>
/// for <c>MessageProperties.WithComponents</c>.
/// </summary>
private static IReadOnlyList<IMessageComponentProperties> 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(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
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<WizardClubOption> 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<StringMenuSelectOptionProperties>(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(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
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<StringMenuSelectOptionProperties> 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();
}
else if (p.Type == WizardCreationType.Single)
{
sb.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 ────────────────────────────────────────────────
/// <summary>
/// Build a <see cref="ModalProperties"/> for the given wizard step.
/// The wizard step's <c>openModal</c> value drives which modal we
/// emit. Returns <c>null</c> if no modal is required for the step.
/// </summary>
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,
};
}
}
@@ -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;
/// <summary>
/// Finalises a wizard draft by calling the shared
/// <see cref="CreateSessionHandler"/>. 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.
/// </summary>
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<DiscordWizardSubmitter> _log;
public DiscordWizardSubmitter(
CreateSessionHandler shared,
RestClient rest,
IWizardDraftRepository drafts,
IWizardContextStore contextStore,
ILogger<DiscordWizardSubmitter> log)
{
_shared = shared;
_rest = rest;
_drafts = drafts;
_contextStore = contextStore;
_log = log;
}
/// <summary>
/// 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 <see cref="MaxRetries"/>
/// consecutive failures the draft is deleted.
/// </summary>
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<WizardAction>(),
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<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
return;
}
draft.UpdatedAt = DateTime.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<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
{
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false),
};
}
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers,
isOneShot: true),
};
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
internal static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> 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<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
}
// ── Validation ───────────────────────────────────────────────────
private static bool IsComplete(WizardPayload p, out string missing)
{
var missingFields = new List<string>();
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<WizardAction> 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<IMessageComponentProperties> BuildActionRowsFromActions(
IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return Array.Empty<IMessageComponentProperties>();
}
var rows = new List<IMessageComponentProperties>();
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<WizardAction> RetryCancelActions() => new[]
{
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}
@@ -8,6 +8,7 @@
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
<NoWarn>$(NoWarn);DAP005</NoWarn>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
</PropertyGroup>
<ItemGroup>
+23
View File
@@ -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;
@@ -25,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
using NetCord.Services.ComponentInteractions;
using Npgsql;
[module: Dapper.DapperAot]
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
@@ -82,6 +86,23 @@ builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
// ── 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<IWizardDraftRepository, GmRelay.Shared.Features.Sessions.CreateSession.Wizard.WizardDraftRepository>();
builder.Services.AddSingleton<IWizardContextStore, DiscordWizardContextStore>();
builder.Services.AddSingleton<IWizardMessenger, DiscordWizardMessenger>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
builder.Services.AddSingleton<DiscordWizardSubmitter>();
builder.Services.AddSingleton<WizardInteractionDispatcher>();
builder.Services.AddSingleton<DiscordWizardButtonModule>();
builder.Services.AddSingleton<DiscordWizardStringMenuModule>();
builder.Services.AddSingleton<DiscordWizardModalModule>();
builder.Services
.AddDiscordGateway(options =>
{
@@ -90,6 +111,8 @@ builder.Services
})
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
.AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>()
.AddComponentInteractions<ModalInteraction, ModalInteractionContext>()
.AddGatewayHandlers(typeof(Program).Assembly);
var host = builder.Build();
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
@@ -57,6 +57,7 @@ public sealed class SendJoinLinkHandler(
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND btrim(s.join_link) <> ''
AND (
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
OR (
@@ -15,4 +15,5 @@ public sealed record CreateSessionCommand(
string? Description = null,
string? Format = null,
int? DurationMinutes = null,
bool IsOneShot = false);
bool IsOneShot = false,
string? LocationAddress = null);
@@ -82,7 +82,13 @@ public sealed class CreateSessionHandler(
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
new
{
GroupId = groupId,
Platform = platform,
ExternalGmId = externalUserId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
},
transaction);
}
else
@@ -118,8 +124,8 @@ public sealed class CreateSessionHandler(
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url, location_address)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl, @LocationAddress)
RETURNING id;
""",
new
@@ -136,11 +142,23 @@ public sealed class CreateSessionHandler(
command.Format,
DurationMinutes = command.DurationMinutes,
IsOneShot = command.IsOneShot,
CoverImageUrl = command.ImageReference
CoverImageUrl = command.ImageReference,
command.LocationAddress
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
sessions.Add(new SessionBatchDto(
sessionId,
scheduledAt.UtcDateTime,
SessionStatus.Planned,
command.MaxPlayers,
command.Link,
command.Format,
command.LocationAddress,
command.Description,
command.System?.ToString(),
command.DurationMinutes,
command.IsOneShot));
}
await transaction.CommitAsync(ct);
@@ -135,7 +135,7 @@ public sealed class JoinSessionHandler(
// Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at",
@@ -161,7 +161,9 @@ public sealed class LeaveSessionHandler(
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers,
join_link AS JoinLink
join_link AS JoinLink,
format AS Format,
location_address AS LocationAddress
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
@@ -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;
/// <summary>
/// Central state machine for the game/pool creation wizard.
/// Central state machine for the game/pool creation wizard. Lives in
/// <c>GmRelay.Shared</c> so it can be driven from any platform
/// messenger. Platform-specific code (<c>Telegram.Bot</c>,
/// <c>NetCord</c>, …) lives in the corresponding adapter and converts
/// its native update type into a <see cref="WizardInteraction"/> before
/// calling <see cref="HandleInteractionAsync"/>.
/// </summary>
public sealed class GameCreationWizard
{
private readonly IWizardDraftRepository _drafts;
private readonly ITelegramWizardMessenger _messenger;
private readonly IWizardMessenger _messenger;
private readonly ILogger<GameCreationWizard> _log;
public GameCreationWizard(
IWizardDraftRepository drafts,
ITelegramWizardMessenger messenger,
IWizardMessenger messenger,
ILogger<GameCreationWizard> log)
{
_drafts = drafts;
@@ -29,44 +31,61 @@ public sealed class GameCreationWizard
_log = log;
}
/// <summary>Handle a text or callback update from the owning GM.</summary>
public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct)
/// <summary>
/// Handle a single user interaction with the wizard. Adapters should
/// map their native event (Telegram <c>Update</c>, Discord
/// interaction, …) into a <see cref="WizardInteraction"/> first.
/// </summary>
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<WizardAction>(), 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,26 +164,24 @@ 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;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? 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,18 +225,29 @@ 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
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload);
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Online:
return Uri.TryCreate(input.Trim(), UriKind.Absolute, out var locationUri) &&
(locationUri.Scheme == Uri.UriSchemeHttp || locationUri.Scheme == Uri.UriSchemeHttps)
? (WizardStepNames.Visibility, SetJoinLink(payload, input.Trim()), payload)
: (null, "Некорректная ссылка", payload);
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Offline:
return ValidateText(input, WizardStepLimits.MaxLocationLength, "Адрес не может быть пустым", "Слишком длинный адрес", out var address)
? (WizardStepNames.Visibility, SetLocationAddress(payload, address), payload)
: (null, address, 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);
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
@@ -226,7 +256,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);
@@ -245,6 +275,7 @@ public sealed class GameCreationWizard
WizardStepNames.System => ApplySystemChoice(payload, choice),
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
WizardStepNames.Format => ApplyFormatChoice(payload, choice),
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
@@ -279,10 +310,30 @@ public sealed class GameCreationWizard
: (null, "Неверная длительность"),
};
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
if (choice is "no_limit")
{
return (WizardStepNames.Format, SetMaxPlayers(p, null));
}
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
{
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
}
return choice switch
{
"waitlist:on" => (WizardStepNames.Format, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Format, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
}
private static (string?, string?) ApplyFormatChoice(WizardPayload p, string choice) => choice switch
{
"online" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Online)),
"offline" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Offline)),
_ => (null, "Неизвестный выбор"),
};
@@ -296,9 +347,15 @@ public sealed class GameCreationWizard
};
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id)
? (NextAfterVisibility(p), SetClubId(p, id))
: (null, "Неверный идентификатор клуба");
{
if (!Guid.TryParse(choice, out var id))
{
return (null, "Неверный идентификатор клуба");
}
var error = SetClubId(p, id);
return (NextAfterVisibility(p), error);
}
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{
@@ -311,7 +368,7 @@ public sealed class GameCreationWizard
{
"_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
? (WizardStepNames.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
@@ -353,13 +410,15 @@ public sealed class GameCreationWizard
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity,
WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity,
WizardStepNames.Location => WizardStepNames.Format,
WizardStepNames.Visibility => WizardStepNames.Location,
WizardStepNames.PickClub => WizardStepNames.Visibility,
WizardStepNames.Publish => WizardStepNames.PickClub,
WizardStepNames.Confirm => WizardStepNames.Publish,
WizardStepNames.PoolSystemDuration => null, // first pool step
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
WizardStepNames.PoolAddSlots => WizardStepNames.Visibility,
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
@@ -367,7 +426,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(
@@ -397,13 +456,22 @@ public sealed class GameCreationWizard
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int v)
private static string? SetMaxPlayers(WizardPayload p, int? v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
private static string? SetFormat(WizardPayload p, WizardSessionFormat v)
{
p.Format = v;
p.JoinLink = null;
p.LocationAddress = null;
return null;
}
private static string? SetJoinLink(WizardPayload p, string v) { p.JoinLink = v; p.LocationAddress = null; return null; }
private static string? SetLocationAddress(WizardPayload p, string v) { p.LocationAddress = v; p.JoinLink = null; return null; }
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
{
@@ -450,8 +518,8 @@ public sealed class GameCreationWizard
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
private static string? NextAfterDuration(WizardPayload p)
{
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Format;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
{
@@ -495,10 +563,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<InlineKeyboardButton[]>());
}
@@ -1,21 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// 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).
/// </summary>
public interface IWizardDraftRepository
{
Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct);
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
Task<int> DeleteExpiredAsync(CancellationToken ct);
}
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// 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).
/// </summary>
public enum WizardActionStyle
{
Primary,
Secondary,
Success,
Danger,
}
/// <summary>
/// A single button on a wizard keyboard. <see cref="Payload"/> is the
/// platform-neutral callback token — usually produced by
/// <see cref="WizardCallbackData"/> but adapters are free to interpret
/// any string.
/// </summary>
public sealed record WizardAction(
string Label,
string Payload,
WizardActionStyle Style = WizardActionStyle.Secondary);
/// <summary>
/// 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.
/// </summary>
public sealed record WizardKeyboard(IReadOnlyList<WizardAction> Actions);
/// <summary>
/// A user-owned group/club selectable from the visibility step. Moved
/// from <c>GmRelay.Bot</c> so the wizard can ask for the list without
/// taking a dependency on Telegram.
/// </summary>
public sealed record WizardClubOption(Guid ClubId, string Name);
/// <summary>
/// Platform-neutral user interaction with the wizard. Adapters convert
/// their native event (Telegram <c>Update</c>, Discord interaction, …)
/// into one of these before handing it to <see cref="GameCreationWizard"/>.
/// </summary>
public sealed record WizardInteraction(
string OwnerId,
string? Text,
string? CallbackPayload,
string? PhotoFileId,
string? PhotoUrl,
string InteractionId);
/// <summary>
/// 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).
/// </summary>
public interface IWizardDraftRepository
{
Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct);
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
Task<int> DeleteExpiredAsync(CancellationToken ct);
}
/// <summary>
/// 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).
/// </summary>
public interface IWizardMessenger
{
/// <summary>
/// Edit the message that currently represents the wizard draft.
/// Returns the new message id as a string — Telegram exposes
/// <c>int32</c>, Discord uses 64-bit snowflakes, both fit in
/// <see cref="string"/> for cross-platform uniformity.
/// </summary>
Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct);
/// <summary>
/// Post a fresh wizard draft message and return its id.
/// </summary>
Task<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct);
/// <summary>
/// Acknowledge a callback / interaction. <paramref name="text"/>
/// is an optional toast the user sees briefly.
/// </summary>
Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct);
/// <summary>
/// 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.
/// </summary>
Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct);
}
@@ -1,14 +1,24 @@
using System;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// 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 <c>wizard</c> to keep the namespace separate
/// from the rest of the bot's command callbacks.
/// </summary>
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)
@@ -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; }
/// <summary>
/// Stable string id of the chat/guild/channel this draft lives in.
/// Stored as <c>TEXT</c> to fit both Telegram's <c>long</c> chat ids
/// and Discord's snowflakes.
/// </summary>
public string ChatId { get; set; } = string.Empty;
/// <summary>
/// Optional thread/topic id within the chat. Telegram's
/// <c>message_thread_id</c>, Discord's thread snowflake, <c>null</c>
/// when the chat has no sub-thread concept.
/// </summary>
public string? MessageThreadId { get; set; }
/// <summary>
/// Platform-specific user id of the wizard owner. Telegram uses
/// <c>long</c>, Discord uses snowflakes — both fit in a string.
/// </summary>
public string OwnerId { get; set; } = string.Empty;
/// <summary>
/// Which messenger platform owns this draft. Defaults to
/// <c>"Telegram"</c> for backward compatibility with pre-V032 rows.
/// </summary>
public string Platform { get; set; } = "Telegram";
public string Step { get; set; } = string.Empty;
public string PayloadJson { get; set; } = "{}";
public long? DraftMessageId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
/// <summary>
/// Id of the message that the wizard last edited. Stored as
/// <c>TEXT</c> to fit both Telegram's <c>int32</c> ids and Discord's
/// 64-bit snowflakes.
/// </summary>
public string? DraftMessageId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
@@ -8,14 +8,21 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
{
public async Task<WizardDraft?> GetActiveAsync(
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
// NOTE: NativeAOT — Dapper.AOT 1.0.48 only generates interceptors for the
// (sql, param) extension overloads, NOT for the (CommandDefinition) overload.
// Passing a CommandDefinition here would skip the interceptor and fall back to
// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
// throws PlatformNotSupportedException on AOT (issue: wizard silently dropped
// every Telegram update in v3.9.0). All four methods therefore use the plain
// (sql, param) overload, matching the pattern in JoinSessionHandler.
public async Task<WizardDraft?> 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,27 +30,25 @@ 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<WizardDraft>(
new CommandDefinition(sql,
new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId },
cancellationToken: ct));
sql,
new { Platform = platform, OwnerId = ownerId });
}
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
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,
@@ -51,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
await connection.ExecuteAsync(sql, draft);
}
public async Task DeleteAsync(Guid id, CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
await connection.ExecuteAsync(sql, new { Id = id });
}
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
return await connection.ExecuteAsync(sql);
}
}
@@ -8,6 +8,9 @@ public enum WizardCreationType { Single, Pool }
public enum WizardVisibility { Public, Club, Members }
[JsonConverter(typeof(JsonStringEnumConverter<WizardSessionFormat>))]
public enum WizardSessionFormat { Online, Offline }
public sealed class WizardSlotInput
{
public DateTimeOffset ScheduledAt { get; set; }
@@ -30,6 +33,9 @@ public sealed class WizardPayload
public string? ImageUrl { get; set; }
public string? System { get; set; }
public int? DurationMinutes { get; set; }
public WizardSessionFormat? Format { get; set; }
public string? JoinLink { get; set; }
public string? LocationAddress { get; set; }
public WizardVisibility? Visibility { get; set; }
public Guid? ClubId { get; set; }
public bool? PublishInShowcase { get; set; }
@@ -0,0 +1,18 @@
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Limits and bounds used by the wizard's input validation. Kept here
/// (rather than on the Telegram-only <c>WizardStep</c>) so the state
/// machine can reference them without pulling in a platform dependency.
/// </summary>
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;
public const int MaxLocationLength = 500;
}
@@ -1,5 +1,11 @@
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Symbolic step identifiers used by <see cref="WizardDraft.Step"/> and
/// the <see cref="WizardCallbackData"/> payload. Strings (rather than an
/// enum) so that future platforms can extend the set without breaking
/// the wire format stored in PostgreSQL.
/// </summary>
public static class WizardStepNames
{
public const string Type = "Type";
@@ -10,6 +16,8 @@ public static class WizardStepNames
public const string Duration = "Duration";
public const string DateTime = "DateTime";
public const string Capacity = "Capacity";
public const string Format = "Format";
public const string Location = "Location";
public const string Visibility = "Visibility";
public const string PickClub = "PickClub";
public const string Publish = "Publish";
@@ -0,0 +1,283 @@
using System;
using System.Collections.Generic;
using System.Text;
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Produces a (text, list of <see cref="WizardAction"/>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 <c>InlineKeyboardMarkup</c> today, Discord components
/// later).
/// </summary>
public static class WizardStepViewBuilder
{
public static (string Text, IReadOnlyList<WizardAction> Actions) Build(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? 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.Format => BuildFormat(),
WizardStepNames.Location => BuildLocation(payload),
WizardStepNames.Visibility => BuildVisibility(),
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
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<WizardAction>) 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<WizardAction>) BuildTitle() => (
"📝 Введите название игры одним сообщением.",
BackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildDescription() => (
"📄 Введите описание (или «-», чтобы пропустить).",
SkipBackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildCover() => (
"🖼 Пришлите картинку как вложение или URL (или «-»).",
SkipBackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildSystem() => (
"🎲 Выберите систему.",
new List<WizardAction>
{
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<WizardAction>) BuildDuration() => (
"⏱ Выберите длительность.",
new List<WizardAction>
{
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<WizardAction>) BuildDateTime() => (
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
BackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
new List<WizardAction>
{
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
});
private static (string, IReadOnlyList<WizardAction>) BuildFormat() => (
"🧭 Выберите формат игры.",
new List<WizardAction>
{
new("🌐 Online", WizardCallbackData.Choice(WizardStepNames.Format, "online"), WizardActionStyle.Primary),
new("📍 Offline", WizardCallbackData.Choice(WizardStepNames.Format, "offline"), WizardActionStyle.Primary),
new("⬅️ Назад", WizardCallbackData.Back()),
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
});
private static (string, IReadOnlyList<WizardAction>) BuildLocation(WizardPayload payload) => payload.Format switch
{
WizardSessionFormat.Offline => ("📍 Введите адрес места проведения.", BackCancel()),
_ => ("🔗 Введите ссылку для подключения к online-игре.", BackCancel()),
};
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
"🔒 Выберите видимость.",
new List<WizardAction>
{
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<WizardAction>) BuildPickClub(IReadOnlyList<WizardClubOption> clubs)
{
if (clubs.Count == 0)
{
return (
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
BackCancel());
}
var actions = new List<WizardAction>(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<WizardAction>) BuildPublish() => (
"✨ Опубликовать в витрине сейчас?",
new List<WizardAction>
{
new("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes"), WizardActionStyle.Success),
new("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")),
});
private static (string, IReadOnlyList<WizardAction>) 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} ч");
AppendFormatLocation(sb, p);
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<WizardAction>
{
new("✅ Создать", WizardCallbackData.Create(), WizardActionStyle.Success),
new("⬅️ Назад", WizardCallbackData.Back()),
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
});
}
// ── Pool views ─────────────────────────────────────────────────────
private static (string, IReadOnlyList<WizardAction>) BuildPoolSystemDuration() => (
"🎲 Выберите систему и длительность пула.",
new List<WizardAction>
{
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<WizardAction>) BuildPoolAddSlots(WizardPayload p) => (
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
new List<WizardAction>
{
new("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), WizardActionStyle.Primary),
new("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), WizardActionStyle.Success),
});
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotDateTime() => (
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
BackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotCapacity() => (
"👥 Введите лимит мест (1..50) и выберите waitlist.",
new List<WizardAction>
{
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"), WizardActionStyle.Success),
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"), WizardActionStyle.Danger),
});
private static (string, IReadOnlyList<WizardAction>) 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} ч");
AppendFormatLocation(sb, p);
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<WizardAction>
{
new("✅ Создать пул", WizardCallbackData.Create(), WizardActionStyle.Success),
new("⬅️ Назад", WizardCallbackData.Back()),
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
});
}
// ── Helpers ────────────────────────────────────────────────────────
private static IReadOnlyList<WizardAction> BackCancel() => new[]
{
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
private static IReadOnlyList<WizardAction> 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 => "только для членов клуба",
_ => "не задана",
};
private static void AppendFormatLocation(StringBuilder sb, WizardPayload p)
{
if (p.Format is null) return;
sb.AppendLine($"🧭 Формат: {p.Format}");
if (p.Format == WizardSessionFormat.Online && !string.IsNullOrWhiteSpace(p.JoinLink))
{
sb.AppendLine($"🔗 Ссылка: {p.JoinLink}");
}
else if (p.Format == WizardSessionFormat.Offline && !string.IsNullOrWhiteSpace(p.LocationAddress))
{
sb.AppendLine($"📍 Адрес: {p.LocationAddress}");
}
}
}
@@ -0,0 +1,16 @@
using System;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// 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.
/// </summary>
public sealed class WizardStorageException : Exception
{
public WizardStorageException(string message, Exception inner)
: base(message, inner)
{
}
}
@@ -159,7 +159,7 @@ public sealed class HandleRescheduleTimeInputHandler(
await transaction.CommitAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -81,6 +81,7 @@ public sealed class DbSessionTriggerStore(
JOIN game_groups g ON g.id = s.group_id
WHERE g.platform = @Platform
AND s.status = @Confirmed
AND btrim(s.join_link) <> ''
AND s.scheduled_at - @LeadTime <= @Now
AND (
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
@@ -1,4 +1,15 @@
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink);
public sealed record SessionBatchDto(
Guid SessionId,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
string JoinLink,
string? Format = null,
string? LocationAddress = null,
string? Description = null,
string? System = null,
int? DurationMinutes = null,
bool IsOneShot = false);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -39,6 +39,12 @@ public static class SessionBatchViewBuilder
session.Status,
session.MaxPlayers,
session.JoinLink,
session.Format,
session.LocationAddress,
session.Description,
session.System,
session.DurationMinutes,
session.IsOneShot,
activePlayers.Count,
activePlayers,
waitlistedPlayers,
@@ -12,6 +12,12 @@ public sealed record SessionViewItem(
string Status,
int? MaxPlayers,
string JoinLink,
string? Format,
string? LocationAddress,
string? Description,
string? System,
int? DurationMinutes,
bool IsOneShot,
int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.7.1</div>
<div class="nav-version">v3.11.0</div>
</div>
</Authorized>
<NotAuthorized>
+57 -8
View File
@@ -119,7 +119,14 @@ internal sealed record WebBatchSessionRow(
long TelegramChatId,
int? ThreadId,
string NotificationMode,
bool TopicCreatedByBot = false);
bool TopicCreatedByBot = false,
string? Description = null,
string? System = null,
int? DurationMinutes = null,
string? Format = null,
string? LocationAddress = null,
bool IsOneShot = false,
string? CoverImageUrl = null);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow(
@@ -1508,7 +1515,14 @@ public sealed class SessionService(
g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode
s.notification_mode AS NotificationMode,
s.description AS Description,
s.system AS System,
s.duration_minutes AS DurationMinutes,
s.format AS Format,
s.location_address AS LocationAddress,
s.is_one_shot AS IsOneShot,
s.cover_image_url AS CoverImageUrl
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
@@ -1536,8 +1550,14 @@ public sealed class SessionService(
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
INSERT INTO sessions (
batch_id, group_id, title, join_link, scheduled_at, status, thread_id,
topic_created_by_bot, max_players, notification_mode, description, system,
duration_minutes, format, location_address, is_one_shot, cover_image_url)
VALUES (
@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId,
@TopicCreatedByBot, @MaxPlayers, @NotificationMode, @Description, @System,
@DurationMinutes, @Format, @LocationAddress, @IsOneShot, @CoverImageUrl)
RETURNING id
""",
new
@@ -1551,11 +1571,29 @@ public sealed class SessionService(
ThreadId = threadId,
sourceSession.TopicCreatedByBot,
sourceSession.MaxPlayers,
sourceSession.NotificationMode
sourceSession.NotificationMode,
Description = sourceSession.Description,
System = sourceSession.System,
DurationMinutes = sourceSession.DurationMinutes,
Format = sourceSession.Format,
LocationAddress = sourceSession.LocationAddress,
IsOneShot = sourceSession.IsOneShot,
CoverImageUrl = sourceSession.CoverImageUrl
},
transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
renderedSessions.Add(new SessionBatchDto(
sessionId,
scheduledAt,
SessionStatus.Planned,
sourceSession.MaxPlayers,
batchJoinLink,
sourceSession.Format,
sourceSession.LocationAddress,
sourceSession.Description,
sourceSession.System,
sourceSession.DurationMinutes,
sourceSession.IsOneShot));
}
await transaction.CommitAsync();
@@ -1770,7 +1808,18 @@ public sealed class SessionService(
},
transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
renderedSessions.Add(new SessionBatchDto(
sessionId,
scheduledAt,
SessionStatus.Planned,
template.MaxPlayers,
template.JoinLink,
Format: null,
LocationAddress: null,
Description: null,
System: null,
DurationMinutes: null,
IsOneShot: false));
}
await transaction.CommitAsync();
@@ -1897,7 +1946,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
var sessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { BatchId = batchId })).ToList();
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -16,15 +16,49 @@ public static class TelegramSessionBatchRenderer
foreach (var session in view.Sessions)
{
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink))
var tags = new List<string>();
if (!string.IsNullOrWhiteSpace(session.System))
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
if (!string.IsNullOrWhiteSpace(session.Format))
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
if (tags.Count > 0)
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
}
if (session.DurationMinutes.HasValue)
{
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
}
if (!string.IsNullOrWhiteSpace(session.Description))
{
messageText += $"📝 <b>Описание:</b>\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n";
}
var format = session.Format ?? string.Empty;
var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase);
var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase);
var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase);
if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink))
{
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
}
if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
{
messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
}
messageText += session.MaxPlayers.HasValue
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
@@ -37,7 +71,7 @@ public static class TelegramSessionBatchRenderer
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
@@ -59,4 +93,14 @@ public static class TelegramSessionBatchRenderer
return (messageText, new InlineKeyboardMarkup(buttons));
}
private static string FormatDuration(int minutes)
{
if (minutes <= 0) return "0 мин";
var hours = minutes / 60;
var mins = minutes % 60;
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
if (hours > 0) return $"{hours} ч";
return $"{mins} мин";
}
}
@@ -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;
/// <summary>
/// Source-level structural smoke tests for the Discord wizard
/// interaction module. The NetCord component-interaction service
/// uses dispatch-by-attribute (no public registry), so a runtime
/// instantiation test would need to spin up the full NetCord host —
/// overkill for a smoke gate. Instead we assert on the source shape
/// (custom-id formats, handler method signatures, dispatcher wiring)
/// so the existing wizard tests catch regressions in the platform-
/// neutral state machine while this file catches regressions in the
/// Discord adapter shell.
/// </summary>
public sealed class DiscordWizardInteractionModuleSourceTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
private static string ReadSource(string relativePath)
{
var repoRoot = GetRepoRoot();
var fullPath = Path.Combine(repoRoot, relativePath);
Assert.True(File.Exists(fullPath), $"Source file {relativePath} should exist.");
return File.ReadAllText(fullPath);
}
[Fact]
public void Module_ShouldExist()
{
var path = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs";
var source = ReadSource(path);
Assert.Contains("public sealed class DiscordWizardButtonModule", source, StringComparison.Ordinal);
Assert.Contains("public sealed class DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
Assert.Contains("public sealed class DiscordWizardModalModule", source, StringComparison.Ordinal);
Assert.Contains("public sealed class WizardInteractionDispatcher", source, StringComparison.Ordinal);
}
[Fact]
public void Modules_ShouldBeDerivedFromComponentInteractionModule()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// The dispatching modules are thin shells that inherit from
// NetCord's ComponentInteractionModule<TContext> for each
// supported component type.
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
Assert.Contains("ComponentInteractionModule<StringMenuInteractionContext>", source, StringComparison.Ordinal);
Assert.Contains("ComponentInteractionModule<ModalInteractionContext>", source, StringComparison.Ordinal);
}
[Fact]
public void Modules_ShouldRegisterWizardComponentInteraction()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// All three modules use the [ComponentInteraction("wizard")]
// prefix registration; the args string carries the rest of
// the custom-id (e.g. "btn:choice:Type:single" or
// "select:Visibility" or "modal:Title").
var count = CountOccurrences(source, "[ComponentInteraction(\"wizard\")]");
Assert.Equal(3, count);
}
[Fact]
public void Dispatcher_ShouldHandleButtonSelectAndModal()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
Assert.Contains("public async Task HandleButtonAsync", source, StringComparison.Ordinal);
Assert.Contains("public async Task HandleStringMenuAsync", source, StringComparison.Ordinal);
Assert.Contains("public async Task HandleModalAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Dispatcher_ShouldParseAllWizardActionKinds()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// The button handler must dispatch on the five action kinds
// the wizard's callback data format emits: choice, back,
// cancel, create. The resume flow is a wizard-internal control
// emitted by the slash command's "Continue / Start over" row.
Assert.Contains("\"choice\"", source, StringComparison.Ordinal);
Assert.Contains("\"back\"", source, StringComparison.Ordinal);
Assert.Contains("\"cancel\"", source, StringComparison.Ordinal);
Assert.Contains("\"create\"", source, StringComparison.Ordinal);
Assert.Contains("\"resume\"", source, StringComparison.Ordinal);
}
[Fact]
public void Dispatcher_ShouldWireWizardStateMachine()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// The dispatcher must call the shared GameCreationWizard's
// HandleInteractionAsync, which is the same entry point the
// Telegram bot uses. This is the core invariant of the
// platform-neutral refactor.
Assert.Contains("GameCreationWizard", source, StringComparison.Ordinal);
Assert.Contains("HandleInteractionAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Dispatcher_ShouldInvokeSubmitterOnCreate()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// The "create" callback must delegate to DiscordWizardSubmitter
// (the 3-retry finalize loop) rather than call the wizard's
// render path.
Assert.Contains("SubmitAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Program_ShouldRegisterAllThreeComponentServices()
{
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
// NetCord requires AddComponentInteractions<TInteraction, TContext>
// per supported interaction type. The wizard needs all three:
// buttons, StringSelectMenus, and modal submits.
Assert.Contains("AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>", source, StringComparison.Ordinal);
Assert.Contains("AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>", source, StringComparison.Ordinal);
Assert.Contains("AddComponentInteractions<ModalInteraction, ModalInteractionContext>", source, StringComparison.Ordinal);
}
[Fact]
public void Program_ShouldRegisterWizardModuleClasses()
{
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
// The wizard module classes have constructor dependencies
// (the dispatcher + shared services) that DI must resolve.
// AddComponentInteractions only registers the IComponentInteractionService,
// not the module classes themselves.
Assert.Contains("WizardInteractionDispatcher", source, StringComparison.Ordinal);
Assert.Contains("DiscordWizardButtonModule", source, StringComparison.Ordinal);
Assert.Contains("DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
Assert.Contains("DiscordWizardModalModule", source, StringComparison.Ordinal);
}
[Fact]
public void Dispatcher_ShouldLookupDraftByOwner()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// All three handlers must look up the active draft by
// (platform="Discord", ownerId=userId) — the wizard's
// invariant is "one active draft per owner", not
// "draft-id-in-custom-id".
Assert.Contains("GetActiveAsync(\"Discord\"", source, StringComparison.Ordinal);
}
[Fact]
public void ModalHandler_ShouldExtractTextFromLabel()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// The wizard's modals wrap a single TextInput in a Label. The
// handler must walk Components[0] (Label) → .Component (TextInput)
// → .Value to retrieve the user's text. If this drifts the
// modal submit silently becomes a no-op.
Assert.Contains("Components[0]", source, StringComparison.Ordinal);
Assert.Contains("TextInput", source, StringComparison.Ordinal);
Assert.Contains(".Value", source, StringComparison.Ordinal);
}
[Fact]
public void StringMenuHandler_ShouldReadSelectedValues()
{
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
// StringSelectMenu interactions expose SelectedValues[0]
// for our MaxValues=1 menus.
Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Renderer_And_Dispatcher_Agree_On_Wire_Format()
{
// Choice button: dispatcher expects `btn:choice:<step>:<value>`.
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:<action>: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:<modalStep>`.
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}"));
}
/// <summary>
/// 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.
/// </summary>
[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]);
}
}
/// <summary>Mirror NetCord's [ComponentInteraction("wizard")] prefix strip.</summary>
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;
}
}
@@ -0,0 +1,66 @@
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using NetCord.Rest;
using Xunit;
namespace GmRelay.Bot.Tests.Discord.Wizard;
/// <summary>
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
/// a session with no player cap (null in <c>sessions.max_players</c>), the
/// same affordance the Telegram wizard provides.
/// </summary>
public sealed class DiscordWizardStepCapacityRenderTests
{
[Fact]
public void RenderCapacity_ContainsNoLimitButton()
{
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var labels = ExtractButtonLabels(render);
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
}
[Fact]
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
{
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var labels = ExtractButtonLabels(render);
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
}
[Theory]
[InlineData(WizardStepNames.Capacity, "wizard:btn:choice:Capacity:no_limit")]
[InlineData(WizardStepNames.PoolSlotCapacity, "wizard:btn:choice:PoolSlotCapacity:no_limit")]
public void Render_NoLimitButton_HasChoiceCustomIdForNoLimit(string step, string expectedCustomIdPrefix)
{
var draft = new WizardDraft { Step = step };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var buttons = ExtractButtons(render);
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
Assert.NotNull(noLimit);
Assert.StartsWith(expectedCustomIdPrefix, noLimit!.CustomId);
}
private static System.Collections.Generic.List<string> ExtractButtonLabels(
DiscordWizardStep.DiscordWizardRender render) =>
render.Components
.OfType<ActionRowProperties>()
.SelectMany(r => r.Components)
.OfType<ButtonProperties>()
.Select(b => b.Label ?? string.Empty)
.ToList();
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
DiscordWizardStep.DiscordWizardRender render) =>
render.Components
.OfType<ActionRowProperties>()
.SelectMany(r => r.Components)
.OfType<ButtonProperties>()
.ToList();
}
@@ -0,0 +1,85 @@
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Xunit;
namespace GmRelay.Bot.Tests.Discord.Wizard;
/// <summary>
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
/// <c>0</c>. <c>0</c> would violate the DB CHECK
/// <c>ck_sessions_max_players</c> in V006 and the contract that
/// <c>null</c> means "no limit".
/// </summary>
public sealed class DiscordWizardSubmitterBuildCommandTests
{
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = null,
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Null(cmd.MaxPlayers);
}
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 5,
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal(5, cmd.MaxPlayers);
}
}
@@ -0,0 +1,192 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Testcontainers.PostgreSql;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
[CollectionDefinition(Name)]
public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture<CreateSessionHandlerPostgresFixture>
{
public const string Name = "Create session handler PostgreSQL";
}
public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime
{
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
public Task InitializeAsync()
{
return container.StartAsync().WaitAsync(ContainerTimeout);
}
public Task DisposeAsync()
{
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
}
public async Task<string> CreateMigratedDatabaseAsync()
{
var databaseName = $"create_session_{Guid.NewGuid():N}";
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
{
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
{
Database = databaseName,
Timeout = 10,
CommandTimeout = 30
}.ConnectionString;
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync().WaitAsync(ContainerTimeout);
foreach (var migration in GetMigrationPaths())
{
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
{
CommandTimeout = 30
};
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
return connectionString;
}
private static IReadOnlyList<string> GetMigrationPaths()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
if (Directory.Exists(migrationsDirectory))
{
return Directory.GetFiles(migrationsDirectory, "V*.sql")
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
.ToArray();
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
}
}
[Collection(CreateSessionHandlerPostgresCollection.Name)]
public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPostgresFixture fixture)
{
[Fact]
public async Task HandleAsync_NewPlatformGroup_AddsOwnerAndPersistsSession()
{
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new CreateSessionHandler(dataSource);
var result = await sut.HandleAsync(
new CreateSessionCommand(
new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"),
new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"),
"Test Adventure",
"https://vtt.example/game",
[DateTimeOffset.UtcNow.AddDays(1)],
null,
null,
GameSystem.Dnd5e,
"Integration regression test",
"Online",
240,
true,
"Online room notes"),
CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(result.BatchId);
Assert.NotNull(result.GroupId);
await using var connection = await dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(
"""
SELECT count(*)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @group_id
AND gm.role = 'Owner'
AND p.platform = 'Telegram'
AND p.external_user_id = '111111111'
""",
connection);
command.Parameters.AddWithValue("group_id", result.GroupId.Value);
var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
Assert.Equal(1, ownerCount);
await using var sessionCommand = new NpgsqlCommand(
"""
SELECT join_link, format, location_address
FROM sessions
WHERE batch_id = @batch_id
""",
connection);
sessionCommand.Parameters.AddWithValue("batch_id", result.BatchId.Value);
await using var reader = await sessionCommand.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal("https://vtt.example/game", reader.GetString(0));
Assert.Equal("Online", reader.GetString(1));
Assert.Equal("Online room notes", reader.GetString(2));
Assert.False(await reader.ReadAsync());
}
[Fact]
public async Task HandleAsync_OfflineSession_PersistsFormatAndLocationAddress()
{
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new CreateSessionHandler(dataSource);
var result = await sut.HandleAsync(
new CreateSessionCommand(
new PlatformUser(PlatformKind.Telegram, "333333333", "Offline GM", "offline_gm"),
new PlatformGroup(PlatformKind.Telegram, "444444444", "Offline Group"),
"Offline Adventure",
string.Empty,
[DateTimeOffset.UtcNow.AddDays(1)],
4,
null,
GameSystem.Dnd5e,
"Offline integration regression test",
"Offline",
240,
true,
"Москва, ул. Кубиков, 12"),
CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(result.BatchId);
await using var connection = await dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(
"""
SELECT join_link, format, location_address
FROM sessions
WHERE batch_id = @batch_id
""",
connection);
command.Parameters.AddWithValue("batch_id", result.BatchId.Value);
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal(string.Empty, reader.GetString(0));
Assert.Equal("Offline", reader.GetString(1));
Assert.Equal("Москва, ул. Кубиков, 12", reader.GetString(2));
Assert.False(await reader.ReadAsync());
}
}
@@ -0,0 +1,161 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
/// when the wizard payload carries no player limit (e.g. user picked
/// «♾ Без лимита» on the Capacity step), the resulting
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
/// <c>0</c> would violate the DB CHECK constraint
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
/// <c>NULL</c>, which is the wire-level representation of "no limit".
/// </summary>
public sealed class CreateSessionHandlerBuildCommandTests
{
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = null,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Null(cmd.MaxPlayers);
}
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 5,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal(5, cmd.MaxPlayers);
}
[Fact]
public void BuildCommand_WhenFormatIsOnline_PropagatesFormatAndJoinLink()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 4,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal("Online", cmd.Format);
Assert.Equal("https://vtt.example/game", cmd.Link);
Assert.Null(cmd.LocationAddress);
}
[Fact]
public void BuildCommand_WhenFormatIsOffline_PropagatesFormatAndAddress()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Offline,
LocationAddress = "Москва, ул. Кубиков, 12",
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 4,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal("Offline", cmd.Format);
Assert.Equal(string.Empty, cmd.Link);
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
}
}
@@ -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);
}
@@ -1,19 +1,107 @@
using System;
using Xunit;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a single-game wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitSingleDraftTests
[Collection(CreateSessionHandlerPostgresCollection.Name)]
public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture)
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
throw new NotImplementedException("See Skip reason above.");
[Fact]
public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs()
{
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var drafts = new FakeWizardDraftRepository();
var wizardMessenger = new FakeWizardMessenger();
var platformMessenger = new FakePlatformMessenger();
var sut = new CreateSessionHandler(
drafts,
new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler(dataSource),
wizardMessenger,
NullLogger<CreateSessionHandler>.Instance,
platformMessenger,
dataSource);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "Тест публикации",
System = "Dnd5e",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = null,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload, ownerId: 111111111);
draft.ChatId = "-1003916537960";
draft.DraftMessageId = "7";
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(platformMessenger.CreatedThreads);
Assert.Equal("Тест публикации", platformMessenger.CreatedThreads[0].Title);
Assert.Single(platformMessenger.SentSchedules);
Assert.Equal("456", platformMessenger.SentSchedules[0].Group.ExternalThreadId);
Assert.Contains(draft.Id, drafts.DeletedIds);
Assert.Contains(wizardMessenger.Edits, edit => edit.Text.Contains("✅ Создано: 1 сессия", StringComparison.Ordinal));
await using var connection = await dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(
"""
SELECT thread_id, batch_message_id, topic_created_by_bot
FROM sessions
ORDER BY created_at DESC
LIMIT 1
""",
connection);
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal(456, reader.GetInt32(0));
Assert.Equal(789, reader.GetInt32(1));
Assert.True(reader.GetBoolean(2));
}
}
internal sealed class FakePlatformMessenger : IPlatformMessenger
{
public List<(PlatformGroup Group, string Title)> CreatedThreads { get; } = new();
public List<PlatformScheduleMessage> SentSchedules { get; } = new();
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
CreatedThreads.Add((group, title));
return Task.FromResult(new PlatformMessageRef(group.Platform, group.ExternalGroupId, "456", string.Empty));
}
public Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
SentSchedules.Add(message);
return Task.FromResult(new PlatformMessageRef(
message.Group.Platform,
message.Group.ExternalGroupId,
message.Group.ExternalThreadId,
"789"));
}
public Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => Task.CompletedTask;
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) => Task.CompletedTask;
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) => Task.CompletedTask;
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) => Task.CompletedTask;
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) => Task.CompletedTask;
}
@@ -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;
@@ -37,6 +36,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
@@ -70,6 +71,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Type = WizardCreationType.Single,
Title = "T",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
@@ -105,6 +108,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput { MaxPlayers = 4 },
};
@@ -136,6 +141,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Title = "P",
System = "Dnd5e",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput(),
};
@@ -147,4 +154,49 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Assert.Single(messenger.Edits);
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_SingleWithNoLimit_DoesNotReportMaxPlayersAsMissing()
{
// Regression for #131: pressing "♾ Без лимита" sets MaxPlayers = null.
// IsComplete must NOT flag that as a missing field; null means
// "no player limit" and is a valid final state.
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = null,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
// Validation must let the no-limit payload through. The shared
// handler is null, so anything that reached the database call would
// throw a NullReferenceException — that is caught by the retry
// path and reported as a "💥 Ошибка:" edit, not a missing-fields
// edit. Therefore we assert that NO edit mentions a missing field.
Assert.NotEmpty(messenger.Edits);
var lastEdit = messenger.Edits[^1].Text;
Assert.DoesNotContain("Не заполнены", lastEdit, StringComparison.OrdinalIgnoreCase);
}
}
@@ -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,23 +78,30 @@ 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);
}
[Fact]
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
public async Task Back_FromPoolAddSlots_GoesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
});
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);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
}
[Fact]
@@ -108,7 +114,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);
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
@@ -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;
@@ -21,9 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
// Capacity → Format (only explicit no-limit can skip numeric capacity)
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
// Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub
@@ -41,14 +39,14 @@ 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
}
[Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
@@ -60,9 +58,9 @@ 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);
Assert.Equal(WizardStepNames.Format, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys));
@@ -72,31 +70,123 @@ public sealed class GameCreationWizardStepTransitionsTests
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
{
// The wizard's callback parser uses the step encoded in the callback
// (not the draft's current step) to drive transitions. So a stale
// "Capacity" button pressed while the user is on System will in fact
// move the draft forward as if they had pressed it on Capacity. We
// lock that behaviour in.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Format, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("single", out var single));
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
// поэтому null-MaxPlayers просто не пишется. Оба варианта
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
// и уйдут в БД как NULL — то есть «без лимита».
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
{
Assert.True(
maxPlayers.ValueKind == JsonValueKind.Null,
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
}
}
[Fact]
public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
{
// A stale waitlist button from Capacity must not move a draft forward
// unless MaxPlayers is already set. Otherwise users can reach Confirm
// with a missing capacity and get "Не заполнены поля: лимит мест".
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System);
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);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Fact]
public async Task PickClub_ValidGuid_ReachesStableStep()
public async Task Format_OnlineChoice_AdvancesToLocationAndPersistsFormat()
{
// The wizard has a quirk: NextAfterVisibility is evaluated before
// SetClubId, so a single click leaves the draft still on PickClub.
// We assert that the wizard does NOT throw and the messenger is asked
// to re-render (i.e. the handler ran end-to-end).
var wizard = BuildWizard(out var drafts, out var messenger);
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Format, "online");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Location, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("format", out var format));
Assert.Equal("Online", format.GetString());
}
[Fact]
public async Task Format_OfflineChoice_AdvancesToLocationAndPersistsFormat()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Format, "offline");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Location, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("format", out var format));
Assert.Equal("Offline", format.GetString());
}
[Fact]
public async Task Location_TextForOnline_StoresJoinLinkAndAdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = PayloadForStep(WizardStepNames.Location);
payload.Format = WizardSessionFormat.Online;
var draft = NewDraft(WizardStepNames.Location, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("https://vtt.example/game", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("joinLink", out var joinLink));
Assert.Equal("https://vtt.example/game", joinLink.GetString());
Assert.False(root.TryGetProperty("locationAddress", out _));
}
[Fact]
public async Task Location_TextForOffline_StoresAddressAndAdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = PayloadForStep(WizardStepNames.Location);
payload.Format = WizardSessionFormat.Offline;
var draft = NewDraft(WizardStepNames.Location, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("Москва, ул. Кубиков, 12", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("locationAddress", out var address));
Assert.Equal("Москва, ул. Кубиков, 12", address.GetString());
Assert.False(root.TryGetProperty("joinLink", out _));
}
[Fact]
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
{
var wizard = BuildWizard(out var drafts, out _);
var clubId = Guid.NewGuid();
var payload = new WizardPayload
{
@@ -110,10 +200,13 @@ 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);
Assert.Equal(WizardStepNames.Publish, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("clubId", out var clubIdJson));
Assert.Equal(clubId, clubIdJson.GetGuid());
}
[Fact]
@@ -132,7 +225,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);
}
@@ -161,6 +254,16 @@ public sealed class GameCreationWizardStepTransitionsTests
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
},
WizardStepNames.Format or WizardStepNames.Location => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
},
WizardStepNames.PickClub => new WizardPayload
{
@@ -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,30 @@ 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);
}
[Theory]
[InlineData("waitlist:on")]
[InlineData("waitlist:off")]
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity,
new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
});
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
@@ -148,7 +170,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 +183,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 +200,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);
}
@@ -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);
@@ -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" },
},
};
@@ -0,0 +1,175 @@
using System;
using System.IO;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Regression guard: WizardDraftRepository must not use the (CommandDefinition)
/// overload of Dapper. Dapper.AOT 1.0.48 only generates interceptors for the
/// (sql, object?) extension overloads; using CommandDefinition falls back to
/// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
/// throws PlatformNotSupportedException on NativeAOT (v3.9.0 wizard regression).
/// The v3.9.1 hotfix switched all four methods to the direct overload.
/// </summary>
public sealed class WizardDraftRepositoryAotShapeTests
{
[Fact]
public void WizardDraftRepository_GetActiveAsync_ShouldNotUseCommandDefinition()
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"Wizard",
"WizardDraftRepository.cs"));
var getActive = ExtractMethodBody(source, "GetActiveAsync", "");
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
}
[Theory]
[InlineData("UpsertAsync")]
[InlineData("DeleteAsync")]
[InlineData("DeleteExpiredAsync")]
public void WizardDraftRepository_MutatingMethods_ShouldNotUseCommandDefinition(string methodName)
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"Wizard",
"WizardDraftRepository.cs"));
var body = ExtractMethodBody(source, methodName, "");
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
}
/// <summary>
/// WizardDraftRepository was the only AOT-fatal site in v3.9.0, but the
/// same pattern (CommandDefinition on a Dapper extension that the AOT
/// generator cannot reach) is repeated in 4 club-picker / permission
/// lookups across Telegram and Discord messengers. v3.9.2 hotfix
/// converted them all to the direct (sql, params) overload. Lock the
/// regression so the next refactor doesn't reintroduce it.
/// </summary>
[Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs", "GetOwnerClubsAsync", "")]
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs", "GetOwnerClubsAsync", "")]
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs", "LoadClubsAsync", "internal static class WizardClubLookup")]
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs", "LoadManagerUserIdsAsync", "")]
public void ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition(string relativePath, string methodName, string containingClass)
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
var body = ExtractMethodBody(source, methodName, containingClass);
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
}
/// <summary>
/// AOT RowFactory for WizardDraft expects to read timestamps as
/// <see cref="DateTime"/> (UTC). If a future refactor switches the
/// properties back to <see cref="DateTimeOffset"/>, the AOT row factory
/// will throw <c>InvalidCastException: DateTime → DateTimeOffset</c>
/// on the first query against wizard_drafts.
/// </summary>
[Fact]
public void WizardDraft_TimestampsMustBeDateTime_NotDateTimeOffset()
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"Wizard",
"WizardDraft.cs"));
Assert.Contains("public DateTime CreatedAt", source, StringComparison.Ordinal);
Assert.Contains("public DateTime UpdatedAt", source, StringComparison.Ordinal);
Assert.Contains("public DateTime ExpiresAt", source, StringComparison.Ordinal);
Assert.DoesNotContain("public DateTimeOffset CreatedAt", source, StringComparison.Ordinal);
Assert.DoesNotContain("public DateTimeOffset UpdatedAt", source, StringComparison.Ordinal);
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
}
private static string ExtractMethodBody(string source, string methodName, string containingClass)
{
var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal);
// If a containing class is given (non-empty), narrow the search
// to the first occurrence AFTER the class declaration. This is
// needed when the same method name is used as a call site
// elsewhere in the file.
if (!string.IsNullOrEmpty(containingClass))
{
var classIdx = source.IndexOf(containingClass, StringComparison.Ordinal);
if (classIdx < 0)
{
throw new InvalidOperationException($"Could not locate class {containingClass} in source.");
}
searchFrom = source.IndexOf(methodName, classIdx, StringComparison.Ordinal);
}
if (searchFrom < 0)
{
throw new InvalidOperationException($"Could not locate {methodName} in source.");
}
// Accept any return type: `public async Task` (no result) or
// `public async Task<int>` (with result). Search for the keyword
// "Task" in a 60-char window before the method name so we also
// pick up `public static async Task<IReadOnlyList<ulong>>`.
var windowStart = Math.Max(0, searchFrom - 60);
var idx = source.IndexOf("Task", windowStart, StringComparison.Ordinal);
if (idx < 0 || idx >= searchFrom)
{
throw new InvalidOperationException($"Could not locate {methodName} declaration in source.");
}
var braceStart = source.IndexOf('{', idx);
if (braceStart < 0)
{
throw new InvalidOperationException($"Could not locate body opening brace for {methodName}.");
}
var depth = 1;
var pos = braceStart + 1;
while (pos < source.Length && depth > 0)
{
switch (source[pos])
{
case '{': depth++; break;
case '}': depth--; break;
}
pos++;
}
return source.Substring(braceStart, pos - braceStart);
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Directory.Build.props")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Could not locate repository root.");
}
}
@@ -11,7 +11,7 @@ public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardD
public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
{
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
public Task InitializeAsync()
@@ -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);
@@ -1,3 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
@@ -13,14 +16,14 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
draft.Step = "Title";
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
draft.UpdatedAt = DateTime.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);
}
@@ -32,10 +35,10 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
var draft = NewDraft("Type", DateTime.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);
}
@@ -46,10 +49,12 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var draft = NewDraft("Type", DateTime.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);
}
@@ -60,8 +65,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
var fresh = NewDraft("Type", DateTime.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
stale.Id = Guid.NewGuid();
await sut.UpsertAsync(fresh, CancellationToken.None);
await sut.UpsertAsync(stale, CancellationToken.None);
@@ -69,20 +74,21 @@ 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()
private static WizardDraft NewDraft(string step, DateTime expiresAt) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
ChatId = "42",
MessageThreadId = null,
OwnerTelegramId = 100,
OwnerId = "100",
Platform = "Telegram",
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
};
}
@@ -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;
/// <summary>
/// Verifies the Telegram <c>Update</c> → <c>WizardInteraction</c> mapping
/// that <see cref="WizardInteractionMapper"/> 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 <c>FileId</c>.
/// </summary>
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);
}
}
@@ -76,6 +76,40 @@ public sealed class WizardStepRenderTests
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
}
[Fact]
public void FormatStep_HasOnlineAndOfflineButtons()
{
var (text, kb) = Render(WizardStepNames.Format);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Online", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Offline", StringComparison.Ordinal));
}
[Fact]
public void LocationStep_ForOnline_AsksForLink()
{
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Online });
Assert.Contains("ссыл", text, StringComparison.OrdinalIgnoreCase);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void LocationStep_ForOffline_AsksForAddress()
{
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Offline });
Assert.Contains("адрес", text, StringComparison.OrdinalIgnoreCase);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
@@ -134,10 +168,14 @@ public sealed class WizardStepRenderTests
{
Type = WizardCreationType.Single,
Title = "My Game",
Format = WizardSessionFormat.Offline,
LocationAddress = "Москва, ул. Кубиков, 12",
});
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("My Game", text);
Assert.Contains("Offline", text);
Assert.Contains("Москва, ул. Кубиков, 12", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
@@ -248,7 +286,7 @@ public sealed class WizardStepRenderTests
private static WizardDraft NewDraft(string step) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
ChatId = "42",
Step = step,
PayloadJson = "{}",
};
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal static class WizardTestFakes
{
public const string PlatformName = "Telegram";
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
@@ -30,19 +31,77 @@ 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),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
/// <summary>
/// Build the platform-neutral <see cref="WizardInteraction"/> the
/// wizard now consumes. Pre-V112 callers passed
/// <c>Telegram.Bot.Types.Update</c> directly; tests now build the
/// neutral interaction via the same mapper the production code uses.
/// </summary>
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);
}
/// <summary>
/// Build a text-style <see cref="WizardInteraction"/> mirroring what
/// <c>WizardInteractionMapper</c> would produce for a Telegram text
/// message.
/// </summary>
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}");
}
/// <summary>
/// Build a photo-style <see cref="WizardInteraction"/> mirroring
/// what <c>WizardInteractionMapper</c> would produce for a Telegram
/// photo message.
/// </summary>
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}");
}
/// <summary>
/// Build a Telegram <see cref="Update"/> carrying a callback query.
/// Used by router-level tests that exercise
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
/// </summary>
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
{
CallbackQuery = new CallbackQuery
@@ -57,6 +116,11 @@ internal static class WizardTestFakes
},
};
/// <summary>
/// Build a Telegram <see cref="Update"/> carrying a text message.
/// Used by router-level tests that exercise
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
/// </summary>
public static Update TextUpdate(string text, long ownerId = 100) => new()
{
Message = new Message
@@ -69,9 +133,9 @@ internal static class WizardTestFakes
}
/// <summary>
/// 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.
/// </summary>
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<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
public Task<WizardDraft?> 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<WizardDraft?>(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
}
/// <summary>
/// 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 <c>ITelegramWizardMessenger</c> recorders so existing test
/// assertions (<c>edit.ChatId</c>, <c>edit.Text</c>, …) keep working
/// after the refactor.
/// </summary>
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<WizardAction> Actions)> EditActions { get; } = new();
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
public Task<long> EditMessageTextAsync(
long chatId,
int? messageThreadId,
long messageId,
public Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
InlineKeyboardMarkup keyboard,
IReadOnlyList<WizardAction> 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<long> SendGroupMessageAsync(
long chatId,
int? messageThreadId,
public Task<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
InlineKeyboardMarkup keyboard,
IReadOnlyList<WizardAction> 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<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
public Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
=> Task.FromResult(Clubs);
}
@@ -0,0 +1,68 @@
using GmRelay.Bot.Tests.Features.Sessions.CreateSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Bot.Tests.Infrastructure.Scheduling;
[Collection(CreateSessionHandlerPostgresCollection.Name)]
public sealed class DbSessionTriggerStoreTests(CreateSessionHandlerPostgresFixture fixture)
{
[Fact]
public async Task GetSessionsNeedingJoinLinkAsync_IgnoresConfirmedSessionsWithoutJoinLink()
{
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
await using var connection = await dataSource.OpenConnectionAsync();
var groupId = await InsertTelegramGroupAsync(connection);
var dueAt = DateTimeOffset.UtcNow.AddMinutes(4).UtcDateTime;
var onlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, "https://vtt.example/game", "Online");
var offlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, string.Empty, "Offline");
var sut = new DbSessionTriggerStore(dataSource, new PlatformSchedulerOptions(PlatformKind.Telegram));
var result = await sut.GetSessionsNeedingJoinLinkAsync(DateTimeOffset.UtcNow, CancellationToken.None);
Assert.Contains(onlineSessionId, result);
Assert.DoesNotContain(offlineSessionId, result);
}
private static async Task<Guid> InsertTelegramGroupAsync(NpgsqlConnection connection)
{
await using var command = new NpgsqlCommand(
"""
INSERT INTO game_groups (name, platform, external_group_id)
VALUES ('Trigger Test Group', 'Telegram', @ExternalGroupId)
RETURNING id
""",
connection);
command.Parameters.AddWithValue("ExternalGroupId", Guid.NewGuid().ToString("N"));
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Group insert failed."));
}
private static async Task<Guid> InsertSessionAsync(
NpgsqlConnection connection,
Guid groupId,
DateTime scheduledAt,
string joinLink,
string format)
{
await using var command = new NpgsqlCommand(
"""
INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, format)
VALUES (@GroupId, 'Trigger Test Session', @JoinLink, @ScheduledAt, @Status, @Format)
RETURNING id
""",
connection);
command.Parameters.AddWithValue("GroupId", groupId);
command.Parameters.AddWithValue("JoinLink", joinLink);
command.Parameters.AddWithValue("ScheduledAt", scheduledAt);
command.Parameters.AddWithValue("Status", SessionStatus.Confirmed);
command.Parameters.AddWithValue("Format", format);
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Session insert failed."));
}
}
@@ -2,6 +2,7 @@ using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging.Abstractions;
using System.Reflection;
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
@@ -36,9 +37,35 @@ public sealed class TelegramPlatformMessengerTests
Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message);
}
[Fact]
public void BuildDirectNotificationText_OneHourReminderWithoutJoinLink_ShouldNotRenderBlankLinkLine()
{
var notification = new PlatformDirectSessionNotification(
PlatformDirectSessionNotificationKind.OneHourReminder,
new PlatformUser(PlatformKind.Telegram, "123", "Player", "player"),
Guid.NewGuid(),
"Offline Game",
DateTime.UtcNow,
JoinLink: string.Empty);
var text = InvokeBuildDirectNotificationText(notification);
Assert.DoesNotContain("🔗", text);
}
private static TelegramPlatformMessenger CreateMessenger() =>
new(null!, NullLogger<TelegramPlatformMessenger>.Instance);
private static string InvokeBuildDirectNotificationText(PlatformDirectSessionNotification notification)
{
var method = typeof(TelegramPlatformMessenger).GetMethod(
"BuildDirectNotificationText",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
return Assert.IsType<string>(method.Invoke(null, new object[] { notification }));
}
private static SessionBatchViewModel CreateView() =>
new("Test batch", []);
}
@@ -149,4 +149,36 @@ public sealed class SessionBatchViewBuilderTests
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
Assert.DoesNotContain("ожидания", joinAction.Label);
}
[Fact]
public void Build_ShouldPassThroughNewFields()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(
sessionId,
DateTime.UtcNow,
SessionStatus.Planned,
4,
"https://example.com/game",
"Offline",
"Moscow",
"A short description",
"D\u0026D 5e",
240,
true)
};
var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var session = result.Sessions[0];
Assert.Equal("A short description", session.Description);
Assert.Equal("D\u0026D 5e", session.System);
Assert.Equal(240, session.DurationMinutes);
Assert.True(session.IsOneShot);
Assert.Equal("Offline", session.Format);
Assert.Equal("Moscow", session.LocationAddress);
}
}
@@ -16,9 +16,9 @@ public sealed class TelegramSessionBatchRendererTests
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2"),
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2", "Online", null),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1")
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1", "Online", null)
};
var participants = new[]
{
@@ -35,7 +35,7 @@ public sealed class TelegramSessionBatchRendererTests
Assert.Contains("Charlie", text);
Assert.Contains("Bob", text);
Assert.Contains("Сессия отменена", text);
Assert.Contains("Ссылка на игру", text);
Assert.Contains("Ссылка:", text);
Assert.Contains("https://example.com/game1", text);
Assert.Contains("https://example.com/game2", text);
@@ -67,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests
public void Render_ShouldShowWaitlistButtonWhenFull()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game", "Online", null) };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -130,16 +130,66 @@ public sealed class TelegramSessionBatchRendererTests
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.DoesNotContain("Ссылка на игру", text);
Assert.DoesNotContain("Ссылка:", text);
Assert.Contains("📅", text);
Assert.Equal(2, buttons.Count);
}
[Fact]
public void Render_ShouldShowOfflineAddress()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(
sessionId,
DateTime.UtcNow,
SessionStatus.Planned,
4,
"",
"Offline",
"Москва, ул. Кубиков, 12"),
};
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("📍 <b>Адрес:</b>", text);
Assert.Contains("Москва, ул. Кубиков, 12", text);
Assert.DoesNotContain("Ссылка:", text);
}
[Fact]
public void Render_ShouldShowOnlineLinkWithLinkIcon()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(
sessionId,
DateTime.UtcNow,
SessionStatus.Planned,
4,
"https://vtt.example/game",
"Online",
null),
};
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("🔗 <b>Ссылка:</b>", text);
Assert.Contains("https://vtt.example/game", text);
Assert.DoesNotContain("📍 Адрес:", text);
}
[Fact]
public void Render_ShouldEncodeHtmlInJoinLink()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2") };
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2", "Online", null) };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -148,4 +198,77 @@ public sealed class TelegramSessionBatchRendererTests
Assert.Contains("a=1&amp;b=2", text);
Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded
}
[Fact]
public void Render_ShouldShowStructuredGameCard()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(
sessionId,
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
"https://vtt.example/game",
"Hybrid",
"Moscow, Kubik Bar",
"Mystery one-shot in Bamberg.",
"D\u0026D 5e",
240,
true)
};
var participants = new[]
{
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
};
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("🏷", text);
Assert.Contains("Система:", text);
Assert.Contains("D\u0026amp;D 5e", text);
Assert.Contains("Формат:", text);
Assert.Contains("Hybrid", text);
Assert.Contains("Тип:", text);
Assert.Contains("One-shot", text);
Assert.Contains("⏱", text);
Assert.Contains("Длительность:", text);
Assert.Contains("4 ч", text);
Assert.Contains("📝", text);
Assert.Contains("Описание:", text);
Assert.Contains("Mystery one-shot in Bamberg.", text);
Assert.Contains("🔗", text);
Assert.Contains("Ссылка:", text);
Assert.Contains("📍", text);
Assert.Contains("Адрес:", text);
Assert.Contains("@alice", text);
Assert.Contains("Bob", text);
Assert.Contains("Лист ожидания", text);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(2, buttons.Count);
}
[Fact]
public void Render_ShouldHandleMissingOptionalFields()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("📅", text);
Assert.Contains("👥", text);
Assert.DoesNotContain("Система:", text);
Assert.DoesNotContain("Формат:", text);
Assert.DoesNotContain("Длительность:", text);
Assert.DoesNotContain("Описание:", text);
Assert.DoesNotContain("Ссылка:", text);
Assert.DoesNotContain("Адрес:", text);
}
}
@@ -14,8 +14,16 @@ public sealed class CampaignTemplatesNavigationTests
[Fact]
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{
// Read the version from Directory.Build.props (the canonical source of
// truth) so the test doesn't need to be hand-edited on every version
// bump. Asserting the rendered NavMenu matches the canonical version
// catches real bugs (e.g. someone bumps Directory.Build.props but
// forgets to update NavMenu.razor) without false alarms from a stale
// hard-coded literal.
var propsPath = FindRepositoryFile("Directory.Build.props");
var version = ReadVersionFromProps(propsPath);
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
Assert.Contains("v3.7.1", navMenu, StringComparison.Ordinal);
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
}
[Fact]
@@ -68,4 +76,23 @@ public sealed class CampaignTemplatesNavigationTests
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
/// <summary>
/// Parse the <c>&lt;Version&gt;...&lt;/Version&gt;</c> element from
/// <c>Directory.Build.props</c>. Tolerant of whitespace, comments and
/// attribute shuffling — the MSBuild schema for <c>Version</c> is just
/// a plain element with a string body.
/// </summary>
private static string ReadVersionFromProps(string propsPath)
{
var doc = System.Xml.Linq.XDocument.Load(propsPath);
var versionElement = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "Version");
Assert.NotNull(versionElement);
var version = versionElement!.Value.Trim();
Assert.False(
string.IsNullOrEmpty(version),
$"<Version> in {propsPath} is empty");
return version;
}
}
@@ -11,7 +11,7 @@ public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<Po
public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime
{
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
public Task InitializeAsync()
@@ -0,0 +1,81 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using GmRelay.Web.Services;
namespace GmRelay.Bot.Tests.Web.Rendering;
public sealed class WebTelegramSessionBatchRendererTests
{
[Fact]
public void Render_ShouldShowStructuredGameCard()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(
sessionId,
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
"https://vtt.example/game",
"Hybrid",
"Moscow, Kubik Bar",
"Mystery one-shot in Bamberg.",
"D\u0026D 5e",
240,
true)
};
var participants = new[]
{
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
};
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("🏷", text);
Assert.Contains("Система:", text);
Assert.Contains("D\u0026amp;D 5e", text);
Assert.Contains("Формат:", text);
Assert.Contains("Hybrid", text);
Assert.Contains("Тип:", text);
Assert.Contains("One-shot", text);
Assert.Contains("⏱", text);
Assert.Contains("Длительность:", text);
Assert.Contains("4 ч", text);
Assert.Contains("📝", text);
Assert.Contains("Описание:", text);
Assert.Contains("Mystery one-shot in Bamberg.", text);
Assert.Contains("🔗", text);
Assert.Contains("Ссылка:", text);
Assert.Contains("📍", text);
Assert.Contains("Адрес:", text);
Assert.Contains("@alice", text);
Assert.Contains("Bob", text);
Assert.Contains("Лист ожидания", text);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(2, buttons.Count);
}
[Fact]
public void Render_ShouldHandleMissingOptionalFields()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("📅", text);
Assert.Contains("👥", text);
Assert.DoesNotContain("Система:", text);
Assert.DoesNotContain("Формат:", text);
Assert.DoesNotContain("Длительность:", text);
Assert.DoesNotContain("Описание:", text);
Assert.DoesNotContain("Ссылка:", text);
Assert.DoesNotContain("Адрес:", text);
}
}