Compare commits

...

56 Commits

Author SHA1 Message Date
Toutsu 40fc435bda feat(web): dashboard session deletion and E2E coverage for issue #150
Deploy Telegram Bot / build-and-push (push) Failing after 28m28s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
- Add DeleteSessionAsync to ISessionStore/SessionService (unpublish portfolio card,
  remove bot-created empty forum topic, update batch message).
- Add DeleteSessionForCurrentUserAsync to AuthorizedSessionService with audit log.
- Add delete button + confirmation dialog to GroupDetails.razor.
- Extend dashboard Playwright tests with edit persistence and delete verification.
- Update AuthorizedSessionServiceTests with delete authorization coverage.
- Mark issue #150 as done in tests/e2e/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:05:48 +03:00
Toutsu 892f39401c feat(e2e): #148 /newsession scenario from creation to publication
- Add NewSessionScenario that walks the Telegram wizard:
  single game, title, skip description/cover, D&D 5e, 4h, datetime,
  capacity, online format, join link, public visibility, publish, confirm
- Add ClickInlineButtonAsync / ClickInlineButtonByTextAsync to TelegramUserClient
- Add local WizardCallback/Step constants mirroring GmRelay.Shared wizard wire format
- Program.cs now runs full flow: group setup + /newsession + cleanup

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:22:10 +03:00
Toutsu f4a61269c2 feat(e2e): #147 group creation and bot invitation scenario
- Add GroupSetupScenario: create supergroup, invite GmRelay bot, send /start,
  wait for reply, then delete the group
- Extend TelegramUserClient with DeleteGroupAsync and channel cache
- Update Program.cs to run the scenario with cleanup in finally
- Update README status table and runner documentation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:17:58 +03:00
Toutsu 4b0f328f2e feat(e2e): #146 MTProto Telegram user client runner
- Add standalone C# console runner tests/e2e/runner/ using WTelegramClient
- Provide TelegramUserClient wrapper: login, create supergroup, invite bot,
  send messages/commands, read recent messages, wait for bot reply
- Add .env.example and runner .gitignore to keep secrets/session files out of git
- Update E2E README with runner instructions and status table
- Runner project intentionally excluded from GM-Relay.slnx to avoid CI/AOT impact

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:07:48 +03:00
Toutsu fcc8514847 feat(e2e): #145 Playwright dashboard tests with mock Telegram auth
- Add Playwright-based E2E tests in tests/e2e/dashboard/
- Authenticate via /auth/telegram-webapp using helpers/telegram_init_data.py
- Cover dashboard load and session edit flow
- Add requirements.txt and package dashboard folder
- Update README with setup and test descriptions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:59:36 +03:00
Toutsu 5319592964 feat(e2e): shared initData / Login Widget payload builder for E2E tests
- Add TelegramAuthPayloadBuilder in GmRelay.Shared for C# tests.
- Refactor TelegramAuthServiceTests to use the shared builder.
- Add Python equivalent (telegram_init_data.py) for E2E runner.
- Add self-contained Python tests and E2E README.

Closes #144

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:53:22 +03:00
Toutsu 6a59c48348 chore(release): bump version to 3.11.3
Deploy Telegram Bot / build-and-push (push) Successful in 27m4s
Deploy Telegram Bot / scan-images (push) Successful in 13m53s
Deploy Telegram Bot / deploy (push) Successful in 2m10s
2026-06-15 20:15:18 +03:00
Toutsu fa506b2aef Merge pull request #143: feat(discord): /newsession wizard parity with Telegram (Format, Location, publish)
Deploy Telegram Bot / build-and-push (push) Successful in 26m45s
Deploy Telegram Bot / scan-images (push) Successful in 8m55s
Deploy Telegram Bot / deploy (push) Successful in 2m13s
2026-06-15 19:34:54 +03:00
Toutsu e0602052ea review fixes: complete success path, pool capacity navigation, time parser tests
PR Checks / test-and-build (pull_request) Successful in 24m27s
- DiscordWizardSubmitter.SubmitAsync: confirm success, delete draft, clear context.
- GameCreationWizard: pool free-text duration now advances to Capacity.
- PreviousStep(Capacity) returns PoolSystemDuration for pools.
- Remove unused optional IPlatformMessenger/NpgsqlDataSource from submitter.
- Add DiscordTimeParserTests preserving ParseTimeInput coverage.
2026-06-15 18:37:23 +03:00
Toutsu 9709d09b15 feat(discord): make /newsession identical to Telegram wizard
PR Checks / test-and-build (pull_request) Successful in 30m45s
- Remove legacy DiscordNewSessionCommand/Handler and their tests.
- Rename /newsession-wizard to /newsession.
- Add shared pool capacity step before Format/Location.
- Render Format and Location in Discord wizard; Location uses a modal.
- Propagate Format, JoinLink and LocationAddress in BuildCommand.
- Publish created sessions through existing IPlatformMessenger pipeline.
- Update README, version bump to 3.11.1, sync compose/deploy/NavMenu.
2026-06-15 17:49:53 +03:00
Toutsu a391c51761 feat(listsessions): add join/leave buttons for players
Deploy Telegram Bot / build-and-push (push) Successful in 21m33s
Deploy Telegram Bot / scan-images (push) Successful in 8m55s
Deploy Telegram Bot / deploy (push) Successful in 2m3s
For non-managers /listsessions now shows player-friendly actions:
-  Записаться <date> when not registered
- ✖️ Выйти <date> when already active
- ✖️ Выйти из ожидания <date> when waitlisted

Extend SessionListItemDto and the shared SQL query with IsUserActive
and IsUserWaitlisted flags so the renderer can choose the right button.
Update tests to cover all three player states.
2026-06-15 15:05:12 +03:00
Toutsu e15652399b feat(bot): register Telegram command menu on startup
Deploy Telegram Bot / build-and-push (push) Successful in 23m54s
Deploy Telegram Bot / scan-images (push) Successful in 9m14s
Deploy Telegram Bot / deploy (push) Successful in 1m41s
Set /start, /newsession, /listsessions, /exportcalendar and /help
via setMyCommands for both private chats and group chats so users
see the command list when typing '/'.

Also update /help text to list all commands first and then show the
example.
2026-06-15 11:41:33 +03:00
Toutsu 40b13db320 fix(list-sessions): clarify manager action button labels
Deploy Telegram Bot / build-and-push (push) Successful in 3m6s
Deploy Telegram Bot / scan-images (push) Successful in 8m43s
Deploy Telegram Bot / deploy (push) Successful in 2m5s
The /listsessions buttons for owners/co-GMs only showed emoji + date,
so it was unclear what each button did. Add explicit verb labels:
-  Отменить <date>
-  Перенести <date>
- ⬆️ С ожидания <date>
- 🗑 Удалить <date>

Update the renderer test to assert the new labels.
2026-06-15 10:57:15 +03:00
Toutsu e0ee8fc962 fix(list-sessions): clarify manager action button labels
The /listsessions buttons for owners/co-GMs only showed emoji + date,
so it was unclear what each button did. Add explicit verb labels:
-  Отменить <date>
-  Перенести <date>
- ⬆️ С ожидания <date>
- 🗑 Удалить <date>

Update the renderer test to assert the new labels.
2026-06-15 10:56:42 +03:00
Toutsu 6707a2850c fix(list-sessions): clarify manager action button labels
Deploy Telegram Bot / build-and-push (push) Successful in 23m5s
Deploy Telegram Bot / scan-images (push) Failing after 13m20s
Deploy Telegram Bot / deploy (push) Has been skipped
The /listsessions buttons for owners/co-GMs only showed emoji + date,
so it was unclear what each button did. Add explicit verb labels:
-  Отменить <date>
-  Перенести <date>
- ⬆️ С ожидания <date>
- 🗑 Удалить <date>

Update the renderer test to assert the new labels.
2026-06-15 10:55:59 +03:00
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
109 changed files with 5602 additions and 885 deletions
+38 -2
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.9.0
VERSION: 3.11.3
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.9.0</Version>
<Version>3.11.3</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+4 -4
View File
@@ -4,14 +4,14 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.6.0`.
**Текущая версия:** `v3.11.1`.
---
## ✨ Key Features
### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
- **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация.
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
@@ -25,7 +25,7 @@
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
### Discord Bot
- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале.
- **Slash-команды `/newsession` и `/listsessions`**: `/newsession` ведёт ГМа по тому же wizard, что и в Telegram: тип, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка или адрес, видимость и публикация. `/listsessions` показывает расписание и управление сессиями.
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
@@ -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`.
## 📚 Портфолио завершённых приключений
+50 -1
View File
@@ -1,4 +1,53 @@
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
## 🎯 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) использовали один и тот же движок визарда.
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.3
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.3
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.3
restart: always
depends_on:
db:
@@ -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",
@@ -5,11 +5,13 @@ using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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 SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
@@ -31,17 +33,23 @@ public sealed class CreateSessionHandler
private readonly SharedCreateSessionHandler _shared;
private readonly IWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
private readonly IPlatformMessenger? _platformMessenger;
private readonly NpgsqlDataSource? _dataSource;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
IWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
ILogger<CreateSessionHandler> log,
IPlatformMessenger? platformMessenger = null,
NpgsqlDataSource? dataSource = null)
{
_drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
_platformMessenger = platformMessenger;
_dataSource = dataSource;
}
/// <summary>
@@ -66,16 +74,16 @@ public sealed class CreateSessionHandler
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, 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;
}
@@ -106,19 +114,24 @@ public sealed class CreateSessionHandler
}
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.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
catch (Exception ex)
{
@@ -135,16 +148,96 @@ public sealed class CreateSessionHandler
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditDraftMessageAsync(
draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
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.
@@ -170,7 +263,7 @@ public sealed class CreateSessionHandler
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
p.Single?.MaxPlayers,
isOneShot: true),
};
}
@@ -178,11 +271,11 @@ public sealed class CreateSessionHandler
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 user = new PlatformUser(
@@ -200,15 +293,16 @@ public sealed class CreateSessionHandler
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)
@@ -224,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
{
@@ -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
@@ -82,6 +82,14 @@ public sealed class TelegramWizardMessenger(
// 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
@@ -95,10 +103,8 @@ public sealed class TelegramWizardMessenger(
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<WizardClubOption>(
new CommandDefinition(
sql,
new { Platform = "Telegram", ExternalId = ownerId },
cancellationToken: ct));
sql,
new { Platform = "Telegram", ExternalId = ownerId });
return rows.AsList();
}
@@ -23,11 +23,18 @@ internal static class SessionListMessageRenderer
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
if (sessions.Count == 0)
{
return [];
}
return sessions.First().CanManage
? RenderManagerActions(sessions)
: RenderPlayerActions(sessions);
}
private static IReadOnlyList<PlatformMessageAction> RenderManagerActions(IReadOnlyList<SessionListItemDto> sessions)
{
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
@@ -36,19 +43,19 @@ internal static class SessionListMessageRenderer
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"❌ Отменить {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"⏰ Перенести {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"⬆️ С ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
@@ -60,4 +67,31 @@ internal static class SessionListMessageRenderer
return actions;
}
private static IReadOnlyList<PlatformMessageAction> RenderPlayerActions(IReadOnlyList<SessionListItemDto> sessions)
{
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
if (session.IsUserActive || session.IsUserWaitlisted)
{
actions.Add(new PlatformMessageAction(
$"leave_session:{session.Id}",
session.IsUserWaitlisted ? $"✖️ Выйти из ожидания {dateTitle}" : $"✖️ Выйти {dateTitle}",
$"leave_session:{session.Id}"));
}
else
{
actions.Add(new PlatformMessageAction(
$"join_session:{session.Id}",
$"✅ Записаться {dateTitle}",
$"join_session:{session.Id}"));
}
}
return actions;
}
}
@@ -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>(
@@ -0,0 +1,46 @@
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <summary>
/// Registers the bot's command list with Telegram so users see the
/// command menu when they type "/" in a chat.
/// </summary>
public sealed class TelegramCommandsSetupService(
ITelegramBotClient bot,
ILogger<TelegramCommandsSetupService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var commands = new[]
{
new BotCommand { Command = "start", Description = "Начать работу с ботом" },
new BotCommand { Command = "newsession", Description = "Создать новую игровую сессию" },
new BotCommand { Command = "listsessions", Description = "Список предстоящих сессий" },
new BotCommand { Command = "exportcalendar", Description = "Экспортировать расписание в ICS" },
new BotCommand { Command = "help", Description = "Справка по командам" }
};
try
{
await bot.SetMyCommands(
commands,
scope: new BotCommandScopeAllPrivateChats(),
cancellationToken: cancellationToken);
await bot.SetMyCommands(
commands,
scope: new BotCommandScopeAllGroupChats(),
cancellationToken: cancellationToken);
logger.LogInformation("Telegram command menu registered for private chats and groups.");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to register Telegram command menu.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -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} мин";
}
}
@@ -366,6 +366,13 @@ public sealed class UpdateRouter(
text: """
GM-Relay бот для управления игровыми сессиями.
/start начать работу с ботом
/newsession создать новую игровую сессию
/listsessions список предстоящих сессий
/exportcalendar экспортировать расписание в ICS
/help эта справка
Пример создания сессии:
/newsession
Название: My Game
Время: 15.05.2026 19:30
@@ -377,10 +384,8 @@ public sealed class UpdateRouter(
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
/help эта справка
""",
cancellationToken: ct);
break;
@@ -0,0 +1,2 @@
ALTER TABLE sessions
ADD COLUMN location_address TEXT;
+1
View File
@@ -98,6 +98,7 @@ builder.Services.AddSingleton<DirectSessionNotificationSender>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramCommandsSetupService>();
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>();
@@ -1,128 +0,0 @@
using GmRelay.DiscordBot.Rendering;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
private readonly ILogger<DiscordNewSessionCommand> _logger;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler, ILogger<DiscordNewSessionCommand> logger)
{
_handler = handler;
_logger = logger;
}
[SlashCommand("newsession", "Create a new game session")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
{
_logger.LogInformation(
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
Context.User.Id,
Context.User.GetType().Name,
Context.Interaction.GuildId,
Context.Channel?.Id);
var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild.");
var member = Context.User as GuildInteractionUser;
if (member is null)
{
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
throw new InvalidOperationException("Guild member data not available in interaction.");
}
var resolvedPermissions = (ulong)member.Permissions;
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
var guildName = guildId.ToString();
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
guildName = guild.Name;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
guildId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
}
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"X {timeResult.Error}"));
return;
}
// Defer the response to avoid Discord 3-second interaction timeout
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
try
{
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
var view = await _handler.HandleAsync(
guildId: guildId.ToString(),
channelId: Context.Channel!.Id.ToString(),
groupName: guildName,
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guildOwnerId,
title: title,
scheduledAt: timeResult.Value,
maxPlayers: seats is null ? null : (int)seats.Value,
joinLink: link,
CancellationToken.None);
_logger.LogInformation("Session created successfully. Building render.");
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
_logger.LogInformation("Sending success response.");
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":white_check_mark: **Session created successfully!**";
message.Embeds = embeds;
message.Components = actionRows;
});
_logger.LogInformation("Success response sent.");
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":no_entry: {ex.Message}";
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":boom: An error occurred while creating the session.";
});
}
}
}
@@ -1,156 +0,0 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
ILogger<DiscordNewSessionHandler> logger)
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input)
{
var trimmed = input.Trim();
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt1))
{
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
if (DateTime.TryParseExact(
trimmed,
"dd.MM.yyyy HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt2))
{
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
string groupName,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
? title
: groupName.Trim();
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"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",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var transactionCommitted = false;
try
{
await connection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username",
new { Name = userDisplayName, UserId = userId.ToString() },
transaction);
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
transaction);
await connection.ExecuteAsync(
@"INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
ON CONFLICT (group_id, player_id) DO NOTHING",
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
var batchId = Guid.NewGuid();
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = joinLink ?? string.Empty,
ScheduledAt = scheduledAt.UtcDateTime,
Status = SessionStatus.Planned,
MaxPlayers = maxPlayers
},
transaction);
await transaction.CommitAsync(cancellationToken);
transactionCommitted = true;
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
}
catch
{
if (!transactionCommitted)
{
await transaction.RollbackAsync(cancellationToken);
}
throw;
}
}
}
@@ -75,7 +75,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
var parsedOptions = new List<DateTimeOffset>();
foreach (var opt in options)
{
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
var result = DiscordTimeParser.ParseTimeInput(opt);
if (!result.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
@@ -85,7 +85,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
parsedOptions.Add(result.Value);
}
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
var deadlineResult = DiscordTimeParser.ParseTimeInput(deadline);
if (!deadlineResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
@@ -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,45 @@
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public static class DiscordTimeParser
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input)
{
var trimmed = input.Trim();
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt1))
{
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
if (DateTime.TryParseExact(
trimmed,
"dd.MM.yyyy HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt2))
{
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
}
@@ -8,10 +8,9 @@ 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.
/// Small lookup helper for Discord permission checks. The slash command
/// and reschedule command both need to enumerate DB managers for a guild;
/// this class centralises the query so it isn't duplicated.
/// </summary>
internal static class DiscordPermissionLookup
{
@@ -31,8 +30,10 @@ internal static class DiscordPermissionLookup
""";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await connection.QueryAsync<ulong>(
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
sql,
new { GuildId = guildId.ToString() });
return rows.ToList();
}
}
@@ -13,7 +13,7 @@ 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
/// <c>/newsession</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
@@ -44,7 +44,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
_log = log;
}
[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
[SlashCommand("newsession", "Пошаговое создание игры или пула")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
{
@@ -115,9 +115,9 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
? WizardStepNames.Title
: WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
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.
@@ -147,7 +147,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
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);
await Context.Interaction.ModifyResponseAsync(msg =>
@@ -132,6 +132,7 @@ public sealed class WizardInteractionDispatcher
WizardStepNames.Cover,
WizardStepNames.DateTime,
WizardStepNames.Capacity,
WizardStepNames.Location,
WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolSlotCapacity,
"SystemFreeText",
@@ -166,7 +167,7 @@ public sealed class WizardInteractionDispatcher
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -276,14 +277,14 @@ public sealed class WizardInteractionDispatcher
// 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.
// re-run /newsession.
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 заново.")
.WithContent("♻️ Мастер сброшен. Запустите /newsession заново.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -314,7 +315,7 @@ public sealed class WizardInteractionDispatcher
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -365,7 +366,7 @@ public sealed class WizardInteractionDispatcher
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -537,11 +538,10 @@ internal static class WizardClubLookup
ORDER BY g.name
""";
await using var conn = await dataSource.OpenConnectionAsync(ct);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await conn.QueryAsync<WizardClubOption>(
new CommandDefinition(
sql,
new { Platform = "Discord", OwnerId = ownerId },
cancellationToken: ct));
sql,
new { Platform = "Discord", OwnerId = ownerId });
return rows.AsList();
}
}
@@ -164,11 +164,11 @@ public sealed class DiscordWizardMessenger : IWizardMessenger
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>(
new CommandDefinition(
sql,
new { Platform = "Discord", ExternalId = ownerId },
cancellationToken: ct));
sql,
new { Platform = "Discord", ExternalId = ownerId });
return rows.AsList();
}
@@ -60,6 +60,8 @@ public static class DiscordWizardStep
WizardStepNames.Duration => RenderDuration(),
WizardStepNames.DateTime => RenderDateTime(),
WizardStepNames.Capacity => RenderCapacity(),
WizardStepNames.Format => RenderFormat(),
WizardStepNames.Location => RenderLocation(payload),
WizardStepNames.Visibility => RenderVisibility(),
WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => RenderPublish(),
@@ -252,16 +254,40 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderCapacity() => new(
"👥 Лимит мест",
"Введите лимит (1..50) и выберите waitlist.",
"Введите лимит (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 RenderFormat() => new(
"🧭 Формат игры",
"Выберите формат.",
new[]
{
Row(ChoiceBtn("🌐 Online", WizardStepNames.Format, "online", ButtonStyle.Primary),
ChoiceBtn("📍 Offline", WizardStepNames.Format, "offline", ButtonStyle.Primary)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderLocation(WizardPayload payload)
{
var isOnline = payload.Format == WizardSessionFormat.Online;
return new DiscordWizardRender(
isOnline ? "🔗 Ссылка" : "📍 Адрес",
isOnline ? "Введите ссылку для подключения." : "Введите адрес места проведения.",
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Location);
}
private static DiscordWizardRender RenderVisibility() => new(
"🔒 Видимость",
"Выберите, кто увидит сессию.",
@@ -371,11 +397,12 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
"👥 Лимит слотов",
"Введите лимит (1..50) и выберите waitlist.",
"Введите лимит (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)),
},
@@ -417,6 +444,10 @@ public static class DiscordWizardStep
{
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();
}
@@ -560,6 +591,20 @@ public static class DiscordWizardStep
Required = true,
}),
}),
WizardStepNames.Location => new ModalProperties(
ModalCustomId(WizardStepNames.Location),
"🔗 Ссылка / 📍 Адрес",
new IModalComponentProperties[]
{
new LabelProperties(
"Ссылка или адрес",
new TextInputProperties(ModalCustomId(WizardStepNames.Location), TextInputStyle.Short)
{
Placeholder = "https://… или адрес",
MaxLength = WizardStepLimits.MaxLocationLength,
Required = true,
}),
}),
WizardStepNames.PoolSlotDateTime => new ModalProperties(
ModalCustomId(WizardStepNames.PoolSlotDateTime),
"📅 Дата/время слота",
@@ -65,19 +65,32 @@ public sealed class DiscordWizardSubmitter
return;
}
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
try
{
var commands = BuildCommands(draft, payload);
foreach (var cmd in commands)
{
await _shared.HandleAsync(cmd, ct);
var result = await _shared.HandleAsync(cmd, ct);
if (!result.Success)
{
await EditDraftMessageAsync(
draft,
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
Array.Empty<WizardAction>(),
ct);
return;
}
created.Add((cmd, result));
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
// Success: replace the wizard message with a confirmation and
// clean up the draft so the user can start a new one later.
var confirmation = created.Count == 1
? $"✅ Создано: {created[0].Command.Title}"
: $"✅ Создано: {created[0].Command.Title} и ещё {created.Count - 1} сессия/сессии";
await EditDraftMessageAsync(draft, confirmation, Array.Empty<WizardAction>(), ct);
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
}
@@ -90,14 +103,14 @@ public sealed class DiscordWizardSubmitter
{
await EditDraftMessageAsync(
draft,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
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
@@ -134,19 +147,19 @@ public sealed class DiscordWizardSubmitter
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
p.Single?.MaxPlayers,
isOneShot: true),
};
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
pool.MaxPlayers ?? (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 user = new PlatformUser(
@@ -164,15 +177,16 @@ public sealed class DiscordWizardSubmitter
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)
@@ -188,12 +202,15 @@ public sealed class DiscordWizardSubmitter
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.
}
else
{
@@ -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>
+2 -1
View File
@@ -27,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
using NetCord.Services.ComponentInteractions;
using Npgsql;
[module: Dapper.DapperAot]
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
@@ -59,7 +61,6 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
@@ -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
@@ -169,7 +169,7 @@ public sealed class GameCreationWizard
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;
@@ -224,11 +224,31 @@ public sealed class GameCreationWizard
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
case WizardStepNames.Capacity:
if (payload.Type == WizardCreationType.Pool)
{
if (payload.Pool?.MaxPlayers is not null) return (null, "Лимит уже задан", payload);
return int.TryParse(input, out var poolCap) && poolCap >= WizardStepLimits.MinCapacity && poolCap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.Format, SetPoolMaxPlayers(payload, poolCap), payload)
: (null, "Лимит должен быть 1..50", payload);
}
if (payload.Single?.MaxPlayers is not null) return (null, "Лимит уже задан", payload);
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
? (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, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
@@ -236,7 +256,7 @@ public sealed class GameCreationWizard
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
? (WizardStepNames.Capacity, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
@@ -264,6 +284,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),
@@ -298,10 +319,32 @@ 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 p.Type == WizardCreationType.Pool
? (WizardStepNames.Format, SetPoolMaxPlayers(p, null))
: (WizardStepNames.Format, SetMaxPlayers(p, null));
}
if (choice is "waitlist:on" or "waitlist:off" && p.Type != WizardCreationType.Pool && 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, "Неизвестный выбор"),
};
@@ -315,9 +358,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
{
@@ -330,11 +379,12 @@ 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.Capacity, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
{
"add" => BeginNewPoolSlot(p),
@@ -371,14 +421,16 @@ public sealed class GameCreationWizard
WizardStepNames.System => WizardStepNames.Cover,
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity,
WizardStepNames.Capacity => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.DateTime,
WizardStepNames.Format => 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,
@@ -416,13 +468,24 @@ 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? SetPoolMaxPlayers(WizardPayload p, int? v)
{ p.Pool ??= new WizardPoolInput(); p.Pool.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)
{
@@ -469,8 +532,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.Capacity;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
{
@@ -481,6 +544,7 @@ public sealed class GameCreationWizard
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
}
private static (string? sys, int? dur) SplitSystemDuration(string s)
{
var idx = s.IndexOf(':');
@@ -43,9 +43,9 @@ public sealed class WizardDraft
/// </summary>
public string? DraftMessageId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
@@ -8,6 +8,13 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
{
// 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 = """
@@ -29,13 +36,10 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
ORDER BY updated_at DESC
LIMIT 1
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
new CommandDefinition(
sql,
new { Platform = platform, OwnerId = ownerId },
cancellationToken: ct));
sql,
new { Platform = platform, OwnerId = ownerId });
}
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
@@ -52,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; }
@@ -44,6 +50,8 @@ public sealed class WizardPayload
public sealed class WizardPoolInput
{
public int? MaxPlayers { get; set; }
public List<WizardSlotInput> Slots { get; set; } = new();
}
@@ -14,4 +14,5 @@ public static class WizardStepLimits
public const int MinCapacity = 1;
public const int MinDurationHours = 1;
public const int MaxDurationHours = 12;
public const int MaxLocationLength = 500;
}
@@ -16,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";
@@ -30,6 +30,8 @@ public static class WizardStepViewBuilder
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(),
@@ -97,13 +99,30 @@ public static class WizardStepViewBuilder
BackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
"👥 Введите лимит мест (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>
@@ -149,6 +168,7 @@ public static class WizardStepViewBuilder
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)}");
@@ -203,6 +223,8 @@ public static class WizardStepViewBuilder
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.Pool?.MaxPlayers is { } poolMax) sb.AppendLine($"👥 Мест в пуле: {poolMax}");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
sb.AppendLine();
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
@@ -244,4 +266,19 @@ public static class WizardStepViewBuilder
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}");
}
}
}
@@ -5,7 +5,17 @@ using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed record SessionListItemDto(
Guid Id,
string Title,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
int PlayerCount,
int WaitlistCount,
bool CanManage,
bool IsUserActive,
bool IsUserWaitlisted);
public sealed record SessionListResult(
IReadOnlyList<SessionListItemDto> Sessions,
@@ -29,7 +39,27 @@ public sealed class ListSessionsHandler(
WHERE gm.group_id = s.group_id
AND manager_player.platform = @Platform
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
) AS CanManage,
EXISTS (
SELECT 1
FROM session_participants user_sp
JOIN players user_p ON user_p.id = user_sp.player_id
WHERE user_sp.session_id = s.id
AND user_sp.is_gm = false
AND user_sp.registration_status = @Active
AND user_p.platform = @Platform
AND user_p.external_user_id = @ExternalUserId
) AS IsUserActive,
EXISTS (
SELECT 1
FROM session_participants user_sp
JOIN players user_p ON user_p.id = user_sp.player_id
WHERE user_sp.session_id = s.id
AND user_sp.is_gm = false
AND user_sp.registration_status = @Waitlisted
AND user_p.platform = @Platform
AND user_p.external_user_id = @ExternalUserId
) AS IsUserWaitlisted
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
@@ -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,
@@ -0,0 +1,198 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace GmRelay.Shared.Telegram;
/// <summary>
/// Generates Telegram authentication payloads that pass the validation performed by
/// <see cref="GmRelay.Web.Services.TelegramAuthService"/>.
///
/// Useful for tests and local E2E runners that need a valid Telegram user identity without
/// talking to real Telegram servers.
/// </summary>
public static class TelegramAuthPayloadBuilder
{
/// <summary>
/// Builds a Telegram Login Widget query string and hash.
/// The resulting query can be sent to the widget callback endpoint.
/// </summary>
public static LoginWidgetResult BuildLoginWidget(
string botToken,
long telegramId,
string firstName,
string? lastName = null,
string? username = null,
string? photoUrl = null,
long? authDate = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(botToken);
ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
var timestamp = authDate ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["auth_date"] = timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture),
["first_name"] = firstName,
["id"] = telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(lastName))
values["last_name"] = lastName;
if (!string.IsNullOrWhiteSpace(photoUrl))
values["photo_url"] = photoUrl;
if (!string.IsNullOrWhiteSpace(username))
values["username"] = username;
var hash = ComputeLoginWidgetHash(botToken, values);
values["hash"] = hash;
var queryString = string.Join(
"&",
values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
return new LoginWidgetResult(
telegramId,
firstName,
lastName,
username,
photoUrl,
timestamp,
hash,
queryString);
}
/// <summary>
/// Builds a Telegram Mini App initData raw string (the value passed in the WebApp URL hash).
/// </summary>
public static MiniAppInitDataResult BuildMiniAppInitData(
string botToken,
long telegramId,
string firstName,
string? lastName = null,
string? username = null,
string? photoUrl = null,
string? languageCode = null,
bool isPremium = false,
long? chatId = null,
string? chatType = null,
string? chatTitle = null,
string? queryId = null,
string? startParam = null,
long? authDate = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(botToken);
ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
var userPayload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["id"] = telegramId,
["first_name"] = firstName,
["last_name"] = lastName,
["username"] = username,
["photo_url"] = photoUrl,
["language_code"] = languageCode,
["is_premium"] = isPremium ? true : null
};
// Remove null values to match real Telegram initData serialization.
var userJson = JsonSerializer.Serialize(
userPayload.Where(kv => kv.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value),
JsonSerializerOptions.Web);
var timestamp = authDate ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["auth_date"] = timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture),
["user"] = userJson
};
if (!string.IsNullOrWhiteSpace(queryId))
values["query_id"] = queryId;
if (!string.IsNullOrWhiteSpace(startParam))
values["start_param"] = startParam;
if (chatId.HasValue)
{
values["chat"] = JsonSerializer.Serialize(new
{
id = chatId.Value,
type = chatType ?? "private",
title = chatTitle
}, JsonSerializerOptions.Web);
}
var hash = ComputeMiniAppHash(botToken, values);
var pairs = values
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")
.Append($"hash={hash}");
var initDataRaw = string.Join("&", pairs);
return new MiniAppInitDataResult(
telegramId,
firstName,
lastName,
username,
photoUrl,
timestamp,
hash,
initDataRaw);
}
/// <summary>
/// Computes the HMAC-SHA256 hash used by Telegram Login Widget callbacks.
/// </summary>
public static string ComputeLoginWidgetHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
/// <summary>
/// Computes the HMAC-SHA256 hash used by Telegram Mini App initData.
/// </summary>
public static string ComputeMiniAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
public sealed record LoginWidgetResult(
long TelegramId,
string FirstName,
string? LastName,
string? Username,
string? PhotoUrl,
long AuthDate,
string Hash,
string QueryString);
public sealed record MiniAppInitDataResult(
long TelegramId,
string FirstName,
string? LastName,
string? Username,
string? PhotoUrl,
long AuthDate,
string Hash,
string InitDataRaw);
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.9.0</div>
<div class="nav-version">v3.11.3</div>
</div>
</Authorized>
<NotAuthorized>
@@ -10,6 +10,7 @@
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Сессии группы — GM-Relay</PageTitle>
@@ -393,6 +394,9 @@
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline">📜 История</a>
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingSessionId == session.Id)" @onclick="() => DeleteSession(session.Id, session.Title)">
@(deletingSessionId == session.Id ? "⏳ Удаляем..." : "🗑 Удалить")
</button>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -491,6 +495,9 @@
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">📜 История</a>
<button type="button" class="btn-gm btn-gm-danger" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(deletingSessionId == session.Id)" @onclick="() => DeleteSession(session.Id, session.Title)">
@(deletingSessionId == session.Id ? "⏳ Удаляем..." : "🗑 Удалить")
</button>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -572,6 +579,7 @@
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
private Guid? loadingParticipantsSessionId;
private Guid? deletingSessionId;
protected override async Task OnInitializedAsync()
{
@@ -904,6 +912,40 @@
}
}
private async Task DeleteSession(Guid sessionId, string title)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Удалить сессию «{title}»?");
if (!confirmed)
{
return;
}
errorMessage = null;
successMessage = null;
deletingSessionId = sessionId;
try
{
await SessionService.DeleteSessionForCurrentUserAsync(sessionId);
expandedSessions.Remove(sessionId);
participantsCache.Remove(sessionId);
successMessage = "Сессия удалена.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
deletingSessionId = null;
}
}
private static string FormatParticipantUsername(WebParticipant p)
{
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
@@ -229,6 +229,22 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
}
public async Task DeleteSessionForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
var title = await sessionStore.DeleteSessionAsync(sessionId, session.GroupId);
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Deleted", title, null);
}
public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
@@ -150,6 +150,7 @@ public interface ISessionStore
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
+154 -8
View File
@@ -119,9 +119,24 @@ 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 WebDeleteSessionInfo(
Guid Id,
string Title,
Guid BatchId,
long TelegramChatId,
int? BatchMessageId,
int? ThreadId,
bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow(
Guid GroupId,
string Name,
@@ -1079,6 +1094,95 @@ public sealed class SessionService(
}
}
public async Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebDeleteSessionInfo>(
"""
SELECT s.id AS Id,
s.title AS Title,
s.batch_id AS BatchId,
g.external_group_id::BIGINT AS TelegramChatId,
s.batch_message_id AS BatchMessageId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
FOR UPDATE
""",
new { SessionId = sessionId, GroupId = groupId },
transaction);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, "0");
}
await conn.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
AND pgs.session_id = @SessionId
AND pg.is_public = true
""",
new { SessionId = sessionId },
transaction);
await conn.ExecuteAsync(
"DELETE FROM sessions WHERE id = @Id AND group_id = @GroupId",
new { Id = sessionId, GroupId = groupId },
transaction);
var remainingInTopic = session.ThreadId.HasValue
? await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { GroupId = groupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync();
if (session.ThreadId.HasValue && session.TopicCreatedByBot && remainingInTopic == 0)
{
try
{
await bot.DeleteForumTopic(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId.Value);
logger.LogInformation(
"Deleted forum topic {ThreadId} for group {GroupId} as no sessions remained.",
session.ThreadId.Value,
groupId);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to delete forum topic {ThreadId} for group {GroupId}",
session.ThreadId.Value,
groupId);
}
}
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
return session.Title;
}
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -1508,7 +1612,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 +1647,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 +1668,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 +1905,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 +2043,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} мин";
}
}
@@ -1,225 +0,0 @@
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordNewSessionHandlerTests
{
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");
}
// --- Runtime tests for ParseTimeInput (static, no DB) ---
[Fact]
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
var result = DiscordNewSessionHandler.ParseTimeInput(
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
// 15:00 MSK = 12:00 UTC
Assert.Equal(12, result.Value.Hour);
Assert.Equal(0, result.Value.Minute);
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
}
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordNewSessionHandler.ParseTimeInput(
expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
Assert.Equal(16, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordNewSessionHandler.ParseTimeInput(
expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
// --- Source-level structural tests ---
[Fact]
public void Handler_ShouldExist()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
Assert.True(File.Exists(handlerPath), "DiscordNewSessionHandler should exist.");
}
[Fact]
public void Handler_ShouldUseDapperForDatabaseAccess()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("QueryAsync", source, StringComparison.Ordinal);
Assert.Contains("ExecuteAsync", source, StringComparison.Ordinal);
Assert.Contains("ExecuteScalarAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldUseNpgsqlDataSource()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("NpgsqlDataSource", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldCheckPermissionsViaPermissionChecker()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("CanManageSchedule", source, StringComparison.Ordinal);
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldLoadCoGmPermissionsFromDiscordPlayers()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Matches(
@"QueryAsync<ulong>[\s\S]*JOIN players p ON p\.id = gm\.player_id[\s\S]*p\.platform = 'Discord'[\s\S]*g\.external_group_id = @GuildId",
source);
}
[Fact]
public void Handler_ShouldBePlatformNeutral()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.DoesNotContain("telegram_chat_id", source, StringComparison.Ordinal);
Assert.DoesNotContain("telegram_id", source, StringComparison.Ordinal);
Assert.Contains("platform = 'Discord'", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldUseTransactions()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("BeginTransactionAsync", source, StringComparison.Ordinal);
Assert.Contains("CommitAsync", source, StringComparison.Ordinal);
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldRespectCancellationToken()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("CancellationToken", source, StringComparison.Ordinal);
}
[Fact]
public void Command_ShouldRenderEmbedOnSuccess()
{
var repoRoot = GetRepoRoot();
var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionCommand.cs");
var source = File.ReadAllText(commandPath);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("groupName", source, StringComparison.Ordinal);
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
}
private static DateTimeOffset FutureDateAt1930()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
return new DateTimeOffset(
future.Year,
future.Month,
future.Day,
19,
30,
0,
TimeSpan.Zero);
}
}
@@ -2,6 +2,7 @@ using System;
using System.IO;
using System.Reflection;
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.Bot.Tests.Discord;
@@ -54,7 +55,6 @@ public sealed class DiscordStartupTests
}
[Theory]
[InlineData(typeof(DiscordNewSessionCommand), "newsession")]
[InlineData(typeof(DiscordListSessionsCommand), "listsessions")]
[InlineData(typeof(DiscordRescheduleCommand), "reschedule")]
public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName)
@@ -76,15 +76,28 @@ public sealed class DiscordStartupTests
{
var service = new ApplicationCommandService<SlashCommandContext>();
service.AddModules(typeof(DiscordNewSessionCommand).Assembly);
service.AddModules(typeof(DiscordListSessionsCommand).Assembly);
var commandNames = service.GetCommands()
.Select(command => command.Name)
.ToArray();
Assert.Contains("listsessions", commandNames);
Assert.Contains("reschedule", commandNames);
}
[Fact]
public void DiscordSessionSlashCommands_ShouldIncludeNewSessionWizard()
{
var service = new ApplicationCommandService<SlashCommandContext>();
service.AddModules(typeof(DiscordWizardCommand).Assembly);
var commandNames = service.GetCommands()
.Select(command => command.Name)
.ToArray();
Assert.Contains("newsession", commandNames);
Assert.Contains("listsessions", commandNames);
Assert.Contains("reschedule", commandNames);
}
[Fact]
@@ -114,7 +127,6 @@ public sealed class DiscordStartupTests
{
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("JoinSessionHandler", program);
Assert.Contains("LeaveSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
@@ -0,0 +1,77 @@
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordTimeParserTests
{
[Fact]
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
var result = DiscordTimeParser.ParseTimeInput(
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
// 15:00 MSK = 12:00 UTC
Assert.Equal(12, result.Value.Hour);
Assert.Equal(0, result.Value.Minute);
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
}
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordTimeParser.ParseTimeInput(
expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
Assert.Equal(16, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordTimeParser.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordTimeParser.ParseTimeInput(
expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordTimeParser.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
private static DateTimeOffset FutureDateAt1930()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
return new DateTimeOffset(
future.Year,
future.Month,
future.Day,
19,
30,
0,
TimeSpan.Zero);
}
}
@@ -0,0 +1,89 @@
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();
[Fact]
public void RenderFormat_ContainsOnlineAndOfflineButtons()
{
var draft = new WizardDraft { Step = WizardStepNames.Format };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var labels = ExtractButtonLabels(render);
Assert.Contains(labels, l => l.Contains("Online", System.StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Offline", System.StringComparison.Ordinal));
}
[Theory]
[InlineData(WizardSessionFormat.Online, "🔗 Ссылка")]
[InlineData(WizardSessionFormat.Offline, "📍 Адрес")]
public void RenderLocation_ForFormat_OpensModalAndShowsPrompt(WizardSessionFormat format, string expectedTitle)
{
var draft = new WizardDraft { Step = WizardStepNames.Location };
var render = DiscordWizardStep.Render(draft, new WizardPayload { Format = format });
Assert.Equal(expectedTitle, render.EmbedTitle);
Assert.Equal(WizardStepNames.Location, render.OpenModalStep);
}
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,198 @@
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);
}
[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 = 5,
},
};
var cmd = DiscordWizardSubmitter.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 = 5,
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal("Offline", cmd.Format);
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
Assert.Equal(string.Empty, cmd.Link);
}
[Fact]
public void BuildCommand_WhenPoolMaxPlayersIsSet_PropagatesValueToMaxPlayers()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var slotTime = DateTimeOffset.UtcNow.AddDays(1);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Pool = new WizardPoolInput
{
MaxPlayers = 12,
Slots = { new WizardSlotInput { ScheduledAt = slotTime, MaxPlayers = 8 } },
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { slotTime },
payload.Pool.MaxPlayers,
isOneShot: false);
Assert.Equal(12, 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);
}
}
@@ -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;
}
@@ -36,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),
@@ -69,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
{
@@ -104,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 },
};
@@ -135,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(),
};
@@ -146,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);
}
}
@@ -84,17 +84,24 @@ public sealed class GameCreationWizardCancelBackTests
}
[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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
}
[Fact]
@@ -20,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
@@ -47,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests
}
[Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
public async Task PoolSystemDuration_PreselectedButton_AdvancesToCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
@@ -61,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys));
@@ -71,13 +70,126 @@ public sealed class GameCreationWizardStepTransitionsTests
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
public async Task PoolCapacity_Text_AdvancesToFormat_AndSetsMaxPlayers()
{
// 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 payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.Capacity, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("10", 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("pool", out var pool));
Assert.True(pool.TryGetProperty("maxPlayers", out var maxPlayers));
Assert.Equal(10, maxPlayers.GetInt32());
}
[Fact]
public async Task PoolSystemDuration_FreeTextDuration_AdvancesToCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
};
var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("4", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("durationMinutes", out var dur));
Assert.Equal(240, dur.GetInt32());
}
[Fact]
public async Task Back_FromPoolCapacity_GoesToPoolSystemDuration()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.Capacity, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
}
[Theory]
[InlineData("waitlist:on")]
[InlineData("waitlist:off")]
public async Task Capacity_WithMaxPlayersAndWaitlist_AdvancesToFormat(string waitlistChoice)
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1), MaxPlayers = 5 },
};
var draft = NewDraft(WizardStepNames.Capacity, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, waitlistChoice);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Format, draft.Step);
}
[Fact]
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
{
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);
@@ -85,17 +197,85 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
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
{
@@ -111,8 +291,11 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
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]
@@ -160,6 +343,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
{
@@ -136,6 +136,29 @@ public sealed class GameCreationWizardValidationTests
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);
}
[Theory]
[InlineData("0")]
[InlineData("13")]
@@ -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()
@@ -16,11 +16,11 @@ 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.Platform, draft.OwnerId, CancellationToken.None);
@@ -35,7 +35,7 @@ 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.Platform, draft.OwnerId, CancellationToken.None);
@@ -49,7 +49,7 @@ 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 otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1)
@@ -65,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);
@@ -78,7 +78,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
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",
@@ -87,8 +87,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
Platform = "Telegram",
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
};
}
@@ -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));
@@ -40,9 +40,9 @@ internal static class WizardTestFakes
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>
@@ -20,7 +20,9 @@ public sealed class SessionListMessageRendererTests
4,
3,
1,
true)
true,
false,
false)
};
var text = SessionListMessageRenderer.RenderText(sessions);
@@ -32,25 +34,92 @@ public sealed class SessionListMessageRendererTests
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Contains(actions, a => a.Label == $"❌ Отменить {shortDate}");
Assert.Contains(actions, a => a.Label == $"⏰ Перенести {shortDate}");
Assert.Contains(actions, a => a.Label == $"⬆️ С ожидания {shortDate}");
Assert.Contains(actions, a => a.Label == $"🗑 Удалить {shortDate}");
}
[Fact]
public void Render_ShouldHideManagerActions_WhenUserCannotManage()
public void Render_ShouldIncludeJoinAction_WhenPlayerIsNotRegistered()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionListItemDto(
Guid.NewGuid(),
sessionId,
"Ravenloft",
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
3,
0,
false,
false,
false)
};
var actions = SessionListMessageRenderer.RenderActions(sessions);
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Single(actions);
Assert.Contains(actions, a => a.Payload == $"join_session:{sessionId}");
Assert.Contains(actions, a => a.Label == $"✅ Записаться {shortDate}");
}
[Fact]
public void Render_ShouldIncludeLeaveAction_WhenPlayerIsActive()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionListItemDto(
sessionId,
"Ravenloft",
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
3,
0,
false,
true,
false)
};
var actions = SessionListMessageRenderer.RenderActions(sessions);
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Single(actions);
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
Assert.Contains(actions, a => a.Label == $"✖️ Выйти {shortDate}");
}
[Fact]
public void Render_ShouldIncludeLeaveWaitlistAction_WhenPlayerIsWaitlisted()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionListItemDto(
sessionId,
"Ravenloft",
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
3,
1,
false)
false,
false,
true)
};
var actions = SessionListMessageRenderer.RenderActions(sessions);
Assert.Empty(actions);
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Single(actions);
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
Assert.Contains(actions, a => a.Label == $"✖️ Выйти из ожидания {shortDate}");
}
}
@@ -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);
}
}
@@ -815,6 +815,7 @@ public sealed class AuthorizedPortfolioServiceTests
public Task<WebSession?> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId) => throw new NotImplementedException();
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException();
public Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException();
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException();
@@ -765,6 +765,58 @@ public sealed class AuthorizedSessionServiceTests
Assert.False(store.CreateBatchFromTemplateCalled);
}
[Fact]
public async Task DeleteSessionForCurrentUserAsync_Deletes_WhenSessionBelongsToManager()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var accessor = CreateAccessor(gmId.ToString(System.Globalization.CultureInfo.InvariantCulture));
var service = new AuthorizedSessionService(store, accessor);
await service.DeleteSessionForCurrentUserAsync(sessionId);
Assert.True(store.DeleteCalled);
Assert.Equal(sessionId, store.LastDeletedSessionId);
Assert.Equal(groupId, store.LastDeletedGroupId);
Assert.Null(await store.GetSessionAsync(sessionId));
}
[Fact]
public async Task DeleteSessionForCurrentUserAsync_Throws_WhenSessionDoesNotBelongToManager()
{
var gmId = 1001L;
var otherGmId = 2002L;
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId),
new(otherGroupId, 43, "Beta", otherGmId)
],
sessions:
[
new(sessionId, otherGroupId, "Session B", DateTime.UtcNow, "Planned", "https://example.test/b", Guid.NewGuid(), 10, 43, 4, 1, 0)
]);
var accessor = CreateAccessor(gmId.ToString(System.Globalization.CultureInfo.InvariantCulture));
var service = new AuthorizedSessionService(store, accessor);
await Assert.ThrowsAsync<SessionAccessDeniedException>(
() => service.DeleteSessionForCurrentUserAsync(sessionId));
Assert.False(store.DeleteCalled);
}
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null,
@@ -796,6 +848,9 @@ public sealed class AuthorizedSessionServiceTests
public DateTime? LastUpdatedScheduledAt { get; private set; }
public string? LastUpdatedJoinLink { get; private set; }
public int? LastUpdatedMaxPlayers { get; private set; }
public bool DeleteCalled { get; private set; }
public Guid? LastDeletedSessionId { get; private set; }
public Guid? LastDeletedGroupId { get; private set; }
public Guid? LastPromotedSessionId { get; private set; }
public Guid? LastPromotedGroupId { get; private set; }
public Guid? LastUpdatedBatchId { get; private set; }
@@ -1036,6 +1091,15 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId)
{
DeleteCalled = true;
LastDeletedSessionId = sessionId;
LastDeletedGroupId = groupId;
sessionsById.Remove(sessionId);
return Task.FromResult<string?>("Deleted session");
}
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
PromoteCalled = true;
@@ -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.9.0", 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);
}
}
@@ -0,0 +1,223 @@
using System.Security.Cryptography;
using System.Text;
using GmRelay.Shared.Telegram;
using GmRelay.Web.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace GmRelay.Bot.Tests.Web;
public sealed class TelegramAuthPayloadBuilderTests
{
[Fact]
public void BuildLoginWidget_ShouldGeneratePayloadAcceptedByAuthService()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
"Lovelace",
"ada");
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void BuildLoginWidget_ShouldBeRejectedWhenTampered()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(botToken, 424242L, "Ada");
var tamperedQuery = ParseQueryString(result.QueryString.Replace("hash=", "hash=00", StringComparison.Ordinal));
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(tamperedQuery, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildLoginWidget_ShouldBeRejectedWhenExpired()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
authDate: expiredAuthDate);
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildMiniAppInitData_ShouldGeneratePayloadAcceptedByAuthService()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
424242L,
"Ada",
"Lovelace",
"ada");
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void BuildMiniAppInitData_ShouldBeRejectedWhenTampered()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(botToken, 424242L, "Ada");
var tamperedInitData = result.InitDataRaw.Replace("hash=", "hash=00", StringComparison.Ordinal);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildMiniAppInitData_ShouldBeRejectedWhenExpired()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
424242L,
"Ada",
authDate: expiredAuthDate);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildMiniAppInitData_ShouldIncludeOptionalFields()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
424242L,
"Ada",
"Lovelace",
"ada",
photoUrl: "https://t.me/i/userpic/320/ada.jpg",
languageCode: "en",
isPremium: true,
chatId: -1001234567890L,
chatType: "supergroup",
chatTitle: "Test Club",
queryId: "AAHdF6IQAAAAAN0XohDhrOrc",
startParam: "ref123");
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
Assert.Contains("start_param=ref123", result.InitDataRaw, StringComparison.Ordinal);
Assert.Contains("query_id=", result.InitDataRaw, StringComparison.Ordinal);
Assert.Contains("chat=%7B%22id%22%3A-1001234567890", result.InitDataRaw, StringComparison.Ordinal);
}
[Fact]
public void ComputeLoginWidgetHash_ShouldMatchTelegramAuthServiceExpectations()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = "1714300000",
["first_name"] = "Ada",
["id"] = "424242"
};
var hash = TelegramAuthPayloadBuilder.ComputeLoginWidgetHash(botToken, values);
var recomputed = ComputeLegacyTelegramHash(botToken, values);
Assert.Equal(recomputed, hash);
}
[Fact]
public void ComputeMiniAppHash_ShouldMatchTelegramAuthServiceExpectations()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = "1714300000",
["user"] = """{"id":424242,"first_name":"Ada"}"""
};
var hash = TelegramAuthPayloadBuilder.ComputeMiniAppHash(botToken, values);
var recomputed = ComputeLegacyWebAppHash(botToken, values);
Assert.Equal(recomputed, hash);
}
private static IConfiguration CreateConfiguration(string botToken) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Telegram:BotToken"] = botToken
})
.Build();
private static QueryCollection ParseQueryString(string queryString)
{
var parsed = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
return new QueryCollection(parsed.ToDictionary(
pair => pair.Key,
pair => pair.Value));
}
// Legacy inline computation kept to prove the builder matches the original algorithm.
private static string ComputeLegacyTelegramHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string ComputeLegacyWebAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
@@ -1,6 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GmRelay.Shared.Telegram;
using GmRelay.Web.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
@@ -14,17 +13,13 @@ public sealed class TelegramAuthServiceTests
public void Verify_ShouldAcceptValidTelegramPayload()
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
new Dictionary<string, string>
{
["auth_date"] = authDate,
["first_name"] = "Ada",
["id"] = "424242",
["last_name"] = "Lovelace",
["username"] = "ada"
});
424242L,
"Ada",
"Lovelace",
"ada");
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out var telegramId, out var name);
@@ -38,22 +33,11 @@ public sealed class TelegramAuthServiceTests
public void Verify_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["first_name"] = "Ada",
["id"] = "424242"
};
var query = CreateQueryCollection(botToken, values);
var invalidQuery = new QueryCollection(new Dictionary<string, StringValues>(query.ToDictionary(
pair => pair.Key,
pair => pair.Value))
{
["hash"] = "00"
});
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(botToken, 424242L, "Ada");
var tamperedQuery = ParseQueryString(result.QueryString.Replace("hash=", "hash=00", StringComparison.Ordinal));
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(invalidQuery, out _, out _);
var verified = service.Verify(tamperedQuery, out _, out _);
Assert.False(verified);
}
@@ -62,15 +46,13 @@ public sealed class TelegramAuthServiceTests
public void Verify_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
new Dictionary<string, string>
{
["auth_date"] = expiredAuthDate,
["first_name"] = "Ada",
["id"] = "424242"
});
424242L,
"Ada",
authDate: expiredAuthDate);
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out _, out _);
@@ -82,17 +64,16 @@ public sealed class TelegramAuthServiceTests
public void VerifyWebAppInitData_ShouldAcceptValidTelegramWebAppPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["query_id"] = "AAHdF6IQAAAAAN0XohDhrOrc",
["user"] = """{"id":424242,"first_name":"Ada","last_name":"Lovelace","username":"ada"}"""
});
424242L,
"Ada",
"Lovelace",
"ada",
queryId: "AAHdF6IQAAAAAN0XohDhrOrc");
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out var telegramId, out var name);
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
@@ -103,14 +84,8 @@ public sealed class TelegramAuthServiceTests
public void VerifyWebAppInitData_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
var tamperedInitData = initData.Replace("hash=", "hash=00", StringComparison.Ordinal);
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(botToken, 424242L, "Ada");
var tamperedInitData = result.InitDataRaw.Replace("hash=", "hash=00", StringComparison.Ordinal);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
@@ -122,16 +97,15 @@ public sealed class TelegramAuthServiceTests
public void VerifyWebAppInitData_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
424242L,
"Ada",
authDate: expiredAuthDate);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out _, out _);
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out _, out _);
Assert.False(verified);
}
@@ -141,23 +115,22 @@ public sealed class TelegramAuthServiceTests
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new Dictionary<string, string>
{
["auth_date"] = authDate.ToString(),
["first_name"] = "Ada",
["id"] = "424242",
["last_name"] = "Lovelace",
["photo_url"] = "https://t.me/i/userpic/320/ada.jpg",
["username"] = "ada"
};
var payload = new TelegramLoginPayload(
424242,
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
"Lovelace",
"ada",
"https://t.me/i/userpic/320/ada.jpg",
authDate,
ComputeTelegramHash(botToken, values));
authDate);
var payload = new TelegramLoginPayload(
result.TelegramId,
result.FirstName,
result.LastName,
result.Username,
result.PhotoUrl,
result.AuthDate,
result.Hash);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyLoginPayload(payload, out var telegramId, out var name);
@@ -190,20 +163,19 @@ public sealed class TelegramAuthServiceTests
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var values = new Dictionary<string, string>
{
["auth_date"] = authDate.ToString(),
["first_name"] = "Ada",
["id"] = "424242"
};
var payload = new TelegramLoginPayload(
424242,
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
null,
null,
null,
authDate,
ComputeTelegramHash(botToken, values));
authDate: authDate);
var payload = new TelegramLoginPayload(
result.TelegramId,
result.FirstName,
result.LastName,
result.Username,
result.PhotoUrl,
result.AuthDate,
result.Hash);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyLoginPayload(payload, out _, out _);
@@ -263,48 +235,11 @@ public sealed class TelegramAuthServiceTests
})
.Build();
private static QueryCollection CreateQueryCollection(string botToken, Dictionary<string, string> values)
private static QueryCollection ParseQueryString(string queryString)
{
var hash = ComputeTelegramHash(botToken, values);
var queryValues = values.ToDictionary(
var parsed = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
return new QueryCollection(parsed.ToDictionary(
pair => pair.Key,
pair => new StringValues(pair.Value));
queryValues["hash"] = new StringValues(hash);
return new QueryCollection(queryValues);
}
private static string ComputeTelegramHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string CreateWebAppInitData(string botToken, IReadOnlyDictionary<string, string> values)
{
var hash = ComputeTelegramWebAppHash(botToken, values);
var encodedPairs = values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")
.Append($"hash={hash}");
return string.Join("&", encodedPairs);
}
private static string ComputeTelegramWebAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
pair => pair.Value));
}
}
+31
View File
@@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
env/
venv/
# Secrets
.env
*.env
# Telegram sessions
*.session
*.session-journal
# Playwright artifacts
test-results/
playwright-report/
playwright/.cache/
# .NET build artifacts
bin/
obj/
packages.lock.json
# IDE
.vscode/
.idea/
*.user
*.suo
+140
View File
@@ -0,0 +1,140 @@
# GmRelay E2E Tests
This module contains locally-run end-to-end tests for the GmRelay Telegram bot and Blazor/Web dashboard.
It is deliberately **not** wired into CI because it requires real Telegram infrastructure (MTProto user client) and a running Web instance.
## Status
Tracked as a Gitea milestone: [E2E Automation](https://git.codeanddice.ru/toutsu/GmRelayBot/issues?state=open&milestone=...) <!-- update milestone link manually -->
| Issue | Title | Status |
|-------|-------|--------|
| #144 | initData / Login Widget helper for mock Telegram auth | ✅ Done |
| #145 | Playwright tests for Blazor dashboard with mocked Telegram auth | ✅ Done |
| #146 | Telegram user client (MTProto) | ✅ Done |
| #147 | Automate group creation and bot invitation | ✅ Done |
| #148 | Scenario: /newsession from creation to publication | ✅ Done |
| #149 | Join/leave, waitlist, reschedule and notification scenarios | ✅ Done |
| #150 | Dashboard display and editing verification | ✅ Done |
| #151 | Console runner and cleanup | ⏳ Planned |
## Structure
```text
tests/e2e/
├── README.md
├── requirements.txt
├── .gitignore
├── helpers/
│ ├── telegram_init_data.py # Build valid Telegram auth payloads
│ ├── test_telegram_init_data.py # Self-contained sanity tests for the helper
│ └── __init__.py
├── dashboard/
│ ├── test_dashboard_auth_and_sessions.py # Playwright tests for the Blazor dashboard
│ └── __init__.py
└── runner/
├── GmRelay.E2E.Runner.csproj # C# console runner using WTelegramClient (MTProto)
├── Program.cs # Entry point for quick manual checks
├── TelegramUserClient.cs # Reusable MTProto user client wrapper
├── GroupSetupScenario.cs # Create group + invite bot + verify /start
├── NewSessionScenario.cs # Walk the /newsession wizard end-to-end
├── JoinLeaveWaitlistRescheduleScenario.cs # Join, waitlist, promotion, leave, reschedule, notifications
├── DatabaseAssertions.cs # Query PostgreSQL and seed fake participants
├── RunnerConfig.cs # Configuration model
├── .env.example # Required environment variables
├── .gitignore # Ignore .env and session files
└── packages.lock.json # Restored lock file for the runner project
```
## Install dependencies
### Python (dashboard tests)
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r tests/e2e/requirements.txt
playwright install chromium
```
### C# runner (MTProto)
```bash
dotnet restore tests/e2e/runner/GmRelay.E2E.Runner.csproj
```
## Run helper tests
```bash
python tests/e2e/helpers/test_telegram_init_data.py
```
## Run Playwright dashboard tests
1. Start the Web dashboard (and PostgreSQL) locally. The fastest way:
```bash
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
```
2. Export environment variables that match the running Web instance:
```bash
export GMRELAY_E2E_BASE_URL="http://localhost:8080"
export GMRELAY_E2E_BOT_TOKEN="<same-token-as-web>"
export GMRELAY_E2E_TELEGRAM_ID="9000000001"
export GMRELAY_E2E_DATABASE_URL="Host=localhost;Database=gmrelay;Username=postgres;Password=<password>"
```
3. Run the tests:
```bash
python tests/e2e/dashboard/test_dashboard_auth_and_sessions.py
```
## Run the MTProto user client runner
The runner logs in to a real Telegram user account, creates a supergroup, invites the test bot, sends `/start`, waits for a reply, and deletes the group.
1. Copy the example environment file and fill in real values:
```bash
cp tests/e2e/runner/.env.example tests/e2e/runner/.env
```
2. Edit `tests/e2e/runner/.env` with your Telegram `api_id`, `api_hash`, `phone_number`, the bot username/token, and Web URL.
3. Run:
```bash
dotnet run --project tests/e2e/runner/GmRelay.E2E.Runner.csproj
```
**Security notes:**
- Never commit `.env` or `*.session` files.
- Use a dedicated test Telegram account, never your personal or production account.
- The first run will prompt for the Telegram verification code (sent to the phone number).
- Subsequent runs reuse the persisted `.session` file.
## What the dashboard tests cover
- `test_dashboard_authenticates_and_shows_groups`
Builds a valid Mini App initData payload, posts it to `/auth/telegram-webapp`, and verifies that the Blazor home page renders the authenticated greeting.
- `test_dashboard_session_edit_flow`
Seeds a player, group, and session directly in PostgreSQL, opens the group details page, clicks through to the session editor, changes the title, join link, max players and publication mode, asserts the updated values appear on the page, and verifies the persisted database state.
- `test_dashboard_session_delete_flow`
Seeds a session, opens the group details page, confirms the deletion dialog, asserts the session disappears from the dashboard, and verifies the session was removed from PostgreSQL.
## What the MTProto runner currently covers
- Login as a Telegram user.
- Create a supergroup (`Channels_CreateChannel` with `megagroup: true`).
- Resolve a bot by username and invite it to the group.
- Send `/start` to the bot inside the group and wait for any reply.
- Walk `/newsession` from start to published schedule message.
- Join/leave a session via inline buttons and assert the database state.
- Join into a waitlist when the active roster is full (using a seeded fake participant).
- Manually promote a waitlisted player via `/listsessions` and verify promotion.
- Leave an active session and verify automatic promotion of the next waitlisted player.
- Initiate a reschedule, propose 2-3 time options, vote, and wait for the background deadline service to apply the new time.
- Exercise T-24h RSVP confirmation and T-5m join-link reminders by time-travelling `sessions.scheduled_at` from the runner (database-level time-mock).
- Delete the test supergroup after the scenario (cleanup).
## Notes
- Authentication is mocked using `helpers/telegram_init_data.py`, which mirrors `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`.
- The Web instance validates HMAC-SHA256 with the same bot token, so the test payload is indistinguishable from a real Telegram Mini App payload.
- The runner project is intentionally **not** included in `GM-Relay.slnx` so it does not participate in CI builds or Native AOT trimming.
- The Telegram lifecycle scenario uses **one real test account** plus fake participants seeded directly into PostgreSQL, and a **database-level time-mock** (`UPDATE sessions SET scheduled_at = ...`) so 24-hour and 5-minute reminders fire in minutes instead of hours. This is the locally-runnable approach chosen for issue #149.
- For headful debugging, change `headless=True` to `headless=False` in the dashboard test file.
View File
@@ -0,0 +1,391 @@
"""
Playwright E2E tests for the GmRelay Blazor/Web dashboard.
These tests use a mocked Telegram Mini App initData payload so they can run
locally against a real GmRelay.Web instance without talking to Telegram.
Prerequisites:
pip install playwright
playwright install chromium
Environment:
GMRELAY_E2E_BASE_URL - Web dashboard URL (default: http://localhost:8080)
GMRELAY_E2E_BOT_TOKEN - Bot token matching the target Web instance
GMRELAY_E2E_TELEGRAM_ID - Telegram ID to use for the mocked user
GMRELAY_E2E_DATABASE_URL - PostgreSQL DSN for DB assertions (optional)
"""
from __future__ import annotations
import os
import time
import urllib.parse
import uuid
from typing import Optional
from playwright.sync_api import Page, expect, sync_playwright
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from helpers.telegram_init_data import build_mini_app_init_data
def _base_url() -> str:
return os.environ.get("GMRELAY_E2E_BASE_URL", "http://localhost:8080").rstrip("/")
def _bot_token() -> str:
token = os.environ.get("GMRELAY_E2E_BOT_TOKEN")
if not token:
raise RuntimeError(
"GMRELAY_E2E_BOT_TOKEN is required. It must match the bot token used by the target Web instance."
)
return token
def _telegram_id() -> int:
return int(os.environ.get("GMRELAY_E2E_TELEGRAM_ID", "9000000001"))
def _database_url() -> Optional[str]:
return os.environ.get("GMRELAY_E2E_DATABASE_URL")
def _seed_group_in_database(
telegram_id: int,
group_id: str,
group_name: str,
session_id: str,
session_title: str,
batch_id: Optional[str] = None,
) -> None:
"""
Insert minimal test data via the shared Npgsql connection so the dashboard
has something to render. This is intentionally thin: full state setup is
performed by the console runner in issue #151.
"""
import psycopg2
dsn = _database_url()
if not dsn:
return # rely on a pre-seeded dev database
platform = "Telegram"
external_user_id = str(telegram_id)
now = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
scheduled_at = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() + 86400))
batch_id = batch_id or str(uuid.uuid4())
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO gmrelay.players (platform, external_user_id, display_name, created_at)
VALUES (%s, %s, %s, %s)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name
RETURNING id
""",
(platform, external_user_id, "E2E Test GM", now),
)
player_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO gmrelay.game_groups (id, platform, external_group_id, telegram_chat_id, name, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
RETURNING id
""",
(group_id, platform, "-1001234567890", -1001234567890, group_name, now),
)
cur.execute(
"""
INSERT INTO gmrelay.group_managers (group_id, player_id, role, created_at)
VALUES (%s, %s, %s, %s)
ON CONFLICT (group_id, player_id) DO UPDATE SET role = EXCLUDED.role
""",
(group_id, player_id, "Owner", now),
)
cur.execute(
"""
INSERT INTO gmrelay.sessions (
id, group_id, batch_id, title, scheduled_at, join_link, status, publication_mode,
notification_mode, max_players, format, location_address, system, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
scheduled_at = EXCLUDED.scheduled_at,
join_link = EXCLUDED.join_link,
max_players = EXCLUDED.max_players,
publication_mode = EXCLUDED.publication_mode,
format = EXCLUDED.format,
location_address = EXCLUDED.location_address,
system = EXCLUDED.system
""",
(
session_id,
group_id,
batch_id,
session_title,
scheduled_at,
"https://example.com/join",
"Planned",
"None",
"GroupAndDirect",
5,
"Online",
"Discord",
"D&D 5e",
now,
),
)
cur.execute(
"""
INSERT INTO gmrelay.session_participants (session_id, player_id, registration_status, rsvp_status, is_gm, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (session_id, player_id) DO NOTHING
""",
(session_id, player_id, "Active", "Pending", True, now),
)
conn.commit()
def _get_session_from_db(session_id: str):
import psycopg2
dsn = _database_url()
if not dsn:
return None
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT title, join_link, max_players, publication_mode, format, location_address, system, status
FROM gmrelay.sessions
WHERE id = %s
""",
(session_id,),
)
row = cur.fetchone()
if row is None:
return None
return {
"title": row[0],
"join_link": row[1],
"max_players": row[2],
"publication_mode": row[3],
"format": row[4],
"location_address": row[5],
"system": row[6],
"status": row[7],
}
def _assert_session_deleted(session_id: str) -> None:
import psycopg2
dsn = _database_url()
if not dsn:
return
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM gmrelay.sessions WHERE id = %s", (session_id,))
assert cur.fetchone()[0] == 0, f"Session {session_id} was not deleted"
def _delete_test_data(group_id: str, session_id: str) -> None:
import psycopg2
dsn = _database_url()
if not dsn:
return
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM gmrelay.session_participants WHERE session_id = %s", (session_id,))
cur.execute("DELETE FROM gmrelay.sessions WHERE id = %s", (session_id,))
cur.execute("DELETE FROM gmrelay.group_managers WHERE group_id = %s", (group_id,))
cur.execute("DELETE FROM gmrelay.game_groups WHERE id = %s", (group_id,))
cur.execute(
"DELETE FROM gmrelay.players WHERE external_user_id = %s AND platform = 'Telegram'",
(str(_telegram_id()),),
)
conn.commit()
def _authenticate_page(page: Page, base_url: str, bot_token: str, telegram_id: int) -> None:
"""
Authenticate by calling /auth/telegram-webapp with a valid initData payload.
The response sets the auth cookie; subsequent page navigations are authenticated.
"""
init_data = build_mini_app_init_data(
bot_token=bot_token,
telegram_id=telegram_id,
first_name="E2E",
last_name="Test",
username="e2e_test",
)
response = page.request.post(
f"{base_url}/auth/telegram-webapp",
data={"initData": init_data.init_data_raw},
)
expect(response).to_be_ok()
page.goto(base_url)
def _wait_for_blazor(page: Page) -> None:
"""Wait until Blazor has finished the initial render."""
page.wait_for_selector("text=Добро пожаловать", timeout=15000)
def test_dashboard_authenticates_and_shows_groups() -> None:
base_url = _base_url()
bot_token = _bot_token()
telegram_id = _telegram_id()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
_authenticate_page(page, base_url, bot_token, telegram_id)
_wait_for_blazor(page)
heading = page.locator("h2")
expect(heading).to_contain_text("Добро пожаловать")
browser.close()
def test_dashboard_session_edit_flow() -> None:
base_url = _base_url()
bot_token = _bot_token()
telegram_id = _telegram_id()
group_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"
session_id = "bbbbbbbb-bbbb-bbbb-bbbb-aaaaaaaaaaa1"
batch_id = "cccccccc-cccc-cccc-cccc-aaaaaaaaaaa1"
original_title = "E2E Original Title"
updated_title = "E2E Updated Title"
updated_join_link = "https://example.com/updated-join"
try:
_seed_group_in_database(
telegram_id=telegram_id,
group_id=group_id,
group_name="E2E Test Group",
session_id=session_id,
session_title=original_title,
batch_id=batch_id,
)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
_authenticate_page(page, base_url, bot_token, telegram_id)
_wait_for_blazor(page)
page.goto(f"{base_url}/group/{group_id}")
page.wait_for_selector(f"text={original_title}", timeout=15000)
page.locator(f"text={original_title}").first.click()
page.wait_for_selector("text=Редактирование сессии", timeout=15000)
title_input = page.get_by_label("Название игры")
title_input.fill(updated_title)
join_input = page.get_by_label("Ссылка для подключения")
join_input.fill(updated_join_link)
max_players_input = page.get_by_label("Лимит мест")
max_players_input.fill("3")
page.get_by_label("Режим публикации").select_option("Catalog")
save_button = page.locator("button:has-text('Сохранить изменения')").first
save_button.click()
page.wait_for_selector(f"text={updated_title}", timeout=15000)
expect(page.locator(f"text={updated_title}").first).to_be_visible()
db_session = _get_session_from_db(session_id)
if db_session is not None:
assert db_session["title"] == updated_title
assert db_session["join_link"] == updated_join_link
assert db_session["max_players"] == 3
assert db_session["publication_mode"] == "Catalog"
browser.close()
finally:
_delete_test_data(group_id, session_id)
def test_dashboard_session_delete_flow() -> None:
base_url = _base_url()
bot_token = _bot_token()
telegram_id = _telegram_id()
group_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"
session_id = "bbbbbbbb-bbbb-bbbb-bbbb-aaaaaaaaaaa2"
batch_id = "cccccccc-cccc-cccc-cccc-aaaaaaaaaaa2"
session_title = "E2E Delete Target"
try:
_seed_group_in_database(
telegram_id=telegram_id,
group_id=group_id,
group_name="E2E Delete Group",
session_id=session_id,
session_title=session_title,
batch_id=batch_id,
)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
_authenticate_page(page, base_url, bot_token, telegram_id)
_wait_for_blazor(page)
page.goto(f"{base_url}/group/{group_id}")
page.wait_for_selector(f"text={session_title}", timeout=15000)
page.on("dialog", lambda dialog: dialog.accept())
delete_button = page.locator("button:has-text('Удалить')").first
expect(delete_button).to_be_visible()
delete_button.click()
page.wait_for_selector("text=Сессия удалена.", timeout=15000)
expect(page.locator(f"text={session_title}")).to_have_count(0)
_assert_session_deleted(session_id)
browser.close()
finally:
_delete_test_data(group_id, session_id)
if __name__ == "__main__":
test_dashboard_authenticates_and_shows_groups()
print("PASS test_dashboard_authenticates_and_shows_groups")
test_dashboard_session_edit_flow()
print("PASS test_dashboard_session_edit_flow")
test_dashboard_session_delete_flow()
print("PASS test_dashboard_session_delete_flow")
View File
+191
View File
@@ -0,0 +1,191 @@
"""
Generate Telegram Mini App initData and Login Widget payloads for local E2E tests.
This mirrors GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder so the Python E2E
runner can produce authentication payloads that pass GmRelay.Web.Services.TelegramAuthService
validation without talking to real Telegram servers.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
import urllib.parse
from dataclasses import dataclass
from typing import Optional
def _login_widget_secret_key(bot_token: str) -> bytes:
return hashlib.sha256(bot_token.encode("utf-8")).digest()
def _mini_app_secret_key(bot_token: str) -> bytes:
return hmac.new(
key=b"WebAppData",
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
def compute_login_widget_hash(bot_token: str, values: dict[str, str]) -> str:
"""Compute HMAC-SHA256 hash used by Telegram Login Widget callbacks."""
data_check_string = "\n".join(
f"{k}={values[k]}" for k in sorted(values.keys()) if k != "hash"
)
secret_key = _login_widget_secret_key(bot_token)
return hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
def compute_mini_app_hash(bot_token: str, values: dict[str, str]) -> str:
"""Compute HMAC-SHA256 hash used by Telegram Mini App initData."""
data_check_string = "\n".join(
f"{k}={values[k]}" for k in sorted(values.keys()) if k != "hash"
)
secret_key = _mini_app_secret_key(bot_token)
return hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
@dataclass(frozen=True)
class LoginWidgetResult:
telegram_id: int
first_name: str
last_name: Optional[str]
username: Optional[str]
photo_url: Optional[str]
auth_date: int
hash: str
query_string: str
def build_login_widget(
bot_token: str,
telegram_id: int,
first_name: str,
last_name: Optional[str] = None,
username: Optional[str] = None,
photo_url: Optional[str] = None,
auth_date: Optional[int] = None,
) -> LoginWidgetResult:
"""Build a Telegram Login Widget query string and hash."""
timestamp = auth_date if auth_date is not None else int(time.time())
values: dict[str, str] = {
"auth_date": str(timestamp),
"first_name": first_name,
"id": str(telegram_id),
}
if last_name:
values["last_name"] = last_name
if photo_url:
values["photo_url"] = photo_url
if username:
values["username"] = username
hash_value = compute_login_widget_hash(bot_token, values)
values["hash"] = hash_value
query_string = "&".join(
f"{urllib.parse.quote(k)}={urllib.parse.quote(v)}" for k, v in values.items()
)
return LoginWidgetResult(
telegram_id=telegram_id,
first_name=first_name,
last_name=last_name,
username=username,
photo_url=photo_url,
auth_date=timestamp,
hash=hash_value,
query_string=query_string,
)
@dataclass(frozen=True)
class MiniAppInitDataResult:
telegram_id: int
first_name: str
last_name: Optional[str]
username: Optional[str]
photo_url: Optional[str]
auth_date: int
hash: str
init_data_raw: str
def build_mini_app_init_data(
bot_token: str,
telegram_id: int,
first_name: str,
last_name: Optional[str] = None,
username: Optional[str] = None,
photo_url: Optional[str] = None,
language_code: Optional[str] = None,
is_premium: bool = False,
chat_id: Optional[int] = None,
chat_type: Optional[str] = None,
chat_title: Optional[str] = None,
query_id: Optional[str] = None,
start_param: Optional[str] = None,
auth_date: Optional[int] = None,
) -> MiniAppInitDataResult:
"""Build a Telegram Mini App initData raw string."""
user_payload: dict[str, object] = {
"id": telegram_id,
"first_name": first_name,
}
if last_name is not None:
user_payload["last_name"] = last_name
if username is not None:
user_payload["username"] = username
if photo_url is not None:
user_payload["photo_url"] = photo_url
if language_code is not None:
user_payload["language_code"] = language_code
if is_premium:
user_payload["is_premium"] = True
user_json = json.dumps(user_payload, separators=(",", ":"))
timestamp = auth_date if auth_date is not None else int(time.time())
values: dict[str, str] = {
"auth_date": str(timestamp),
"user": user_json,
}
if query_id:
values["query_id"] = query_id
if start_param:
values["start_param"] = start_param
if chat_id is not None:
chat_payload: dict[str, object] = {"id": chat_id, "type": chat_type or "private"}
if chat_title is not None:
chat_payload["title"] = chat_title
values["chat"] = json.dumps(chat_payload, separators=(",", ":"))
hash_value = compute_mini_app_hash(bot_token, values)
pairs = [
f"{urllib.parse.quote(k)}={urllib.parse.quote(v)}" for k, v in values.items()
]
pairs.append(f"hash={hash_value}")
init_data_raw = "&".join(pairs)
return MiniAppInitDataResult(
telegram_id=telegram_id,
first_name=first_name,
last_name=last_name,
username=username,
photo_url=photo_url,
auth_date=timestamp,
hash=hash_value,
init_data_raw=init_data_raw,
)
@@ -0,0 +1,133 @@
"""Self-contained tests for telegram_init_data helper.
Run with: python tests/e2e/helpers/test_telegram_init_data.py
"""
import sys
import urllib.parse
from telegram_init_data import (
build_login_widget,
build_mini_app_init_data,
compute_login_widget_hash,
compute_mini_app_hash,
)
def _parse_init_data(init_data_raw: str) -> dict[str, str]:
return {
k: v for k, v in (pair.split("=", 1) for pair in init_data_raw.split("&"))
}
def test_login_widget_hash_matches_expected_algorithm():
bot_token = "test-bot-token"
values = {"auth_date": "1714300000", "first_name": "Ada", "id": "424242"}
hash_value = compute_login_widget_hash(bot_token, values)
assert len(hash_value) == 64, f"expected 64 hex chars, got {len(hash_value)}"
assert hash_value == hash_value.lower(), "hash must be lowercase hex"
print("PASS test_login_widget_hash_matches_expected_algorithm")
def test_mini_app_hash_matches_expected_algorithm():
bot_token = "test-bot-token"
values = {
"auth_date": "1714300000",
"user": '{"id":424242,"first_name":"Ada"}',
}
hash_value = compute_mini_app_hash(bot_token, values)
assert len(hash_value) == 64, f"expected 64 hex chars, got {len(hash_value)}"
assert hash_value == hash_value.lower(), "hash must be lowercase hex"
print("PASS test_mini_app_hash_matches_expected_algorithm")
def test_build_login_widget_contains_all_fields():
result = build_login_widget(
bot_token="test-bot-token",
telegram_id=424242,
first_name="Ada",
last_name="Lovelace",
username="ada",
photo_url="https://t.me/i/userpic/320/ada.jpg",
auth_date=1714300000,
)
parsed = _parse_init_data(result.query_string)
assert parsed["id"] == "424242"
assert parsed["first_name"] == "Ada"
assert parsed["last_name"] == "Lovelace"
assert parsed["username"] == "ada"
assert urllib.parse.unquote(parsed["photo_url"]) == "https://t.me/i/userpic/320/ada.jpg"
assert parsed["auth_date"] == "1714300000"
assert parsed["hash"] == result.hash
print("PASS test_build_login_widget_contains_all_fields")
def test_build_mini_app_init_data_contains_all_fields():
result = build_mini_app_init_data(
bot_token="test-bot-token",
telegram_id=424242,
first_name="Ada",
last_name="Lovelace",
username="ada",
query_id="AAHdF6IQAAAAAN0XohDhrOrc",
start_param="ref123",
auth_date=1714300000,
)
parsed = _parse_init_data(result.init_data_raw)
assert parsed["auth_date"] == "1714300000"
assert parsed["hash"] == result.hash
assert urllib.parse.unquote(parsed["start_param"]) == "ref123"
assert urllib.parse.unquote(parsed["query_id"]) == "AAHdF6IQAAAAAN0XohDhrOrc"
user = urllib.parse.unquote(parsed["user"])
assert '"id":424242' in user
assert '"first_name":"Ada"' in user
print("PASS test_build_mini_app_init_data_contains_all_fields")
def test_build_mini_app_init_data_with_chat():
result = build_mini_app_init_data(
bot_token="test-bot-token",
telegram_id=424242,
first_name="Ada",
chat_id=-1001234567890,
chat_type="supergroup",
chat_title="Test Club",
auth_date=1714300000,
)
parsed = _parse_init_data(result.init_data_raw)
chat = urllib.parse.unquote(parsed["chat"])
assert '"id":-1001234567890' in chat
assert '"type":"supergroup"' in chat
assert '"title":"Test Club"' in chat
print("PASS test_build_mini_app_init_data_with_chat")
def main():
tests = [
test_login_widget_hash_matches_expected_algorithm,
test_mini_app_hash_matches_expected_algorithm,
test_build_login_widget_contains_all_fields,
test_build_mini_app_init_data_contains_all_fields,
test_build_mini_app_init_data_with_chat,
]
failed = 0
for test in tests:
try:
test()
except Exception as ex:
failed += 1
print(f"FAIL {test.__name__}: {ex}")
print(f"\n{len(tests) - failed}/{len(tests)} tests passed")
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
playwright>=1.44.0
psycopg2-binary>=2.9.9
+19
View File
@@ -0,0 +1,19 @@
# Configuration for the GmRelay E2E MTProto runner.
# Copy this file to .env and fill in real values.
# NEVER commit .env or *.session files to git.
# Telegram user account credentials (MTProto)
api_id=12345678
api_hash=abcdef0123456789abcdef0123456789
phone_number=+1234567890
# Bot under test
TELEGRAM_BOT_USERNAME=gmrelay_test_bot
TELEGRAM_BOT_TOKEN=1234567890:ABCDEF...token
# Web dashboard under test
GMRELAY_E2E_BASE_URL=http://localhost:8080
GMRELAY_E2E_TELEGRAM_ID=9000000001
# PostgreSQL connection string (optional, used for seeding/cleanup)
GMRELAY_E2E_DATABASE_URL=Host=localhost;Database=gmrelay;Username=postgres;Password=postgres
+5
View File
@@ -0,0 +1,5 @@
.env
*.session
bin/
obj/
packages.lock.json

Some files were not shown because too many files have changed in this diff Show More