Compare commits

...

149 Commits

Author SHA1 Message Date
Toutsu 1853a7a9c7 chore(release): bump version to 2.7.2
PR Checks / test-and-build (pull_request) Successful in 8m3s
Issue: #33
2026-05-21 15:36:17 +03:00
Toutsu befb2da6a0 test: add DiscordLandingPromisesSmokeTests
Mirror TelegramLandingPromisesSmokeTests for Discord MVP:
- Join/leave/waitlist promotion via capacity rules
- Reschedule voting flow
- Direct message notifications on reschedule
- Dashboard batch update

Issue: #33
2026-05-21 15:34:03 +03:00
Toutsu d29c6c0725 test: extend DiscordSessionBatchRendererTests with regression cases
- Confirmed status blue color
- Empty player description
- Embed URL from join link
- Inline field values for capacity/waitlist/status

Issue: #33
2026-05-21 15:21:02 +03:00
Toutsu 47b22c7401 test: extend TelegramSessionBatchRendererTests with regression cases
- Empty sessions
- HTML encoding in title
- Confirmed status buttons
- No join link handling

Issue: #33
2026-05-21 15:19:08 +03:00
Toutsu b4a39c027f test: extend SessionBatchViewBuilderTests with edge cases
- Empty sessions
- Confirmed status
- Null MaxPlayers

Issue: #33
2026-05-21 15:11:01 +03:00
Toutsu dd9eab2e4a Merge pull request #89: chore: добавить compose/deploy wiring для Discord bot (issue #32)
Deploy Telegram Bot / build-and-push (push) Successful in 4m50s
Deploy Telegram Bot / scan-images (push) Successful in 1m51s
Deploy Telegram Bot / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:51:35 +03:00
Toutsu 492d47a863 fix(discord): add wget to Dockerfile for healthcheck
PR Checks / test-and-build (pull_request) Successful in 7m30s
Issue #32
2026-05-21 14:40:45 +03:00
Toutsu fe8d5fe026 test(discord): update version assertions to 2.7.1
PR Checks / test-and-build (pull_request) Successful in 7m7s
Issue #32
2026-05-21 14:26:02 +03:00
Toutsu a2fa9aaa6c chore(release): bump version to 2.7.1
Issue #32
2026-05-21 14:24:17 +03:00
Toutsu 5b65ac4a2f chore(discord): add healthcheck to compose.yaml discord service
Issue #32
2026-05-21 14:21:35 +03:00
Toutsu feb3e08b63 feat(discord): register health check hosted service in Program.cs
Issue #32
2026-05-21 14:20:40 +03:00
Toutsu f1d8f56fec feat(discord): add health check hosted service
Issue #32
2026-05-21 14:19:50 +03:00
Toutsu 08ffc6694e chore(discord): add DISCORD_BOT_TOKEN to .env.example
Issue #32
2026-05-21 14:19:02 +03:00
Toutsu 3199c48fcd Merge pull request #88: feat(platform): route scheduler notifications through platform messenger
Deploy Telegram Bot / build-and-push (push) Successful in 6m18s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 16s
2026-05-21 12:40:22 +03:00
Toutsu 2a707e4825 feat(platform): route scheduler notifications through platform messenger
PR Checks / test-and-build (pull_request) Successful in 7m9s
2026-05-21 12:30:35 +03:00
Toutsu 5dbec1a0a4 docs: add issue 31 implementation plan 2026-05-20 14:53:41 +03:00
Toutsu 7426000937 docs: add issue 31 platform notification design 2026-05-20 14:38:27 +03:00
Toutsu 0c62631ab6 Merge pull request #87: feat(discord): implement reschedule voting via Discord interactions (issue #30)
Deploy Telegram Bot / build-and-push (push) Successful in 4m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m25s
Deploy Telegram Bot / deploy (push) Successful in 14s
Database:
- Add source_platform and proposed_by_external_user_id to reschedule_proposals
- Make proposed_by nullable for Discord proposals

Shared:
- Extract platform-neutral RescheduleVoteRules, RescheduleVotingInput, RescheduleDtos
- Create RescheduleVotingFinalizer for cross-platform deadline handling

Telegram:
- Refactor RescheduleVotingDeadlineService to use RescheduleVotingFinalizer
- Tag Telegram proposals with source_platform = 'Telegram'

Discord:
- /reschedule slash command with time options and deadline
- DiscordRescheduleVoteHandler for button interactions
- DiscordRescheduleVotingRenderer for embeds and buttons
- DiscordRescheduleVotingDeadlineService for automatic finalization
- DiscordSessionInteractionModule routing for vote buttons

Version: 2.5.0 -> 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 13:12:26 +03:00
Toutsu db9a931ed6 fix(shared): filter due proposals by source_platform to prevent cross-platform race
PR Checks / test-and-build (pull_request) Successful in 6m11s
Both Telegram and Discord deadline services were querying ALL due
proposals without filtering by source_platform. If the Telegram
service reached a Discord proposal first, it finalized the DB state
but skipped message handling. The Discord service then saw status
!= 'Voting' and never updated the Discord vote message.

Fix: GetDueProposalIdsAsync now accepts a sourcePlatform parameter
and filters at the DB level. Each service only processes its own
platform's proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:48:25 +03:00
Toutsu 35548a03cb test(discord): update version assertions to 2.6.0
PR Checks / test-and-build (pull_request) Successful in 6m27s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:05 +03:00
Toutsu dda393c372 chore: bump version to 2.6.0
Synchronized across Directory.Build.props, compose.yaml,
deploy.yml, and NavMenu.razor.

Bump version → 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:45 +03:00
Toutsu 1e9bf4ab25 feat(telegram): set source_platform = 'Telegram' on reschedule proposals
Ensures Telegram-initiated reschedule proposals are tagged with
source_platform so the platform-neutral finalizer can distinguish
them from Discord proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:24 +03:00
Toutsu 690aa0272f feat(discord): add reschedule voting deadline service 2026-05-20 12:29:33 +03:00
Toutsu d871f2c142 feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger 2026-05-20 12:26:31 +03:00
Toutsu 9712fe125b feat(discord): add DiscordRescheduleVotingRenderer and replace inline helper 2026-05-20 12:23:25 +03:00
Toutsu fdfc73ae9c feat(discord): add reschedule vote button handler 2026-05-20 12:21:13 +03:00
Toutsu e93e777fb3 feat(discord): add /reschedule slash command and handler 2026-05-20 12:15:03 +03:00
Toutsu a13edf20af feat(shared): add RescheduleVotingFinalizer and ISystemClock 2026-05-20 11:54:53 +03:00
Toutsu fcd7de035f refactor(shared): extract reschedule voting types to Shared 2026-05-20 11:44:57 +03:00
Toutsu fb0c29eefe feat(db): add platform columns to reschedule_proposals 2026-05-20 11:41:25 +03:00
Toutsu 9ff5cc4a67 Merge pull request #86: feat(discord): enable session join leave buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m54s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 15s
2026-05-20 09:09:51 +03:00
Toutsu 3251846001 fix(shared): enable dapper aot for session handlers
PR Checks / test-and-build (pull_request) Successful in 6m30s
2026-05-20 09:01:34 +03:00
Toutsu 39132be4e8 feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior.

Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates.

Bump version to 2.5.0 and update Discord docs.

Refs #29
2026-05-19 14:13:48 +03:00
Toutsu 90da33154c Merge pull request #85: feat(discord): implement /newsession and /listsessions (issue #28)
Deploy Telegram Bot / build-and-push (push) Successful in 4m19s
Deploy Telegram Bot / scan-images (push) Successful in 1m17s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-19 12:53:35 +03:00
Toutsu d55003a2a9 feat(discord): improve UX and add source-level tests for /newsession
PR Checks / test-and-build (pull_request) Successful in 5m59s
- DiscordNewSessionCommand: on success, renders session details via
  DiscordSessionBatchRenderer.Render() with embeds and action rows.
- DiscordNewSessionCommand: uses Discord emoji shortcodes for error
  and success messages (, , 💥).
- DiscordNewSessionHandlerTests: added 7 source-level structural tests
  verifying Dapper usage, NpgsqlDataSource, permission checks,
  platform neutrality, transaction safety, CancellationToken usage,
  and embed rendering in the command.

Refs issue #28

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:36:17 +03:00
Toutsu daa59335cc fix(discord): resolve permission checking for /newsession command
- DiscordPermissionChecker: removed dead-code userRoles overload;
  now only uses resolvedPermissions bitflag (Administrator = 0x8).
- DiscordNewSessionCommand: computes resolved permissions from guild
  user roles via Context.Guild.Users[Id].RoleIds + guild.Roles.
- DiscordNewSessionHandler: updated signature to accept ulong
  resolvedPermissions instead of unused userRoles.
- Added ILogger to command for diagnostics on unexpected errors.
- Added test: regular user with ManageServer (but not Admin) is rejected.

Refs issue #28

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:30:25 +03:00
Toutsu 474e7f62f7 chore: bump version to 2.4.0
Synchronized across Directory.Build.props, compose.yaml, deploy.yml,
NavMenu.razor, and project structure tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:36:28 +03:00
Toutsu 8666b8984e feat: register Discord session handlers and permission checker in DI
Task 5: DI wiring for DiscordNewSessionHandler, DiscordListSessionsHandler,
DiscordPermissionChecker, and DiscordPlatformMessenger.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:33:33 +03:00
Toutsu d373ff49ba feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:22:44 +03:00
Toutsu 95aad3a2f6 feat(discord): add /newsession slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:17:07 +03:00
Toutsu 76456cc28a feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:09:45 +03:00
Toutsu ac8f03ecc9 feat(discord): add DiscordPermissionChecker for session management rights
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 10:51:32 +03:00
Toutsu 21760ae6f7 Merge pull request #84: feat: implement DiscordSessionBatchRenderer for Embed and Buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m7s
Deploy Telegram Bot / scan-images (push) Successful in 1m13s
Deploy Telegram Bot / deploy (push) Successful in 12s
- Render SessionBatchViewModel into NetCord EmbedProperties + ActionRowProperties
- 7 tests covering open/full/waitlist/cancelled/reschedule states
- Version bump 2.2.0 → 2.3.0

Closes #27
2026-05-18 18:54:04 +03:00
Toutsu 5dddf99288 chore: bump version to 2.3.0
PR Checks / test-and-build (pull_request) Successful in 5m34s
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor, and DiscordProjectStructureTests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:08:12 +03:00
Toutsu 1c75994722 feat: implement DiscordSessionBatchRenderer for Embed and Buttons
- Render SessionBatchViewModel into NetCord EmbedProperties + ActionRowProperties
- One embed per session with game title, Moscow date, players, capacity, waitlist, status
- Buttons map AvailableAction to ButtonProperties with platform-neutral custom IDs
- Cancelled sessions get embed but no action row
- Full sessions trigger waitlist button label
- 7 tests covering open/full/waitlist/cancelled/reschedule states

Closes #27

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:05:35 +03:00
Toutsu c0147fd310 test: add DiscordSessionBatchRenderer tests (RED)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:56:48 +03:00
Toutsu 745a65818d Merge pull request #83: feat: add Discord NetCord gateway worker
Deploy Telegram Bot / build-and-push (push) Successful in 4m9s
Deploy Telegram Bot / scan-images (push) Successful in 1m6s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-18 16:11:25 +03:00
Toutsu 05ca8061e9 feat: add Discord NetCord gateway worker
PR Checks / test-and-build (pull_request) Successful in 5m46s
Add a separate GmRelay.DiscordBot worker using NetCord Gateway with startup token validation, PostgreSQL datasource registration, slash-command setup, component interaction service registration, and lifecycle logging.

Wire the Discord service through Aspire AppHost, Docker Compose, PR checks, deploy image build/push/scan/pull steps, README docs, and synchronized version 2.2.0.

Add TDD coverage for project isolation, token validation, startup wiring, runtime wiring, and version synchronization.

Bump version -> 2.2.0
2026-05-18 16:04:31 +03:00
Toutsu ab59d234f3 Merge pull request #82: refactor: make session join leave platform-neutral
Deploy Telegram Bot / build-and-push (push) Successful in 3m45s
Deploy Telegram Bot / scan-images (push) Successful in 1m0s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-18 13:38:22 +03:00
Toutsu e791fc2f4a refactor: make session join leave platform-neutral
PR Checks / test-and-build (pull_request) Successful in 5m3s
Convert join/leave interaction commands to PlatformUser, PlatformGroup, and PlatformMessageRef. Persist and look up participants by platform identity while keeping Telegram callbacks intact. Add V017 migration and TDD coverage. Bump version to 2.1.1.
2026-05-18 13:30:48 +03:00
Toutsu cb515b0e05 Merge pull request #81: feat: refresh dashboard design with fantasy RPG aesthetic
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / scan-images (push) Successful in 1m4s
Deploy Telegram Bot / deploy (push) Successful in 11s
🎨 Dashboard design refresh
- Complete fantasy RPG aesthetic overhaul
- Glass-morphism cards with gradient borders
- Cinzel + Jura typography
- Atmospheric backgrounds with noise texture

🧹 Chore: migrated k8s manifests to gmrelay-k8s repo

Bump version → 2.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:29:16 +03:00
Toutsu cea6ec801a chore: bump version to 2.1.0
PR Checks / test-and-build (pull_request) Successful in 5m11s
Synchronize version across all 4 files:
- Directory.Build.props
- compose.yaml (bot + web images)
- .gitea/workflows/deploy.yml
- NavMenu.razor

Bump version → 2.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:22:00 +03:00
Toutsu 8e57f8b07a chore: migrate k8s manifests to dedicated repo
PR Checks / test-and-build (pull_request) Successful in 5m11s
All Kubernetes manifests moved to git.codeanddice.ru/Toutsu/gmrelay-k8s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:04:22 +03:00
Toutsu e837e191c2 feat: refresh dashboard design with fantasy RPG aesthetic
- Replace Inter font with Cinzel (headings) + Jura (body)
- Deepen dark background palette with atmospheric gradient orbs
- Add subtle noise texture overlay for depth
- Refine glass cards with animated gradient border glow on hover
- Sharpen accent colors: violet #8b5cf6 + cyan #22d3ee
- Improve button tactile feedback and shadow system
- Add k8s manifests for minikube local deployment

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:54:56 +03:00
Toutsu df01aa9f3e Merge pull request #80: refactor: add platform messenger contracts
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m37s
Deploy Telegram Bot / deploy (push) Successful in 19s
2026-05-15 18:46:30 +03:00
Toutsu 18e702cd04 fix: validate platform schedule update target
PR Checks / test-and-build (pull_request) Successful in 13m7s
2026-05-15 18:31:17 +03:00
Toutsu 5931099c14 style: remove reschedule prompt trailing whitespace
PR Checks / test-and-build (pull_request) Successful in 12m16s
2026-05-15 12:46:50 +03:00
Toutsu 8bcd16fbc9 refactor: add platform messenger contracts
PR Checks / test-and-build (pull_request) Successful in 12m35s
Introduce platform-neutral PlatformKind, PlatformUser, PlatformGroup, and IPlatformMessenger contracts in GmRelay.Shared.

Route Telegram session schedule updates, direct notifications, interaction replies, and calendar export through TelegramPlatformMessenger while preserving existing Telegram behavior.

Bump version -> 2.0.1
2026-05-15 12:30:37 +03:00
Toutsu 7cecb722d8 Merge pull request #79: chore: add platform identity and platform_messages for multi-platform support (#23)
Deploy Telegram Bot / build-and-push (push) Successful in 7m11s
Deploy Telegram Bot / scan-images (push) Successful in 2m41s
Deploy Telegram Bot / deploy (push) Successful in 17s
PR Checks / test-and-build (pull_request) Successful in 11m17s
2026-05-15 11:02:23 +03:00
Toutsu 11b145a967 chore: add platform identity and platform_messages for multi-platform support (#23)
PR Checks / test-and-build (pull_request) Successful in 9m36s
TDD cycle for issue #23:
- RED: 9 migration smoke tests (file presence + schema expectations)
- GREEN: V016 migration adding platform identity columns
- GREEN: CreateSessionHandler, JoinSessionHandler, Web SessionService updated
  with dual-write to legacy and new identity columns + COALESCE fallbacks
- GREEN: get_group_attendance_stats recreated for external_username
- Bump version to 2.0.0

Changes:
- V016__add_platform_identity.sql:
  - players: platform, external_user_id, external_username
  - game_groups: platform, external_group_id, external_channel_id
  - platform_messages table with cross-platform message tracking
  - Backfill all existing Telegram data into new columns
  - Recreate get_group_attendance_stats with COALESCE fallback
- V012__add_attendance_stats.sql: use COALESCE(external_username, telegram_username)
- CreateSessionHandler: dual-write + COALESCE fallbacks in SELECTs
- JoinSessionHandler: dual-write to new identity columns
- Web SessionService: dual-write to new identity columns
- PlatformIdentityMigrationTests (9 smoke tests covering all handlers)
- Version synced: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor → 2.0.0

Legacy telegram_* columns preserved for backward compatibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 10:48:10 +03:00
Toutsu 105b3c59d7 fix: address review feedback for health check endpoints
PR Checks / test-and-build (pull_request) Successful in 8m34s
- Install wget in Web Dockerfile for compose healthcheck
- Ensure HttpListener response is always closed in BotHealthCheckHostedService
- Use ephemeral port in Bot health check test to avoid port conflicts
- Rename NpgsqlHealthCheck test to reflect actual behavior

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 11:16:58 +03:00
Toutsu 3bea327043 feat: add health check endpoints for Bot and Web
PR Checks / test-and-build (pull_request) Successful in 8m53s
- Web: add /health endpoint with PostgreSQL readiness check (returns 200+JSON or 503)
- Web: add /alive endpoint for liveness probe
- Bot: add BotHealthCheckHostedService serving /health on port 8081 via HttpListener
- Bot: expose port 8081 in Dockerfile and install wget for healthcheck
- compose.yaml: add healthcheck sections for bot and web services
- tests: add TDD tests for both health endpoints

Bump version -> 1.16.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:54:22 +03:00
Toutsu c6aea78ff3 Delete directory '.hermes/plans'
Deploy Telegram Bot / build-and-push (push) Successful in 1m58s
Deploy Telegram Bot / scan-images (push) Successful in 2m7s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-13 08:36:49 +03:00
Toutsu 01c49f2df0 Merge pull request #62: docs: add MIT LICENSE file
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / scan-images (push) Successful in 2m4s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-12 16:50:20 +03:00
Toutsu 9deccd3a9d docs: add MIT LICENSE file
PR Checks / test-and-build (pull_request) Successful in 7m7s
Add LICENSE file with MIT License text to repository root.
README.md already references it; the file was missing.

Includes TDD-verified tests ensuring LICENSE exists and contains
MIT License text, and README references it correctly.

Bump version → 1.15.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:25:17 +03:00
Toutsu 81d4ec2c97 fix(web): ensure dataprotection-keys dir is owned by app user before switching USER
Deploy Telegram Bot / build-and-push (push) Successful in 3m58s
Deploy Telegram Bot / scan-images (push) Successful in 1m58s
Deploy Telegram Bot / deploy (push) Successful in 13s
The volume mount /app/dataprotection-keys was created under root:root
permissions on the host. When the container restarted with the 1.15.0
image, the non-root app user (uid=1654) could no longer read/write
DataProtection keys, causing every request to fail with
UnauthorizedAccessException and fall back to the generic /Error page.

Add RUN chown during the final Docker stage so the directory ownership
matches the runtime user before USER $APP_UID takes effect.
2026-05-12 16:05:48 +03:00
Toutsu c0a5482e1a Merge pull request #61: infra: add PostgreSQL daily backup via pg_dump with rotation
Deploy Telegram Bot / build-and-push (push) Successful in 3m58s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 14s
- Add db-backup service to compose.yaml (postgres:17-alpine + cron)
- Add pgbackups volume for backup storage
- Add scripts/restore.sh for manual restore from latest backup
- Update .env.example with BACKUP_RETENTION_DAYS and BACKUP_VOLUME_NAME
- Document backup/restore flow in README

Bump version -> 1.15.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:16:11 +03:00
Toutsu 5a18cacb2e fix: address review feedback for backup infrastructure
PR Checks / test-and-build (pull_request) Successful in 6m52s
- compose.yaml: rewrite db-backup to use heredoc script instead of inline
cron command, fixing date escaping and adding temp-file pipeline for
reliable error detection
- compose.yaml: fix pipefail issue by writing pg_dump to tmp file before
compression and rotation
- restore.sh: pass PGPASSWORD explicitly via docker compose exec -e
- restore.sh: use ". .env" with set -a/+a instead of fragile xargs export

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:04:53 +03:00
Toutsu 121272fdfe infra: add PostgreSQL daily backup via pg_dump with rotation
PR Checks / test-and-build (pull_request) Successful in 6m24s
- Add db-backup service to compose.yaml (postgres:17-alpine + cron)
- Add pgbackups volume for backup storage
- Add scripts/restore.sh for manual restore from latest backup
- Update .env.example with BACKUP_RETENTION_DAYS and BACKUP_VOLUME_NAME
- Document backup/restore flow in README

Bump version -> 1.15.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:36:47 +03:00
Toutsu ccf11457ca Merge pull request #56: ci: add Trivy security scanning (SAST/SCA) to pipeline
Deploy Telegram Bot / build-and-push (push) Successful in 24s
Deploy Telegram Bot / scan-images (push) Successful in 1m23s
Deploy Telegram Bot / deploy (push) Successful in 11s
- Trivy fs scan (vuln, misconfig, secret) with lock file verification
- Trivy image scan before deploy
- SecurityCodeScan deep SAST via Roslyn analyzers
- NuGet vulnerability audit via dotnet list package
- C# code style linting via dotnet format
2026-05-12 13:07:20 +03:00
Toutsu e492d4fc2d Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot 2026-05-12 13:07:20 +03:00
Toutsu 11f6b1bcc9 Merge remote-tracking branch 'origin/main' into feature/trivy-security-scan
PR Checks / test-and-build (pull_request) Successful in 5m50s
2026-05-12 12:59:49 +03:00
Toutsu 06d40fdbc8 ci: add deep SAST via SecurityCodeScan Roslyn analyzer
PR Checks / security-scan (pull_request) Failing after 1m17s
PR Checks / test-and-build (pull_request) Successful in 3m27s
- SecurityCodeScan.VS2019 5.6.7 injected into Directory.Build.props
  scans all C# source during every dotnet build
- HIGH/CRITICAL findings fail the build because TreatWarningsAsErrors=true
- No extra CI step needed: analyzer runs inside every build job automatically

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:45:36 +03:00
Toutsu 043ed9ce45 ci: add Trivy security scanning (SAST/SCA) to pipeline
PR Checks / security-scan (pull_request) Failing after 1m15s
PR Checks / test-and-build (pull_request) Successful in 3m24s
- PR checks: filesystem scan with Trivy (vuln, secret, misconfig)
- Deploy pipeline: image scan for bot and web containers before deploy
- Scans entire repository, not filtered file subsets
- Bump version -> 1.14.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:42:32 +03:00
Toutsu 320aba2877 Merge pull request #55: feat(#21): support selected Telegram topics for schedules
Deploy Telegram Bot / build-and-push (push) Successful in 4m1s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-12 12:40:20 +03:00
Toutsu e3fdac15b5 ci: satisfy trivy dockerfile checks
PR Checks / test-and-build (pull_request) Successful in 5m12s
Run runtime images as the built-in non-root .NET app user and install Web runtime OS dependencies with --no-install-recommends.
2026-05-12 12:31:20 +03:00
Toutsu 105a051c2f ci: install latest trivy and verify scan inputs
PR Checks / test-and-build (pull_request) Failing after 6m30s
Enable NuGet lock files so Trivy has dependency targets, fail PR checks when no lock files or language-specific files are detected, and let the installer fetch the latest Trivy release.
2026-05-12 12:20:42 +03:00
Toutsu de9f56c97d feat(#21): support selected telegram topics for schedules
PR Checks / test-and-build (pull_request) Failing after 3m18s
Route new schedules to an existing forum topic when /newsession is sent inside one, create bot-owned topics only from the forum root, and keep group notifications/dashboard updates threaded to the stored topic.

Persist topic ownership so deletion only removes empty bot-created topics, add topic routing tests and smoke coverage, and bump release metadata to 1.14.0.
2026-05-12 12:07:51 +03:00
Hermes Agent 007806a5d8 feat(ci): add C# linter and security scanner to PR checks
Deploy Telegram Bot / build-and-push (push) Successful in 24s
Deploy Telegram Bot / deploy (push) Successful in 10s
- dotnet format --verify-no-changes (C# code style linting)
- dotnet list package --vulnerable --include-transitive (NuGet vulnerability check)
- Trivy filesystem scan (CVE, secrets, dependency scanning)
2026-05-11 20:11:15 +00:00
Toutsu c9627e51a2 chore: ignore .claude and .serena directories 2026-05-11 14:29:04 +03:00
Toutsu 2a3285996e Merge pull request #53: feat(#20): довести RSVP и напоминания до полного набора событий
Deploy Telegram Bot / build-and-push (push) Successful in 3m54s
Deploy Telegram Bot / deploy (push) Successful in 13s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:54:56 +03:00
Toutsu 025c7c2f9a fix(#20): reset confirmation_sent_at on reschedule and add guard
PR Checks / test-and-build (pull_request) Successful in 3m17s
- RescheduleVotingDeadlineService: clear confirmation_sent_at +
  confirmation_message_id when moving session back to Planned.
- HandleRescheduleTimeInputHandler.RescheduleImmediately: same reset.
- SendConfirmationHandler: add confirmation_sent_at IS NULL guard
  to prevent duplicate confirmation messages if DB update fails.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:49:30 +03:00
Toutsu e6e6d17b72 feat(#20): довести RSVP и напоминания до полного набора событий
PR Checks / test-and-build (pull_request) Successful in 3m12s
- Добавлена абстракция ISystemClock + SystemClock / FakeSystemClock
  для тестируемого scheduling.
- Добавлена миграция V014: confirmation_sent_at в sessions.
- Обновлен SendConfirmationHandler: записывает confirmation_sent_at.
- Обновлен SessionSchedulerService:
  - выделен ISessionTriggerStore / DbSessionTriggerStore
  - SQL-запросы используют параметр @Now вместо now()
  - добавлен публичный TickAsync для тестов
  - защита от дублей через confirmation_sent_at IS NULL
- Обновлен RescheduleVotingDeadlineService: использует ISystemClock.
- Добавлены интерфейсы ISendConfirmationHandler, ISendOneHourReminderHandler,
  ISendJoinLinkHandler для unit-тестируемости.
- Добавлены 8 unit-тестов SessionSchedulerService:
  - все 3 триггера (T-24h, T-1h, T-5min)
  - идемпотентность при повторном запуске
  - ошибки handler не падают и не блокируют другие сессии
  - ошибки store логируются без падения worker-а

Bump version -> 1.13.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:38:34 +03:00
Toutsu 563e118f23 Merge pull request #52: feat(#15): add session audit log history tests and bump version to 1.12.0
Deploy Telegram Bot / build-and-push (push) Successful in 3m58s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-10 19:04:46 +03:00
Toutsu e2303490e9 feat(#15): add session audit log history tests and bump version to 1.12.0
PR Checks / test-and-build (pull_request) Successful in 4m4s
Adds missing tests for GetSessionHistoryForGmAsync authorization.
Syncs version across all 4 files for the 1.12.0 minor release.

Bump version -> 1.12.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:57:07 +03:00
Toutsu 9c1c6c2483 Merge pull request #51: feat(#19): добавить ссылку на игру в карточку батча
Deploy Telegram Bot / build-and-push (push) Successful in 4m12s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-10 18:18:50 +03:00
Toutsu c0c8f852d2 feat(#19): добавить ссылку на игру в карточку батча
PR Checks / test-and-build (pull_request) Successful in 3m49s
- SessionBatchDto: добавлено поле JoinLink
- SessionViewItem: добавлено поле JoinLink
- SessionBatchViewBuilder: прокидывание JoinLink из DTO в ViewModel
- CreateSessionHandler, SessionService: обновлены все вызовы конструктора
- TelegramSessionBatchRenderer (Bot + Web): рендеринг ссылки в карточке
- Добавлены тесты на наличие ссылки в рендере
- Все 7 SQL-запросов, загружающих SessionBatchDto, обновлены с join_link AS JoinLink
- Бамп версии до 1.11.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:13:55 +03:00
Toutsu ac6e2455a1 Merge pull request #50: fix(ui): prevent NavMenu logo from overlapping hamburger on mobile
Deploy Telegram Bot / build-and-push (push) Successful in 3m47s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-08 13:57:04 +03:00
Toutsu 9374ff16ed fix(ui): prevent NavMenu logo from overlapping hamburger on mobile
PR Checks / test-and-build (pull_request) Successful in 3m37s
On viewports ≤768px the burger button is position:fixed at the
viewport edge, while the header retained its default 1rem left
padding. The logo image therefore sat completely underneath the
button, causing a visible overlap on hover.

Increase .nav-header padding-left to 3.75rem on mobile so the
.nav-brand clears the 2.5rem fixed toggle with a 0.5rem gap.

Bump version → 1.10.6

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:41:48 +03:00
Toutsu 17b92b25f4 Merge pull request #49: feat(ui): replace emoji logos with new app icon across dashboard
Deploy Telegram Bot / build-and-push (push) Successful in 3m43s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-08 13:24:02 +03:00
Toutsu d2edbf16cc fix(ci): bump version to 1.10.5
PR Checks / test-and-build (pull_request) Successful in 3m49s
Synchronize version across:
- Directory.Build.props
- compose.yaml (bot and web images)
- deploy.yml
- NavMenu version display

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:16:16 +03:00
Toutsu b16627c2b6 feat(ui): replace emoji logos with new app icon across dashboard
- NavMenu: swap 🐢 emoji for <img src="logo.png">
- Login page: swap 🎲 emoji for <img src="logo.png">
- Mini App page: swap 🎲 emoji for <img src="logo.png">
- Replace favicon.png with the new logo
- Add logo.png to wwwroot
- Update CSS for .nav-brand-icon, .login-logo, .mini-app-logo to use object-fit: contain sizing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:15:53 +03:00
Toutsu 4f7afb3bc9 fix(ci): sync NavMenu version to 1.10.4
Deploy Telegram Bot / build-and-push (push) Successful in 3m42s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-05-07 16:24:46 +03:00
Toutsu 5baf63e9ad fix(ci): sync compose.yaml images to 1.10.4
Deploy Telegram Bot / build-and-push (push) Successful in 24s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-07 16:24:15 +03:00
Hermes Agent a0d9d1bc44 fix(#47): use align-items: baseline + vertical-align + nudge for emoji icon
Deploy Telegram Bot / build-and-push (push) Successful in 3m34s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-05-07 13:18:57 +00:00
Hermes Agent f46f2bb5d3 fix(ci): bump deploy.yml VERSION to 1.10.3
Deploy Telegram Bot / build-and-push (push) Successful in 22s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-07 13:11:40 +00:00
Hermes Agent 46527fe761 fix(#47): align NavMenu emoji icon — line-height: 1, increase gap
PR Checks / test-and-build (pull_request) Successful in 3m17s
Deploy Telegram Bot / build-and-push (push) Successful in 3m47s
Deploy Telegram Bot / deploy (push) Failing after 7s
2026-05-07 12:59:50 +00:00
Hermes Agent d0a25895ab fix(#15): make test time stable — use same DateTime instance for unchanged fields
PR Checks / test-and-build (pull_request) Successful in 3m11s
Deploy Telegram Bot / build-and-push (push) Successful in 3m52s
Deploy Telegram Bot / deploy (push) Failing after 7s
2026-05-07 12:46:12 +00:00
Hermes Agent 05faa9e32d fix(#15): correct test — only title changes when other fields stay same
PR Checks / test-and-build (pull_request) Failing after 3m9s
2026-05-07 12:41:30 +00:00
Hermes Agent 0dbd4064ac fix(#15): bump NavMenu version and fix audit log test expectations for MaxPlayers
PR Checks / test-and-build (pull_request) Failing after 3m11s
2026-05-07 12:37:36 +00:00
Hermes Agent 0f03da0a60 docs(#15): bump version to 1.10.2 and add session history feature to README
PR Checks / test-and-build (pull_request) Failing after 3m19s
2026-05-07 12:30:11 +00:00
Hermes Agent 6d90ba8274 feat(#15): add SessionHistory.razor, navigation links, and bump version to 1.10.2 2026-05-07 12:20:44 +00:00
Hermes Agent 35894bf89e feat(#15): session audit log domain, store, and instrumentation 2026-05-07 12:16:54 +00:00
root 6394b1fe8c fix: mobile menu overlay z-index and add stats link on group page 2026-05-07 12:08:37 +00:00
Toutsu d170c83b9e docs(#14): добавить статистику посещаемости и обновить версию в README
Deploy Telegram Bot / build-and-push (push) Successful in 22s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-05-07 14:48:35 +03:00
Toutsu 4a2d1d2d38 Merge pull request 'feat(#14): attendance statistics page' (#45) from issue-14-attendance-stats into main
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / deploy (push) Successful in 12s
feat(#14): attendance statistics page
2026-05-07 14:32:40 +03:00
root 706f20e403 fix: add GetGroupAttendanceStatsAsync stub to FakeSessionStore in tests
PR Checks / test-and-build (pull_request) Successful in 3m14s
Resolves CS0535 build failure in test project.
2026-05-07 11:26:22 +00:00
root 4d3362d93f fix: GroupStats.razor syntax and missing using for Claims
PR Checks / test-and-build (pull_request) Failing after 3m14s
- Add @using System.Security.Claims
- Fix quotation marks in @onclick lambdas (Razor parser error CS1026)
2026-05-07 11:21:42 +00:00
root b03929174a fix: move PlayerAttendanceStats out of interface scope
PR Checks / test-and-build (pull_request) Failing after 2m53s
The record was nested inside ISessionStore, making it ISessionStore.PlayerAttendanceStats.
C# does not infer nested types in return signatures; callers and implementors failed
with CS0246 / CS0738. Moving it to namespace scope resolves the build.
2026-05-07 11:16:13 +00:00
root 7e2747ec73 feat: implement GetGroupAttendanceStatsAsync (#14)
PR Checks / test-and-build (pull_request) Failing after 2m57s
2026-05-07 11:05:38 +00:00
Toutsu ae6be912e3 feat(#14): add GroupStats.razor attendance page
PR Checks / test-and-build (pull_request) Failing after 3m14s
2026-05-07 13:26:03 +03:00
Toutsu 116bed16a8 feat(#14): add PlayerAttendanceStats record + interface method 2026-05-07 13:26:01 +03:00
Toutsu 063de7ee3e feat(#14): add get_group_attendance_stats SQL function 2026-05-07 13:12:39 +03:00
Toutsu 5c4ec562d0 Merge pull request 'feat(#13): календарная подписка по URL' (#44) from issue-13-calendar-sub into main
Deploy Telegram Bot / build-and-push (push) Failing after 16m3s
Deploy Telegram Bot / deploy (push) Has been skipped
Reviewed-on: #44
2026-05-07 10:59:50 +03:00
Toutsu dbd481566c fix(#13): bump version label in NavMenu to v1.10.1
PR Checks / test-and-build (pull_request) Successful in 3m57s
2026-05-07 10:32:23 +03:00
Toutsu 3f4571d3a7 chore(#13): bump version to 1.10.1
PR Checks / test-and-build (pull_request) Failing after 4m26s
2026-05-07 10:25:25 +03:00
Toutsu 8c1e7991cd feat(#13): add calendar subscription link to Telegram export 2026-05-07 10:22:35 +03:00
Toutsu c1fdba510b feat(#13): add Web:BaseUrl config for calendar subscription links 2026-05-07 10:21:07 +03:00
Toutsu 435399dcf2 fix(#13): revert ExportCalendarHandler subscription logic (cross-project ref) 2026-05-07 10:18:25 +03:00
Toutsu ddaa0f4279 feat(#13): register CalendarSubscriptionService and add public /calendar/{token}.ics endpoint 2026-05-07 10:16:02 +03:00
Toutsu b205967f1a feat(#13): add CalendarSubscriptionService with token generation and ICS rendering 2026-05-07 10:15:06 +03:00
Toutsu 7457315d6f feat(#13): add SubscriptionNotFoundException 2026-05-07 10:13:45 +03:00
Toutsu 59f9904d66 feat(#13): add CalendarSubscriptionFilter enum 2026-05-07 10:12:34 +03:00
root 3b91a009ea feat(#13): add calendar subscriptions migration 2026-05-07 06:59:56 +00:00
root a6ae5aac31 refactor(#22): merge platform-neutral batch rendering PR
Deploy Telegram Bot / build-and-push (push) Successful in 5m2s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-06 10:35:40 +00:00
root dc26b4d7e4 test: trigger pr-checks workflow
PR Checks / test-and-build (pull_request) Successful in 4m24s
2026-05-06 10:25:02 +00:00
root bc6136d91e chore(web): bump NavMenu version label to v1.10.0
Deploy Telegram Bot / build-and-push (push) Successful in 4m13s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 10:24:32 +00:00
root 2e95841ca8 fix(tests): avoid xUnit2013 analyzer error on collection count
Deploy Telegram Bot / build-and-push (push) Successful in 22s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-06 10:14:13 +00:00
root a7c8127f90 fix(tests): add missing using and fix xUnit2013 analyzer error
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 10:06:27 +00:00
root cad4e5c30e fix(ci): remove --no-build from dotnet test step
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 09:52:46 +00:00
root 77647e4bb8 fix(ci): use ubuntu runner + setup-dotnet instead of container image
Deploy Telegram Bot / build-and-push (push) Successful in 19s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 09:46:52 +00:00
root 17c631aef2 ci: add PR checks workflow — test + build, no publish
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-05-06 09:40:11 +00:00
root 89b5196676 fix(#22): resolve Telegram namespace collision and add missing MoscowTime using
Deploy Telegram Bot / build-and-push (push) Successful in 7m24s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-06 09:23:52 +00:00
root ab1d2f1683 refactor(#22): platform-neutral batch rendering
Deploy Telegram Bot / build-and-push (push) Failing after 34s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-06 09:17:05 +00:00
root 1bcd88db32 ci: bump deploy workflow version to 1.10.0 2026-05-06 09:14:29 +00:00
root 63e613c061 trigger: ci 2026-05-06 09:12:57 +00:00
Toutsu dbf59c544a docs(adr): добавить ADR 002 — platform-neutral batch rendering 2026-05-06 12:07:10 +03:00
root 14b9bf15f2 refactor(#22): разделить SessionBatchRenderer на neutral view и Telegram renderer
- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Обновлены все вызовы (7 handler-ов + Web SessionService + smoke tests)
- Новые тесты на builder и Telegram renderer
2026-05-06 08:28:25 +00:00
Toutsu 5dee2d87f5 test: cover Telegram landing promise smoke
Deploy Telegram Bot / build-and-push (push) Successful in 5m32s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-05 13:06:09 +03:00
root b71488097e chore: bump version to 1.9.8
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 7s
2026-05-04 17:26:53 +00:00
root 6e92419cff feat: player list, kick, and waitlist promotion (#41)
Deploy Telegram Bot / build-and-push (push) Successful in 4m53s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-04 17:19:58 +00:00
root fdb3445bec docs: bump README to v1.9.7, document player list kick 2026-05-04 17:15:06 +00:00
root c1f5d96e25 feat: show participant list, kick player, auto-promote waitlist 2026-05-04 17:11:23 +00:00
Toutsu c874f7b797 fix: combine session image and text into single Telegram message
Deploy Telegram Bot / build-and-push (push) Successful in 4m2s
Deploy Telegram Bot / deploy (push) Successful in 10s
When creating a session with an image, send it as a single SendPhoto
with the schedule text as caption (+ reply markup), instead of two
separate messages. Falls back to two messages if caption exceeds
Telegram's 1024-char limit.

Also adds BatchMessageEditor helper that transparently handles
EditMessageText vs EditMessageCaption depending on whether the batch
message is a text or photo message. Updated all handlers and web
service to use this helper.

Version bump to 1.9.7.
2026-05-04 10:33:06 +03:00
Toutsu aefed5abd4 feat: improve telegram session posts
Deploy Telegram Bot / build-and-push (push) Successful in 4m28s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-04 09:52:07 +03:00
Toutsu 25c22b2ff5 fix: stabilize session table layout
Deploy Telegram Bot / build-and-push (push) Successful in 4m6s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-02 15:40:24 +03:00
Toutsu cb40c2438d docs: clarify mini app dashboard for GMs
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 21:01:30 +03:00
Toutsu 2a76ec0fb8 fix: stabilize mini app login and safe area
Deploy Telegram Bot / build-and-push (push) Successful in 3m53s
Deploy Telegram Bot / deploy (push) Successful in 17s
2026-04-28 20:25:18 +03:00
Toutsu 57c8714889 fix: refresh login fallback in mini app
Deploy Telegram Bot / build-and-push (push) Successful in 4m11s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 17:20:29 +03:00
191 changed files with 21603 additions and 1936 deletions
+11
View File
@@ -10,8 +10,19 @@ TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# Используется ботом для кнопки меню Telegram и кнопки /start.
TELEGRAM_MINI_APP_URL=
# Токен Discord application bot
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
# === Backup ===
# Сколько дней хранить дампы PostgreSQL (default: 7)
BACKUP_RETENTION_DAYS=7
# Имя Docker volume для резервных копий БД
BACKUP_VOLUME_NAME=game_pgbackups
+51 -3
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.9.1
VERSION: 2.7.2
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -37,6 +37,20 @@ jobs:
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Build Discord Bot image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.DiscordBot/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest \
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }} \
.
- name: Push Discord Bot image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
- name: Build Web image
run: |
docker build \
@@ -51,9 +65,42 @@ jobs:
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 1.5: Сканируем собранные образы на уязвимости
scan-images:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Scan Bot image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Scan Discord Bot image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
- name: Scan Web image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
deploy:
needs: build-and-push
needs: scan-images
runs-on: ubuntu-latest # Тот же локальный раннер
steps:
- name: Checkout repository
@@ -63,6 +110,7 @@ jobs:
run: |
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
@@ -72,7 +120,7 @@ jobs:
docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }}
# Pull гарантирует, что мы получили нужную версию.
docker compose pull bot web
docker compose pull bot discord web
# Запускаем! Флаг -d оставит их работать в фоне.
docker compose up -d
+81
View File
@@ -0,0 +1,81 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Verify Trivy dependency scan inputs
run: |
lock_count="$(find . -name packages.lock.json -not -path "*/bin/*" -not -path "*/obj/*" | tee trivy-targets.txt | wc -l)"
echo "Trivy NuGet lock files: ${lock_count}"
if [ "${lock_count}" -eq 0 ]; then
echo "::error::No packages.lock.json files found. Trivy would scan 0 NuGet dependency files."
exit 1
fi
# ── Linting ──
- name: Lint C# code style
run: dotnet format --verify-no-changes --verbosity diagnostic
# ── Security ──
- name: Check NuGet packages for vulnerabilities
run: |
dotnet list package --vulnerable --include-transitive 2>&1 | tee nuget-audit.txt
if grep -qi "has the following vulnerable packages" nuget-audit.txt; then
echo "::error::Vulnerable NuGet packages found!"
exit 1
fi
echo "No vulnerable packages detected."
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
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_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."
exit 1
fi
exit "${trivy_exit}"
# ── Build (includes SAST via SecurityCodeScan Roslyn analyzer) ──
- name: Build Shared
run: dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
- name: Build Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
- name: Build Discord Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
- name: Build Web (compile check, includes SAST)
run: dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
# ── Tests ──
- name: Run tests
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
BIN
View File
Binary file not shown.
+144
View File
@@ -0,0 +1,144 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build, Test, and Development Commands
This is a .NET 10 solution using the modern XML-based `.slnx` format. The global SDK version is `10.0.100` with `rollForward: latestFeature`.
**Build the solution:**
```bash
dotnet build
```
**Build individual projects (the CI does this to include SAST via SecurityCodeScan):**
```bash
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
```
**Run all tests:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
```
**Run a single test class or method:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~YourTestClassName"
```
**Lint and format:**
```bash
dotnet format --verify-no-changes --verbosity diagnostic # CI enforcement
dotnet format # Apply fixes
```
**Check for vulnerable packages:**
```bash
dotnet list package --vulnerable --include-transitive
```
**Restore with lock file verification:**
The repo enforces `RestorePackagesWithLockFile=true`. After adding or updating packages, commit the updated `packages.lock.json` files or the Trivy scan in CI will fail.
**Run locally with Aspire (dev orchestration):**
```bash
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
```
This automatically starts PostgreSQL in a container, the Bot, and the Web dashboard.
**Run locally with Docker Compose (production-like):**
```bash
cp .env.example .env
# Edit .env with your TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, POSTGRES_PASSWORD
docker compose up -d
```
## High-Level Architecture
### Project Roles and Runtime Model
| Project | Runtime | Key Trait |
|---|---|---|
| `GmRelay.Bot` | `Microsoft.NET.Sdk.Worker` | **Native AOT** binary. Telegram long polling bot + stateless scheduler. |
| `GmRelay.Web` | `Microsoft.NET.Sdk.Web` | Blazor Server dashboard. Cookie auth via Telegram Login Widget / Mini App `initData`. |
| `GmRelay.Shared` | Plain library | Domain models and platform-neutral view builders. **Must not depend on `Telegram.Bot`**. |
| `GmRelay.ServiceDefaults` | Aspire shared project | OpenTelemetry, health checks, HTTP resilience. Referenced by both Bot and Web. |
| `GmRelay.AppHost` | Aspire orchestrator | Dev-only. Spins up PostgreSQL and wires Bot + Web with service discovery. |
**Important:** `README.md` references `GmRelay.Migrator` and `GmRelay.Worker`, but these projects do not exist. Migrations (`DbUp`) and background workers (`BackgroundService`) live inside `GmRelay.Bot`.
### Vertical Slice Architecture with Explicit DI
Each use case is a self-contained vertical slice: a C# record (Command/Query) + Handler class with all logic (SQL, Telegram API calls, validation). There are no abstract repository interfaces or service layers.
Because the Bot is compiled as Native AOT (`PublishAot=true`, `EnableTrimAnalyzer=true`), **all DI registrations are explicit** in `src/GmRelay.Bot/Program.cs`. There is no assembly scanning or reflection-based discovery. When adding a new handler, you must register it manually in Program.cs.
### Database Access: Npgsql + Dapper.AOT + DbUp
**No EF Core** — it is incompatible with Native AOT. The stack is:
- **Npgsql** ADO.NET for connections.
- **Dapper 2.1.72** with **Dapper.AOT 1.0.48** for compile-time source-generated mapping (AOT-safe).
- **DbUp 7.0.1** for migrations. SQL scripts are embedded resources in `src/GmRelay.Bot/Migrations/` (V001 through V015).
- `DbMigrator.MigrateUp()` runs on every Bot startup.
Both Bot and Web share the same PostgreSQL database. Web registers `NpgsqlDataSource` via `builder.AddNpgsqlDataSource("gmrelaydb")` (Aspire integration), while Bot registers it manually to avoid reflection-based Aspire configuration at AOT time.
### Platform-Neutral Rendering (ADR-002)
Rendering is split into two stages:
1. **View Builder** (`GmRelay.Shared`) — platform-agnostic view model from domain DTOs.
2. **Platform Renderer**`TelegramSessionBatchRenderer` lives in both `GmRelay.Bot` and `GmRelay.Web` (temporary duplication until a third Telegram consumer justifies extracting `GmRelay.Shared.Telegram`).
This means `GmRelay.Shared` must remain free of `Telegram.Bot` types. If you need to add rendering logic that produces `InlineKeyboardMarkup`, it belongs in the Bot or Web project, not Shared.
### Stateless Scheduling
The session scheduler (`SessionSchedulerService`) is a `BackgroundService` with a `PeriodicTimer(TimeSpan.FromMinutes(1))`. On each tick it queries PostgreSQL for sessions needing action (T-24h confirmation, T-5min join link) and updates their status. There is no in-memory state — the database is the single source of truth. This design was chosen specifically because Quartz.NET is incompatible with Native AOT.
### Health Checks
- **Bot:** Custom `BotHealthCheckHostedService` listens on port 8081. The Docker health check hits `localhost:8081/health`.
- **Web:** Standard ASP.NET Core health checks on `/health` (JSON response with status and timestamp) and `/alive` (liveness probe tag filter). Exposed via `GmRelay.ServiceDefaults`.
### Authentication and Security
- **Telegram Login Widget** and **Mini App `initData`** verification via HMAC-SHA256. Cookie auth is hardened (`HttpOnly`, `SecurePolicy.Always`, `SameSite.Strict`).
- Web Data Protection keys are persisted to `/app/dataprotection-keys` (Docker volume `web_keys`).
- Security headers middleware (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) is applied globally in Web.
- `SecurityCodeScan.VS2019` (5.6.7) is included in all projects via `Directory.Build.props` for SAST at build time.
- Connection string passwords are redacted in logs via `SecretRedactor`.
### CI/CD Pipeline
`.gitea/workflows/pr-checks.yml` runs on every PR to `main`:
1. `dotnet restore`
2. Verify `packages.lock.json` files exist for Trivy
3. `dotnet format --verify-no-changes`
4. `dotnet list package --vulnerable`
5. Trivy filesystem scan (`vuln,misconfig,secret`, HIGH/CRITICAL)
6. Build Shared → Bot → Web
7. Run tests
`.gitea/workflows/deploy.yml` runs on push to `main`:
1. Build and push `gmrelay-bot` and `gmrelay-web` images to `git.codeanddice.ru/toutsu/...`
2. Trivy image scan on both images (HIGH/CRITICAL, exit-code 1)
3. Create `.env` from secrets and run `docker compose up -d`
### Environment Configuration
Key environment variables (see `.env.example`):
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME`, `TELEGRAM_MINI_APP_URL`
- `POSTGRES_PASSWORD`
- `GMRELAY_WEB_PORT` (default 8080)
- `ConnectionStrings__gmrelaydb` — used by both Bot and Web
The Bot reads config as `Telegram:BotToken` (colon) which maps from `Telegram__BotToken` (double underscore) via environment variables.
### Docker Images
- **Bot:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble` with `clang` and `zlib1g-dev` for AOT compilation. Final stage uses `runtime-deps:10.0-noble`. Exposes 8081.
- **Web:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble`. Final stage uses `aspnet:10.0-noble` with `libgssapi-krb5-2` and `wget`. Exposes 8080.
Both images are built for multi-arch (`linux/amd64`, `linux/arm64`) to support Raspberry Pi 5 (ARM64) deployment.
+6 -1
View File
@@ -1,10 +1,15 @@
<Project>
<PropertyGroup>
<Version>1.9.1</Version>
<Version>2.7.2</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="all" />
</ItemGroup>
</Project>
+1
View File
@@ -2,6 +2,7 @@
<Folder Name="/src/">
<Project Path="src/GmRelay.AppHost/GmRelay.AppHost.csproj" />
<Project Path="src/GmRelay.Bot/GmRelay.Bot.csproj" />
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
<Project Path="src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj" />
<Project Path="src/GmRelay.Web/GmRelay.Web.csproj" />
</Folder>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Toutsu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# Discord /newsession и /listsessions — Issue #28
## Что реализовано
- Slash-команда /newsession для создания игровых сессий прямо из Discord.
- Slash-команда /listsessions для просмотра предстоящих игр в сервере.
- DiscordPermissionChecker — проверка прав (owner / admin / manager).
- DiscordPlatformMessenger — реализация IPlatformMessenger для Discord (NetCord REST).
- Полная интеграция в DI (Program.cs).
## Архитектура
- Vertical slice: каждая команда — отдельный файл (Command + Handler).
- Platform-agnostic SQL: используются колонки platform, external_group_id, external_user_id.
- Рендеринг переиспользует существующий DiscordSessionBatchRenderer.
## TDD
- 212 тестов, все зелёные.
- Source-level тесты проверяют паттерны: Dapper, Npgsql, транзакции, CancellationToken, платформенную нейтральность.
## Версия
- Minor bump: 2.3.0 → 2.4.0
- Синхронизировано: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor.
Closes #28
+132 -156
View File
@@ -1,210 +1,186 @@
# 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.9.1`.
**Текущая версия:** `v2.7.0`.
---
## ✨ Ключевые возможности
## ✨ Key Features
### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram.
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
- **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
### Discord Bot
- **Slash-команды расписания**: GM создаёт сессию через `/newsession` и публикует актуальное расписание через `/listsessions`.
- **Кнопки записи и выхода**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **Подтверждения и RSVP**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает исходы RSVP через платформенный messenger.
- **Напоминания и ссылки**: one-hour reminders и join-link notifications отправляются в Discord DM при включенных личных уведомлениях; сбои DM логируются без публичного fallback.
- **Переносы**: deadline-сервис обновляет Discord vote message и schedule message через `IPlatformMessenger`.
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📱 Telegram Mini App Dashboard**: Мобильная версия dashboard открывается прямо из Telegram, проверяет WebApp `initData` на сервере и использует те же права owner/co-GM, что и обычный Web Dashboard. Mini App ждёт данные Telegram при старте и автоматически обновляет состояние входа после внешнего Telegram Login.
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
- перенести пачку на фиксированный шаг в днях;
- клонировать batch на следующую неделю или месяц.
- **⬆️ Управление очередью**: Заполненность, лист ожидания и ручное повышение игрока из очереди.
- **📜 История изменений сессий**: Страница `/session/{id}/history` показывает аудит-лог всех значимых изменений (время, ссылка, название, участники, статус) с указанием акторов и дат.
- **📊 Статистика посещаемости**: Страница `/group/{id}/stats` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы.
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают platform message расписания через `IPlatformMessenger`.
---
## 🛠 Технологический стек
- **Язык**: C# 14 (.NET 10)
- **Архитектура**: Vertical Slice Architecture, общая библиотека (`GmRelay.Shared`) для доменной логики.
- **Бот**: Telegram.Bot, Native AOT.
- **Веб-интерфейс**: Blazor Server.
- **Оркестрация**: .NET Aspire (`GmRelay.AppHost`).
- **База данных**: PostgreSQL
- **ORM**: Dapper (с использованием Dapper.AOT для source generators).
- **Миграции**: DbUp.
- **Развертывание**: Docker Compose + Multi-arch (AMD64/ARM64).
| Компонент | Технология |
|---|---|
| Язык | C# 14 (.NET 10) |
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker) |
| Веб | Blazor Server |
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
| БД | PostgreSQL |
| ORM | Dapper + **Dapper.AOT** (source generators) |
| Миграции | DbUp |
| Развёртывание | Docker Compose, Multi-arch (**AMD64/ARM64**) |
> [!NOTE]
> При использовании Dapper в режиме Native AOT все SQL-запросы используют строго типизированные DTO; динамические типы (`dynamic`) не поддерживаются.
---
## 🚀 Быстрый старт (Docker Compose)
Проект использует Docker Compose для одновременного запуска базы данных, бота и веб-интерфейса.
### 1. Подготовка
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
### 2. Настройка окружения
Скопируйте файл-шаблон и заполните его значениями:
**Требования:** Docker и Docker Compose.
### 1. Настройка окружения
```bash
cp .env.example .env
```
Отредактируйте `.env`:
**Ключевые переменные `.env`:**
```env
# Токен вашего бота от @BotFather (используется и для бота, и как секретный ключ для веб-авторизации)
# Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Имя вашего бота в Telegram (без @), например: GmRelayBot.
# Найти его можно в информации о боте у @BotFather.
# Используется для работы виджета авторизации (Telegram Login Widget).
# Токен Discord application bot
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
# Имя бота без @ (для Telegram Login Widget)
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp.
# Используется кнопкой меню Telegram и кнопкой /start.
# HTTPS URL Mini App, например https://your-domain.example/miniapp
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
```
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
**Настройка в @BotFather:**
- Команда `/setdomain` для работы виджета авторизации на вашем домене.
- Для Mini App настройте домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`.
- Начиная с **v1.9.3** дополнительных действий для фикса входа не требуется: fallback выполняется внутри активного Telegram WebView по тому же HTTPS-адресу `/miniapp`.
Для Telegram Mini App настройте в @BotFather домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`. Бот также показывает кнопку `Открыть dashboard` в ответе на `/start`, если переменная задана.
### 3. Запуск
Выполните команду:
### 2. Запуск
```bash
docker compose up -d
```
Инфраструктура автоматически:
- Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
- Запустит бота (применив миграции БД).
- Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
**Автоматически выполняется:**
- создание Docker-сети и volume PostgreSQL;
- подъём PostgreSQL (`db:5432`);
- запуск бота с плавной миграцией (DbUp);
- запуск отдельного Discord Gateway worker на NetCord;
- запуск веб-приложения с подключением к БД и Telegram API.
### 3. Первоначальная настройка
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`.
5. В Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 💾 Backup и восстановление
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
### Как это работает
- **Каждый день в 03:00** выполняется `pg_dump` базы `gmrelay_db`.
- Дампы сжимаются (`gzip`) и сохраняются в volume `pgbackups` (`/backups`).
- Формат имени: `gmrelay_db_YYYYMMDD_HHMMSS.sql.gz`.
- Ротация: по умолчанию хранятся последние **7 дней** (настраивается через `BACKUP_RETENTION_DAYS`).
### Проверка бэкапов
```bash
docker compose exec db-backup ls -la /backups
```
### Ручное создание дампа
```bash
docker compose exec db-backup sh -c "pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_manual.sql.gz"
```
### Восстановление из бэкапа
```bash
# Использовать последний автоматический бэкап
./scripts/restore.sh
# Или указать конкретный файл
./scripts/restore.sh backups/gmrelay_db_20260512_030000.sql.gz
```
> [!WARNING]
> Восстановление **перезаписывает текущую базу данных**. Убедитесь, что вы понимаете последствия, прежде чем запускать `restore.sh`.
### Переменные окружения (опциональные)
```env
BACKUP_RETENTION_DAYS=7
BACKUP_VOLUME_NAME=game_pgbackups
```
---
## ⚙️ Настройка бота в Telegram
## 🗂 Структура репозитория
Чтобы бот работал корректно:
1. **Добавьте бота в группу** (или Супергруппу/Форум).
2. **Назначьте бота Администратором**.
3. **Необходимые права**:
* `Выбор тем` (Managed Topics) — **обязательно** для Форумов.
* `Отправка сообщений`.
* `Закрепление сообщений` — рекомендуется.
> [!TIP]
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
---
## 📝 Инструкция для Мастера
### Создание расписания игр
Используйте команду `/newsession` с описанием в следующем формате:
```text
/newsession
Название: Легенды Берега Мечей (D&D 5e)
Время: 15.05.2024 19:30
Время: 22.05.2024 19:00
Мест: 4
Ссылка: https://discord.gg/invite-link
```
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
```text
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 6
Интервал: 7
Мест: 5
Ссылка: https://discord.gg/invite-link
├── src/
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
│ ├── GmRelay.Bot/ # Telegram-бот (Native AOT)
│ ├── GmRelay.DiscordBot/ # Discord Gateway worker на NetCord
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
│ ├── GmRelay.Shared/ # Общие доменные модели
│ └── GmRelay.Web/ # Blazor Server dashboard
├── tests/
│ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute
├── compose.yaml # Docker Compose (AMD64 + ARM64)
└── .env.example # Шаблон переменных окружения
```
Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:``Шаг:`.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Делегирование управления
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
### Перенос сессии голосованием
Owner или co-GM нажимает кнопку `⏰ Перенести` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном:
```text
25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00
```
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
### Шаблоны и bulk-операции в Web Dashboard
Вкладка `Шаблоны` в левом меню вынесена отдельно от страницы группы. Owner и co-GM выбирают группу, сохраняют шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений, а также удаляют устаревшие шаблоны.
На странице группы Web Dashboard показывает только применение сохранённых шаблонов и отдельный блок для каждой пачки игр. Owner и co-GM могут:
- создать новый batch из шаблона, выбрав только первую дату расписания;
- обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Telegram Mini App Dashboard
Owner и co-GM могут открыть мобильный dashboard прямо из Telegram через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Страница `/miniapp` получает `Telegram.WebApp.initData`, отправляет его на серверный endpoint `/auth/telegram-webapp`, проходит HMAC-проверку токеном бота и выдаёт обычную cookie-сессию dashboard.
После входа Mini App использует те же страницы, что и Web Dashboard: список групп, карточки сессий, редактирование игры, повышение игрока из листа ожидания, применение шаблонов и bulk-операции batch. Доступ к чужим группам не появляется: все данные по-прежнему фильтруются через `AuthorizedSessionService` по роли owner/co-GM.
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе.
- `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени.
- `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату.
---
## 🏗 Разработка и запуск локально (.NET Aspire)
Для локальной разработки проще всего использовать .NET Aspire:
1. Установите [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) и workload Aspire.
2. Откройте решение `GM-Relay.slnx`.
3. Установите переменные окружения (или user secrets) для `GmRelay.AppHost`.
4. Запустите проект `GmRelay.AppHost`. Aspire Dashboard запустится автоматически, предоставляя удобный мониторинг БД, бота и веб-интерфейса.
> [!NOTE]
> При использовании **Dapper** в режиме Native AOT, все SQL-запросы используют строго типизированные DTO. Динамические типы (`dynamic`) не поддерживаются.
---
## 📜 Лицензия
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
MIT License. См. [LICENSE](./LICENSE).
---
*Построено с ❤️ для TTRPG-сообщества.*
+23
View File
@@ -0,0 +1,23 @@
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
## 🧩 Что вошло в релиз
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs — handler запроса активных сессий с embed-рендерингом
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs — проверка прав через Discord permissions bitflag (Administrator = 0x8)
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs — реализация IPlatformMessenger для Discord через NetCord REST
- src/GmRelay.DiscordBot/Program.cs — регистрация DI: handlers, permission checker, messenger
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
## 🗺 Что это даёт
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
- Участники сервера видят расписание через /listsessions.
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
## 📦 Версия и деплой
- версия обновлена до 2.4.0
- Docker-образы используют тег 2.4.0
+63 -2
View File
@@ -16,8 +16,40 @@ services:
timeout: 3s
retries: 10
db-backup:
image: postgres:17-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
POSTGRES_USER: gmrelay
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: gmrelay_db
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
volumes:
- pgbackups:/backups
networks:
- gmrelay
entrypoint: ["sh", "-c"]
command:
- |
cat > /usr/local/bin/backup.sh << 'EOF'
#!/bin/sh
set -e
TMPFILE="/tmp/backup_$$.sql"
pg_dump -h db -U gmrelay -d gmrelay_db > "$TMPFILE"
gzip "$TMPFILE"
mv "$TMPFILE.gz" "/backups/gmrelay_db_$(date +%Y%m%d_%H%M%S).sql.gz"
find /backups -name 'gmrelay_db_*.sql.gz' -type f -mtime +${BACKUP_RETENTION_DAYS} -delete
EOF
chmod +x /usr/local/bin/backup.sh
echo "0 3 * * * /usr/local/bin/backup.sh" | crontab -
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.1
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2
restart: always
depends_on:
db:
@@ -28,9 +60,31 @@ services:
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2
restart: always
depends_on:
db:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.1
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2
restart: always
depends_on:
db:
@@ -46,12 +100,19 @@ services:
- web_keys:/app/dataprotection-keys
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
pgdata:
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
web_keys:
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
pgbackups:
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
networks:
gmrelay:
@@ -0,0 +1,66 @@
# ADR 002: Platform-Neutral Batch Rendering
## Status
**Accepted** — implemented in v1.10.0 (PR #42).
## Context
`SessionBatchRenderer` жил в `GmRelay.Shared` и напрямую зависел от `Telegram.Bot` (`InlineKeyboardMarkup`, `ParseMode.Html`). Это создавало проблемы:
1. **Shared не был platform-neutral.** Любой платформенный проект (Discord, Slack, WebSocket) тащил Telegram-зависимость.
2. **Дублирование логики.** `GmRelay.Web` использовал тот же рендерер через прямую зависимость от `Shared`, но Web — это не Telegram-клиент.
3. **Невозможно написать unit-тесты без Telegram-объектов.** Smoke-тесты создавали InlineKeyboardMarkup даже для проверки чисто доменной логики.
## Decision
Разделить рендеринг на две стадии:
1. **View Builder (platform-neutral)** — собирает view model из доменных DTO.
2. **Platform Renderer (platform-specific)** — превращает view model в платформенное представление.
```
Domain DTOs
SessionBatchViewBuilder (Shared)
SessionBatchViewModel (platform-neutral)
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
└──► DiscordSessionBatchRenderer ──► Discord embeds + buttons
```
### Изменённые компоненты
| Компонент | Было | Стало |
|---|---|---|
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
| `DiscordSessionBatchRenderer` | — | `GmRelay.DiscordBot.Rendering` |
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
## Consequences
### Positive
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
- Discord renderer lives in `GmRelay.DiscordBot`, so NetCord stays out of `Shared`.
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
### Negative
- **Временное дублирование.** `TelegramSessionBatchRenderer` и `BatchMessageEditor` скопированы в `Bot` и `Web`. Планируется вынести в `GmRelay.Shared.Telegram` при появлении третьего Telegram-потребителя.
- **Дополнительная стадия.** Теперь два вызова вместо одного: `Build` + `Render`. Этоtrade-off за чистоту абстракции.
## Related
- Issue #22 — этот рефакторинг.
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
- Issue #31 — scheduler notifications and reschedule deadline updates now use `IPlatformMessenger` for Telegram and Discord.
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
+81 -39
View File
@@ -1,4 +1,4 @@
# GM-Relay C4 Model
# GM-Relay - C4 Model
## Level 1: System Context
@@ -6,19 +6,24 @@
C4Context
title GM-Relay System Context
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
Person(player, "Player", "Подтверждает участие через inline-кнопки")
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
Rel(gm, telegram, "Команды бота (/newsession)")
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
Rel(telegram, gmrelay, "Updates (Long Polling)")
Rel(gm, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)")
Rel(gmrelay, discord, "Send/edit schedule, RSVP, reminder, and reschedule messages")
Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
```
## Level 2: Container
@@ -30,49 +35,86 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
System_Boundary(pi, "Raspberry Pi 5") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
Container(discordBot, "GmRelay.DiscordBot", "Worker Service, .NET 10", "NetCord Gateway, slash commands, scheduler notifications, and button interactions")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
}
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(gm, telegram, "Commands")
Rel(player, telegram, "Callback Queries")
Rel(telegram, bot, "GetUpdates (Long Polling)")
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls")
Rel(discordBot, discord, "REST send/edit/reply calls")
Rel(bot, shared, "Uses shared renderers and join/leave handlers")
Rel(discordBot, shared, "Uses shared renderers, scheduler, and platform-neutral handlers")
Rel(web, shared, "Uses shared domain and rendering models")
Rel(bot, db, "Npgsql + Dapper.AOT")
Rel(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper")
```
## Level 3: Component (GmRelay.Bot)
## Level 3: Component - Session Interactions
```mermaid
C4Component
title GmRelay.Bot Components
title Platform-Neutral Session Interactions
Container_Boundary(bot, "GmRelay.Bot") {
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
Container_Boundary(shared, "GmRelay.Shared") {
Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
Component(rsvp, "HandleRsvpHandler", "Feature handler", "Updates RSVP state and emits platform-neutral RSVP outcomes")
Component(scheduler, "SessionSchedulerService", "Background service", "Triggers confirmation, reminder, and join-link notifications per platform")
Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message")
Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
}
System_Ext(telegram, "Telegram Bot API")
ContainerDb(db, "PostgreSQL")
Container_Boundary(discordBot, "GmRelay.DiscordBot") {
Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands")
Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages")
}
Rel(polling, router, "Update")
Rel(router, rsvp, "CallbackQuery rsvp:*")
Rel(scheduler, confirm, "T-24h trigger")
Rel(scheduler, link, "T-5min trigger")
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
Rel(link, telegram, "SendMessage + user mentions")
Rel(confirm, db, "SELECT/UPDATE sessions")
Rel(rsvp, db, "UPDATE participants, SELECT counts")
Rel(link, db, "SELECT confirmed players")
Rel(migrator, db, "DDL migrations")
Container_Boundary(bot, "GmRelay.Bot") {
Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Sends and edits Telegram schedule, RSVP, reminder, join-link, and reschedule messages")
}
ContainerDb(db, "PostgreSQL")
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(discord, discordModule, "Button interaction")
Rel(discordModule, join, "JoinSessionCommand")
Rel(discordModule, leave, "LeaveSessionCommand")
Rel(discordModule, rsvp, "HandleRsvpCommand")
Rel(discordModule, discord, "Deferred ephemeral reply, then modify response")
Rel(updateRouter, join, "JoinSessionCommand")
Rel(updateRouter, leave, "LeaveSessionCommand")
Rel(updateRouter, rsvp, "HandleRsvpCommand")
Rel(join, updateLock, "Acquire by PlatformMessageRef")
Rel(leave, updateLock, "Acquire by PlatformMessageRef")
Rel(join, db, "SELECT FOR UPDATE, INSERT participant")
Rel(leave, db, "SELECT FOR UPDATE, DELETE/promote participant")
Rel(rsvp, db, "Update RSVP and load notification recipients")
Rel(scheduler, db, "Load due session triggers")
Rel(join, renderer, "Build updated schedule view")
Rel(leave, renderer, "Build updated schedule view")
Rel(join, discordMessenger, "Update Discord schedule when command is Discord")
Rel(leave, discordMessenger, "Update Discord schedule when command is Discord")
Rel(join, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(leave, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(rsvp, discordMessenger, "Update Discord confirmation and outcomes")
Rel(rsvp, telegramMessenger, "Update Telegram confirmation and outcomes")
Rel(scheduler, discordMessenger, "Send Discord scheduler notifications")
Rel(scheduler, telegramMessenger, "Send Telegram scheduler notifications")
Rel(discordMessenger, discord, "REST send/edit/DM + ephemeral text")
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
```
@@ -0,0 +1,438 @@
# Player List + Kick + Waitlist Promotion Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player.
**Architecture:** Extend `ISessionStore` with participant queries and a remove method. Update `GroupDetails.razor` to show expandable participant lists. Reuse existing `PromoteWaitlistedPlayerAsync` logic after removal.
**Tech Stack:** C# 14, Blazor SSR, Dapper, PostgreSQL
---
## Task 1: Add domain model for WebParticipant
**Objective:** Create a DTO to represent a session participant in the web layer.
**Files:**
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
**Step 1: Add record**
```csharp
public sealed record WebParticipant(
Guid Id,
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm,
DateTime? RespondedAt);
```
**Step 2: Commit**
```bash
git add src/GmRelay.Web/Services/SessionService.cs
git commit -m "feat: add WebParticipant record"
```
---
## Task 2: Add GetSessionParticipantsAsync to ISessionStore
**Objective:** Retrieve all participants for a session with full player info.
**Files:**
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
**Step 1: Add to interface**
In `ISessionStore.cs`, add:
```csharp
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
```
**Step 2: Implement in SessionService**
In `SessionService.cs`, add:
```csharp
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
ORDER BY sp.is_gm DESC,
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
sp.created_at
""",
new { SessionId = sessionId })).ToList();
}
```
**Step 3: Add authorized wrapper**
In `AuthorizedSessionService.cs`, add:
```csharp
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId);
}
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add GetSessionParticipantsAsync"
```
---
## Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion
**Objective:** Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met.
**Files:**
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
**Step 1: Add to interface**
In `ISessionStore.cs`, add:
```csharp
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
```
**Step 2: Implement in SessionService**
In `SessionService.cs`, add:
```csharp
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
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);
}
// Verify participant exists in this session
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
""",
new { ParticipantId = participantId, SessionId = sessionId },
transaction);
if (participant is null)
{
throw new InvalidOperationException("Участник не найден в этой сессии.");
}
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
await conn.ExecuteAsync(
"DELETE FROM session_participants WHERE id = @ParticipantId",
new { ParticipantId = participantId },
transaction);
WebPromotedParticipantDto? promoted = null;
if (wasActive)
{
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
ORDER BY sp.created_at ASC, sp.id ASC
LIMIT 1
FOR UPDATE OF sp
""",
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (promoted is not null)
{
await conn.ExecuteAsync(
"""
UPDATE session_participants
SET registration_status = @Active,
rsvp_status = @Pending,
responded_at = NULL
WHERE id = @ParticipantRowId
""",
new
{
promoted.ParticipantRowId,
Active = ParticipantRegistrationStatus.Active,
Pending = RsvpStatus.Pending
},
transaction);
}
}
await transaction.CommitAsync();
// Notifications
await bot.SendMessage(
session.TelegramChatId,
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (promoted is not null)
{
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
}
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
```
**Step 3: Add authorized wrapper**
In `AuthorizedSessionService.cs`, add:
```csharp
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
}
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion"
```
---
## Task 4: Modify GroupDetails.razor to show participant list
**Objective:** Add expandable player lists to each session row with kick buttons.
**Files:**
- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
**Step 1:** Add `participants` dictionary and `kickingParticipantId` state variables.
**Step 2:** Add `LoadParticipants(Guid sessionId)` and `KickParticipant(Guid sessionId, Guid participantId)` methods.
**Step 3:** In desktop table, add a new column or expand row with participant list.
**Step 4:** In mobile cards, add expandable participant section.
**Step 5:** Add styles to `app.css` if needed (badge styles are already present).
**Step 6:** Commit
```bash
git add src/GmRelay.Web/Components/Pages/GroupDetails.razor
git add src/GmRelay.Web/wwwroot/app.css
git commit -m "feat: show player list and kick button in GroupDetails"
```
---
## Task 5: Modify EditSession.razor to show participant list
**Objective:** Show participant list on the edit page with kick capability.
**Files:**
- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor`
**Step 1:** Load participants in `OnInitializedAsync`.
**Step 2:** Render participant list below the edit form.
**Step 3:** Add kick button for each non-GM participant.
**Step 4:** Commit
```bash
git add src/GmRelay.Web/Components/Pages/EditSession.razor
git commit -m "feat: show player list and kick button in EditSession"
```
---
## Task 6: Add backend tests
**Objective:** Cover new GetSessionParticipants and RemovePlayerFromSession logic.
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs`
**Step 1:** Write tests for `GetSessionParticipantsForGmAsync`.
**Step 2:** Write tests for `RemovePlayerFromSessionForGmAsync` including waitlist promotion.
**Step 3:** Run tests
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n
```
**Step 4:** Commit
```bash
git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs
git commit -m "test: add SessionParticipant service tests"
```
---
## Task 7: Update README
**Objective:** Bump version and document new features.
**Files:**
- Modify: `README.md`
**Step 1:** Change version from `v1.9.6` to `v1.9.7`.
**Step 2:** Add bullet under Web Dashboard: player list with kick and auto-promote.
**Step 3:** Commit
```bash
git add README.md
git commit -m "docs: bump README to v1.9.7, document player list kick"
```
---
## Task 8: Update Wiki
**Objective:** Update `Руководство ГМа` page with player management instructions.
**Files:**
- Modify: Wiki page `Руководство ГМа`
**Step 1:** Read current wiki content via MCP.
**Step 2:** Add section about viewing player list and removing players.
**Step 3:** Update via MCP.
---
## Task 9: Push branch and run CI
**Objective:** Push branch, monitor workflow, fix issues.
**Step 1:** Push
```bash
git push -u origin feat/player-list-kick-waitlist
```
**Step 2:** Check workflow run via MCP gitea actions.
**Step 3:** Fix any issues.
---
## Task 10: Merge and create release
**Objective:** Merge PR (or fast-forward), tag, create release.
**Step 1:** Merge to main
```bash
git checkout main
git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)"
```
**Step 2:** Tag v1.9.7
```bash
git tag v1.9.7
git push origin main --tags
```
**Step 3:** Create release via MCP gitea_create_release.
---
@@ -0,0 +1,560 @@
# Platform Messenger Contracts Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement issue #24 by adding platform-neutral platform identity and messaging contracts, then routing the Telegram session flows through a Telegram adapter without changing Telegram behavior.
**Architecture:** Keep update routing and Telegram update parsing at the `GmRelay.Bot.Infrastructure.Telegram` boundary, but move outbound messaging decisions behind `GmRelay.Shared.Platform.IPlatformMessenger`. `GmRelay.Shared` owns platform-neutral DTOs and contracts; `GmRelay.Bot` owns `TelegramPlatformMessenger`, which translates neutral requests into `Telegram.Bot` calls and reuses the existing Telegram renderers/editing rules.
**Tech Stack:** .NET 10, C# preview, xUnit, Dapper.AOT constraints, Telegram.Bot in `GmRelay.Bot` only, platform-neutral shared contracts in `GmRelay.Shared`.
---
## Issue Context
- Gitea issue: #24, `refactor: ввести PlatformKind, PlatformUser, PlatformGroup и IPlatformMessenger`
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`, `pending-approval`
- Acceptance criteria:
- New contracts live in a platform-neutral layer.
- Telegram flow goes through the adapter without behavior changes.
- A future DiscordBot can reference the contract without depending on Telegram assemblies.
## Proposed Version Bump
Current version is `2.0.0` in:
- `Directory.Build.props`
- `compose.yaml`
- `.gitea/workflows/deploy.yml`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
Issue label is `type:refactor`; per workflow rules this is not a major bump and has no user-facing feature label. Proposed bump: `2.0.0` -> `2.0.1`.
## Files
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
- Modify: `src/GmRelay.Bot/Program.cs`
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs`
- Modify: version files listed above
## Design
### Shared Contracts
`PlatformKind` is a sentinel enum where `Max` is not a sendable platform:
```csharp
namespace GmRelay.Shared.Platform;
public enum PlatformKind
{
Telegram = 0,
Discord = 1,
Max = 2
}
```
`PlatformUser` and `PlatformGroup` carry external platform identity while keeping current Telegram IDs representable as strings:
```csharp
namespace GmRelay.Shared.Platform;
public sealed record PlatformUser(
PlatformKind Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
public sealed record PlatformGroup(
PlatformKind Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId = null,
string? ExternalThreadId = null);
```
Outbound message contracts stay independent of Telegram/Discord SDK types:
```csharp
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Platform;
public sealed record PlatformMessageRef(
PlatformKind Platform,
string ExternalGroupId,
string? ExternalThreadId,
string ExternalMessageId);
public sealed record PlatformMessageAction(
string Key,
string Label,
string Payload);
public sealed record PlatformScheduleMessage(
PlatformGroup Group,
SessionBatchViewModel View,
PlatformMessageRef? ExistingMessage,
string? ImageReference = null);
public sealed record PlatformPrivateMessage(
PlatformUser Recipient,
string HtmlText);
public sealed record PlatformInteractionReply(
string InteractionId,
string Text,
bool ShowAlert = false);
public sealed record PlatformCalendarFile(
PlatformGroup Group,
string FileName,
byte[] Content,
string CaptionHtml,
IReadOnlyList<PlatformMessageAction> Actions);
```
`IPlatformMessenger` exposes the required outward operations:
```csharp
namespace GmRelay.Shared.Platform;
public interface IPlatformMessenger
{
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct);
Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct);
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
}
```
### Telegram Adapter
`TelegramPlatformMessenger` lives in `GmRelay.Bot.Infrastructure.Telegram`, depends on `ITelegramBotClient`, and translates neutral DTOs to existing Telegram calls:
- `SendScheduleAsync` renders `SessionBatchViewModel` with `TelegramSessionBatchRenderer.Render`.
- `UpdateScheduleAsync` calls `BatchMessageEditor.EditBatchMessageAsync`.
- `SendGroupMessageAsync` calls `SendMessage` with `ParseMode.Html` and optional `messageThreadId`.
- `SendPrivateMessageAsync` calls `SendMessage` to `PlatformUser.ExternalUserId`.
- `AnswerInteractionAsync` calls `AnswerCallbackQuery`.
- `SendCalendarFileAsync` calls `SendDocument` and maps URL actions to inline keyboard buttons.
### Handler Scope
Refactor outbound Telegram calls in these flows to `IPlatformMessenger`:
- Join/leave/promote waitlist schedule updates and callback replies.
- Cancel schedule update, group cancellation message, direct notification and callback reply.
- Reschedule initiation, voting message updates, immediate reschedule schedule update, direct notifications and callback replies.
- Export calendar file sending.
Keep Telegram inbound DTOs at the boundary for now:
- `UpdateRouter` still receives `Telegram.Bot.Types.Update`.
- Text message parsing in reschedule input still receives `Telegram.Bot.Types.Message`.
- `CreateSessionHandler` can keep photo/topic creation via `ITelegramBotClient` because issue #24 targets outbound schedule/interaction/private/calendar contract, not replacing all Telegram update primitives in one PR.
## Tasks
### Task 1: RED - Shared Contract Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
- [ ] **Step 1: Write failing tests for neutral contracts**
```csharp
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Platform;
public sealed class PlatformContractsTests
{
[Fact]
public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel()
{
Assert.Equal(0, (int)PlatformKind.Telegram);
Assert.Equal(1, (int)PlatformKind.Discord);
Assert.Equal(2, (int)PlatformKind.Max);
}
[Fact]
public void PlatformContracts_ShouldBeTelegramAssemblyFree()
{
var contractTypes = new[]
{
typeof(PlatformUser),
typeof(PlatformGroup),
typeof(PlatformMessageRef),
typeof(PlatformMessageAction),
typeof(PlatformScheduleMessage),
typeof(PlatformPrivateMessage),
typeof(PlatformInteractionReply),
typeof(PlatformCalendarFile),
typeof(IPlatformMessenger)
};
Assert.All(contractTypes, type =>
Assert.DoesNotContain(
"Telegram",
string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)),
StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes()
{
var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var view = SessionBatchViewBuilder.Build(
"Campaign",
[new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")],
[]);
var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1");
var message = new PlatformScheduleMessage(group, view, ExistingMessage: null);
Assert.Equal(PlatformKind.Discord, message.Group.Platform);
Assert.Same(view, message.View);
}
}
```
- [ ] **Step 2: Run tests and verify RED**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
```
Expected: compile failure because `GmRelay.Shared.Platform` types do not exist.
### Task 2: GREEN - Add Shared Contracts
**Files:**
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
- [ ] **Step 1: Add the contract files exactly as described in the Design section**
- [ ] **Step 2: Run PlatformContractsTests and verify GREEN**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
```
Expected: `Passed`.
### Task 3: RED - Adapter and Flow Source Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
- [ ] **Step 1: Write source tests for adapter wiring and target flows**
```csharp
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
public sealed class TelegramPlatformMessengerSourceTests
{
[Fact]
public async Task Program_ShouldRegisterTelegramPlatformMessenger()
{
var program = await ReadRepositoryFileAsync("src/GmRelay.Bot/Program.cs");
Assert.Contains("IPlatformMessenger", program, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformMessenger", program, StringComparison.Ordinal);
}
[Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath)
{
var source = await ReadRepositoryFileAsync(relativePath);
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal);
Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
}
[Fact]
public async Task TelegramPlatformMessenger_ShouldOwnTelegramBotClientCalls()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs");
Assert.Contains("ITelegramBotClient", source, StringComparison.Ordinal);
Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal);
Assert.Contains("SendDocument", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
```
- [ ] **Step 2: Run tests and verify RED**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
```
Expected: failures because `TelegramPlatformMessenger` is missing and handlers still call Telegram APIs directly.
### Task 4: GREEN - Implement TelegramPlatformMessenger and Registration
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
- Modify: `src/GmRelay.Bot/Program.cs`
- [ ] **Step 1: Implement adapter**
Implementation notes:
- Parse Telegram chat/thread/message IDs from neutral string IDs with `long.Parse` and `int.Parse`.
- Use `ParseMode.Html` for HTML text.
- Map `PlatformMessageAction` URLs to `InlineKeyboardButton.WithUrl`.
- Return a `PlatformMessageRef` with message IDs converted to strings.
- [ ] **Step 2: Register adapter**
Add `using GmRelay.Shared.Platform;` and register:
```csharp
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
```
- [ ] **Step 3: Run adapter source tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
```
Expected: some handler source tests still fail until Task 5.
### Task 5: GREEN - Refactor Session Flows Through Adapter
**Files:**
- Modify target handler files listed in Task 3
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
- [ ] **Step 1: Replace constructor dependencies**
Use `IPlatformMessenger messenger` in target handlers for outbound operations. Keep `ITelegramBotClient` only where the handler still performs inbound Telegram-specific work that is out of scope, such as message deletion or forum topic creation.
- [ ] **Step 2: Convert Telegram IDs to neutral platform objects**
Use helper code equivalent to:
```csharp
private static PlatformGroup TelegramGroup(long chatId, string? title, int? threadId = null)
=> new(
PlatformKind.Telegram,
chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
title ?? "Telegram chat",
ExternalChannelId: chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
ExternalThreadId: threadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
private static PlatformUser TelegramUser(long telegramId, string displayName, string? username = null)
=> new(
PlatformKind.Telegram,
telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
displayName,
username);
```
- [ ] **Step 3: Replace schedule updates**
Build `SessionBatchViewModel` as before, then call:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
group,
view,
new PlatformMessageRef(PlatformKind.Telegram, group.ExternalGroupId, group.ExternalThreadId, messageId.ToString(System.Globalization.CultureInfo.InvariantCulture))),
ct);
```
- [ ] **Step 4: Replace interaction replies**
Use:
```csharp
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, text, showAlert: false),
ct);
```
- [ ] **Step 5: Replace direct notifications**
`DirectSessionNotificationSender` should become a small compatibility service over `IPlatformMessenger`:
```csharp
await messenger.SendPrivateMessageAsync(
new PlatformPrivateMessage(
new PlatformUser(PlatformKind.Telegram, recipient.TelegramId.ToString(CultureInfo.InvariantCulture), recipient.DisplayName, null),
htmlText),
ct);
```
- [ ] **Step 6: Replace calendar file sending**
`ExportCalendarHandler` builds the same ICS bytes and calls `SendCalendarFileAsync`, preserving the subscription URL button as a `PlatformMessageAction`.
- [ ] **Step 7: Run target source tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
```
Expected: `Passed`.
### Task 6: Regression Tests
**Files:**
- Existing tests only unless a compiler failure exposes a missing using or changed behavior.
- [ ] **Step 1: Run rendering and routing tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Rendering|FullyQualifiedName~Telegram|FullyQualifiedName~RescheduleSession"
```
Expected: `Passed`.
- [ ] **Step 2: Run all tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj
```
Expected: `Passed`.
- [ ] **Step 3: Build solution**
Run:
```powershell
dotnet build GM-Relay.slnx
```
Expected: `Build succeeded` with warnings treated as errors.
### Task 7: Version Bump
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Update all four version locations to `2.0.1`**
- [ ] **Step 2: Verify sync**
Run:
```powershell
rg -n "2\\.0\\.0|2\\.0\\.1" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
```
Expected: no `2.0.0` matches in these files and `2.0.1` appears in all required locations.
### Task 8: Documentation Review
**Files:**
- Review: `README.md`
- Review: `docs/adr/002-platform-neutral-batch-rendering.md`
- [ ] **Step 1: Check README and ADR for platform contract accuracy**
- [ ] **Step 2: Update docs if they now misrepresent platform-neutral responsibilities**
Expected likely doc change: README currently lists current version as `v1.15.0`, which is already inconsistent with repo version `2.0.0`. If this PR bumps to `2.0.1`, update that line to `v2.0.1`.
### Task 9: Commit, PR, CI, Review, Merge, Deploy, Release
**Files:**
- Stage only files intentionally changed for issue #24.
- [ ] **Step 1: Create branch**
```powershell
git checkout -b codex/refactor/issue-24-platform-messenger
```
- [ ] **Step 2: Commit**
```powershell
git add src/GmRelay.Shared/Platform src/GmRelay.Bot tests/GmRelay.Bot.Tests Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md docs/adr/002-platform-neutral-batch-rendering.md
git commit -m "refactor: add platform messenger contracts"
```
- [ ] **Step 3: Push and create PR via Gitea**
- [ ] **Step 4: Wait for PR CI and fix failures if any**
- [ ] **Step 5: Run code review subagent and address findings**
- [ ] **Step 6: Merge PR after CI and review**
- [ ] **Step 7: Monitor deploy workflow**
- [ ] **Step 8: Create release `v2.0.1` with Russian release notes**
- [ ] **Step 9: Close issue #24 with PR and release links**
## Self-Review
- Spec coverage: all issue acceptance criteria map to Shared contracts, Telegram adapter, handler source tests, and build/test verification.
- Placeholder scan: no `TBD`, `TODO`, or "fill later" placeholders are left in this plan.
- Type consistency: all snippets use `GmRelay.Shared.Platform`, `PlatformKind.Telegram`, `PlatformMessageRef`, and `IPlatformMessenger` consistently.
- Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope.
@@ -0,0 +1,731 @@
# Discord NetCord Gateway Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a separate `src/GmRelay.DiscordBot` worker that uses NetCord Gateway for Discord slash commands and component interactions while keeping Telegram dependencies isolated in `src/GmRelay.Bot`.
**Architecture:** Create a new .NET worker project that references `GmRelay.ServiceDefaults` and `GmRelay.Shared`, validates `Discord:Token` during startup, registers NetCord gateway/application command/component services, and logs gateway lifecycle events through NetCord gateway handlers. Keep database connectivity aligned with the existing worker by registering the same `ConnectionStrings:gmrelaydb` `NpgsqlDataSource` pattern, but do not move Telegram code or dependencies.
**Tech Stack:** .NET 10 worker, Aspire service defaults, NetCord.Hosting `1.0.0-alpha.489`, Npgsql `10.0.2`, xUnit, Docker Compose, Gitea Actions.
---
## Issue
- Gitea issue: `#26`, `feat: добавить src/GmRelay.DiscordBot на NetCord Gateway`
- Labels: `type:feature`, `area:discord`, `area:infra`, `platform:discord`, `priority:p1`, `pending-approval`
- Version bump: minor, `2.1.1` -> `2.2.0`
- Branch: `feature/issue-26-discord-netcord-gateway`
## Sources Checked
- NetCord application commands guide: `https://netcord.dev/guides/services/application-commands/introduction.html`
- NetCord intents guide: `https://netcord.dev/guides/events/intents.html`
- NetCord gateway handler docs: `https://netcord.dev/docs/NetCord.Hosting.Gateway.html`
- NuGet flat container for `NetCord.Hosting`: latest observed version `1.0.0-alpha.489`
## File Structure
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` - Discord worker project and package references.
- Create: `src/GmRelay.DiscordBot/Program.cs` - host composition, token validation, database registration, NetCord service registration.
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - strongly typed Discord token/options validation.
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs` - Discord-local startup redaction without referencing the Telegram worker project.
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - NetCord gateway lifecycle handler for ready/connect/resume/disconnect/close/rate-limit events where available.
- Create: `src/GmRelay.DiscordBot/Dockerfile` - publish and runtime image for the Discord worker.
- Modify: `GM-Relay.slnx` - include the new project.
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` - reference the Discord worker for Aspire orchestration.
- Modify: `src/GmRelay.AppHost/Program.cs` - add `discord` project with PostgreSQL reference.
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - reference the Discord worker project.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` - source-level tests for solution inclusion, Docker/Compose/CI wiring, and Telegram isolation.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` - unit tests for token validation.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - source-level startup tests for NetCord registration, service defaults, and PostgreSQL connection requirements.
- Modify: `compose.yaml` - add `discord` service and versioned image tag.
- Modify: `.gitea/workflows/deploy.yml` - build/push/scan/pull Discord image and include `DISCORD_BOT_TOKEN` in `.env`.
- Modify: `.gitea/workflows/pr-checks.yml` - build the Discord project in PR checks.
- Modify: `Directory.Build.props` - version `2.2.0`.
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - visible version `v2.2.0`.
- Generated by restore: `src/GmRelay.DiscordBot/packages.lock.json`.
- Generated by restore: updates to `tests/GmRelay.Bot.Tests/packages.lock.json` and `src/GmRelay.AppHost/packages.lock.json`.
## TDD Plan
### Task 1: Project Presence And Telegram Isolation
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Modify: `GM-Relay.slnx`
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`
- Create: `src/GmRelay.DiscordBot/Program.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using System;
using System.IO;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordProjectStructureTests
{
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");
}
[Fact]
public void Solution_ShouldIncludeDiscordWorkerProject()
{
var repoRoot = GetRepoRoot();
var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx"));
Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution);
}
[Fact]
public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency()
{
var repoRoot = GetRepoRoot();
var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj");
Assert.True(File.Exists(projectPath), "Discord worker project should exist.");
var project = File.ReadAllText(projectPath);
Assert.Contains("Microsoft.NET.Sdk.Worker", project);
Assert.Contains("NetCord.Hosting", project);
Assert.Contains("GmRelay.ServiceDefaults.csproj", project);
Assert.Contains("GmRelay.Shared.csproj", project);
Assert.DoesNotContain("Telegram.Bot", project);
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
}
[Fact]
public void TelegramWorkerProject_ShouldNotReferenceNetCord()
{
var repoRoot = GetRepoRoot();
var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj"));
Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
Expected: FAIL because `GM-Relay.slnx` does not include `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` and the project file does not exist.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
</ItemGroup>
</Project>
```
Add this project to `GM-Relay.slnx` inside `/src/`:
```xml
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
```
Create temporary minimal `src/GmRelay.DiscordBot/Program.cs`:
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
await builder.Build().RunAsync();
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
Expected: PASS.
### Task 2: Token Validation
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs`
- [ ] **Step 1: Write the failing test**
Add the project reference to `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`:
```xml
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
```
Create `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`:
```csharp
using GmRelay.DiscordBot;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordOptionsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_ShouldRejectMissingToken(string? token)
{
var options = new DiscordOptions { Token = token };
var exception = Assert.Throws<InvalidOperationException>(options.Validate);
Assert.Contains("Discord:Token is required", exception.Message);
Assert.Contains("Discord__Token", exception.Message);
}
[Fact]
public void Validate_ShouldAcceptConfiguredToken()
{
var options = new DiscordOptions { Token = "configured-token" };
options.Validate();
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
Expected: FAIL at compile time because `GmRelay.DiscordBot.DiscordOptions` is not defined.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/DiscordOptions.cs`:
```csharp
namespace GmRelay.DiscordBot;
public sealed class DiscordOptions
{
public string? Token { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Token))
{
throw new InvalidOperationException(
"Discord:Token is required. Set via environment variable Discord__Token or user secrets.");
}
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
Expected: PASS.
### Task 3: Startup Wiring For Service Defaults, PostgreSQL, NetCord, And Slash Commands
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
- Modify: `src/GmRelay.DiscordBot/Program.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`:
```csharp
using System;
using System.IO;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordStartupTests
{
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");
}
[Fact]
public void Program_ShouldValidateDiscordTokenBeforeRunning()
{
var program = ReadProgram();
Assert.Contains("GetRequiredSection(\"Discord\")", program);
Assert.Contains("DiscordOptions", program);
Assert.Contains(".Validate()", program);
}
[Fact]
public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource()
{
var program = ReadProgram();
Assert.Contains("builder.AddServiceDefaults()", program);
Assert.Contains("ConnectionStrings:gmrelaydb is required", program);
Assert.Contains("NpgsqlDataSource", program);
Assert.Contains("SecretRedactor.RedactConnectionString", program);
}
[Fact]
public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents()
{
var program = ReadProgram();
Assert.Contains(".AddDiscordGateway", program);
Assert.Contains(".AddApplicationCommands", program);
Assert.Contains(".AddComponentInteractions", program);
Assert.Contains(".AddGatewayHandlers", program);
Assert.Contains("AddSlashCommand", program);
}
private static string ReadProgram()
{
var repoRoot = GetRepoRoot();
return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs"));
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
Expected: FAIL because `Program.cs` does not validate `Discord:Token`, register `NpgsqlDataSource`, or register NetCord services yet.
- [ ] **Step 3: Write minimal implementation**
Replace `src/GmRelay.DiscordBot/Program.cs` with host composition that:
```csharp
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Infrastructure.Logging;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
using NetCord.Hosting.Services.ApplicationCommands;
using NetCord.Hosting.Services.ComponentInteractions;
using Npgsql;
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
var discordOptions = builder.Configuration
.GetRequiredSection("Discord")
.Get<DiscordOptions>() ?? new DiscordOptions();
discordOptions.Validate();
builder.Services.AddSingleton(discordOptions);
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var connectionString = config.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException(
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup");
logger.LogInformation(
"Configured PostgreSQL data source with connection string {ConnectionString}",
SecretRedactor.RedactConnectionString(connectionString));
return NpgsqlDataSource.Create(connectionString);
});
builder.Services
.AddDiscordGateway(options =>
{
options.Token = discordOptions.Token;
options.Intents = GatewayIntents.Guilds;
})
.AddApplicationCommands()
.AddComponentInteractions()
.AddGatewayHandlers(typeof(Program).Assembly);
var host = builder.Build();
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
await host.RunAsync();
```
Use the Discord-local `SecretRedactor` namespace instead of `GmRelay.Bot.Infrastructure.Logging` so the new project does not reference the Telegram worker.
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs`:
```csharp
using System.Text.RegularExpressions;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
internal static partial class SecretRedactor
{
public static string RedactConnectionString(string connectionString)
{
return PasswordPattern().Replace(connectionString, "$1***");
}
[GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")]
private static partial Regex PasswordPattern();
}
```
If `GatewayClientOptions.Token` does not accept `string`, adjust to NetCord's required token type after compile feedback while preserving the tests' intent.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
Expected: PASS.
### Task 4: Gateway Lifecycle Logging
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs`
- [ ] **Step 1: Write the failing test**
Add to `DiscordStartupTests.cs`:
```csharp
[Fact]
public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues()
{
var repoRoot = GetRepoRoot();
var loggerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Infrastructure", "Logging", "DiscordGatewayLifecycleLogger.cs");
Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist.");
var logger = File.ReadAllText(loggerPath);
Assert.Contains("IReadyGatewayHandler", logger);
Assert.Contains("IDisconnectGatewayHandler", logger);
Assert.Contains("IResumeGatewayHandler", logger);
Assert.Contains("LogInformation", logger);
Assert.DoesNotContain("Token", logger);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
Expected: FAIL because `DiscordGatewayLifecycleLogger.cs` does not exist.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` using the concrete NetCord handler signatures from the installed `NetCord.Hosting` package. Minimum behavior:
```csharp
using Microsoft.Extensions.Logging;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
public sealed class DiscordGatewayLifecycleLogger(
ILogger<DiscordGatewayLifecycleLogger> logger)
: IReadyGatewayHandler,
IDisconnectGatewayHandler,
IResumeGatewayHandler
{
public ValueTask HandleAsync(ReadyEventArgs arg)
{
logger.LogInformation("Discord gateway ready as application {ApplicationId}", arg.Application.Id);
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync(DisconnectEventArgs arg)
{
logger.LogWarning("Discord gateway disconnected with close status {CloseStatus}", arg.CloseStatus);
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync()
{
logger.LogInformation("Discord gateway session resumed");
return ValueTask.CompletedTask;
}
}
```
If interface signatures differ in `1.0.0-alpha.489`, inspect the package XML/docs and adjust the handlers to compile while keeping ready/disconnect/resume logging and never logging token values.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
Expected: PASS.
### Task 5: Runtime Container, Compose, AppHost, And CI Wiring
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Create: `src/GmRelay.DiscordBot/Dockerfile`
- Modify: `compose.yaml`
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj`
- Modify: `src/GmRelay.AppHost/Program.cs`
- Modify: `.gitea/workflows/pr-checks.yml`
- Modify: `.gitea/workflows/deploy.yml`
- [ ] **Step 1: Write the failing test**
Add to `DiscordProjectStructureTests.cs`:
```csharp
[Fact]
public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram()
{
var repoRoot = GetRepoRoot();
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj"));
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.2.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks);
Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject);
Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
Expected: FAIL because runtime wiring is not present.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/Dockerfile` modeled after `src/GmRelay.Bot/Dockerfile`, with project copy/restore for `GmRelay.DiscordBot`, `GmRelay.ServiceDefaults`, and `GmRelay.Shared`, and entrypoint `./GmRelay.DiscordBot`.
Add `discord` service to `compose.yaml`:
```yaml
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0
restart: always
depends_on:
db:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
networks:
- gmrelay
```
Add Discord project reference to `src/GmRelay.AppHost/GmRelay.AppHost.csproj`:
```xml
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
```
Add Discord service to `src/GmRelay.AppHost/Program.cs`:
```csharp
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres);
```
Update `.gitea/workflows/pr-checks.yml` with:
```yaml
- name: Build Discord Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
```
Update `.gitea/workflows/deploy.yml` to build, push, scan, pull, and deploy `git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}` and write `DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}` to `.env`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
Expected: PASS.
### Task 6: Version Synchronization
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Write the failing test**
Add to `DiscordProjectStructureTests.cs`:
```csharp
[Fact]
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("v2.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
Expected: FAIL because current version is `2.1.1`.
- [ ] **Step 3: Write minimal implementation**
Update:
- `Directory.Build.props`: `<Version>2.2.0</Version>`
- `.gitea/workflows/deploy.yml`: `VERSION: 2.2.0`
- `compose.yaml`: `gmrelay-bot:2.2.0`, `gmrelay-web:2.2.0`, `gmrelay-discord-bot:2.2.0`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v2.2.0`
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
Expected: PASS.
### Task 7: Restore, Format, Build, And Full Test Verification
**Files:**
- Generated/updated: `src/GmRelay.DiscordBot/packages.lock.json`
- Generated/updated: `tests/GmRelay.Bot.Tests/packages.lock.json`
- Generated/updated: `src/GmRelay.AppHost/packages.lock.json`
- Any code formatting changes required by `dotnet format`
- [ ] **Step 1: Restore lock files**
Run: `dotnet restore GM-Relay.slnx`
Expected: restore succeeds and creates/updates lock files for the new project references and NetCord dependency.
- [ ] **Step 2: Run targeted tests**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~Discord`
Expected: all Discord tests pass.
- [ ] **Step 3: Run full tests**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
Expected: all tests pass.
- [ ] **Step 4: Run release build**
Run: `dotnet build GM-Relay.slnx -c Release`
Expected: solution build succeeds and includes `src/GmRelay.DiscordBot`.
- [ ] **Step 5: Run format check**
Run: `dotnet format --verify-no-changes --verbosity diagnostic`
Expected: no formatting changes required.
- [ ] **Step 6: Inspect diff for secrets**
Run: `git diff --check`
Expected: no whitespace errors and no Discord token value in tracked files.
Run: `git diff -- . ':!*.lock.json'`
Expected: diff contains configuration variable names such as `Discord__Token` and `DISCORD_BOT_TOKEN`, but not a real token value.
### Task 8: Commit, PR, CI, Deploy, Release, Issue Closure
**Files:**
- All intended implementation, test, lock, workflow, compose, and version files.
- [ ] **Step 1: Create commit**
Run:
```powershell
git status --short
git add GM-Relay.slnx Directory.Build.props compose.yaml .gitea/workflows/deploy.yml .gitea/workflows/pr-checks.yml src/GmRelay.AppHost src/GmRelay.DiscordBot src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests
git commit -m "feat: add Discord NetCord gateway worker"
```
Expected: only intended files are staged and committed. Do not stage untracked `CLAUDE.md`.
- [ ] **Step 2: Push branch and open PR**
Run: `git push -u origin feature/issue-26-discord-netcord-gateway`
Create Gitea PR to `main` with:
- Summary of Discord worker, token validation, runtime wiring, and version bump.
- Test plan showing targeted Discord tests, full tests, release build, format, and secret diff inspection.
- Link to issue `#26`.
- [ ] **Step 3: Store Discord token as a Gitea Actions secret**
Use Gitea Actions configuration to create or update repository secret `DISCORD_BOT_TOKEN` with the user-provided Discord bot token.
Expected: token is stored only as an Actions secret. The token value is not written to source files, plan files, logs, PR text, release notes, or commits.
- [ ] **Step 4: Monitor CI**
Use Gitea Actions run reads until PR checks finish. If CI fails, inspect logs, fix with TDD where the failure is code behavior, push again, and re-check.
- [ ] **Step 5: Review, merge, deploy, release**
After CI passes and review is approved:
- Merge PR.
- Monitor deploy workflow on `main`.
- Create release `v2.2.0` with Russian release notes.
- Close issue `#26` with a comment linking PR and release.
## Self-Review
- Spec coverage: Project creation, NetCord Gateway, slash/component service registration, `Discord__Token`, PostgreSQL service defaults, lifecycle logging, Telegram isolation, solution build, compose/deploy integration, and version sync are covered.
- Placeholder scan: No task uses `TBD`, `TODO`, or an unspecified "add tests" instruction.
- Type consistency: Test class names and file paths are consistent across tasks; NetCord lifecycle handler signatures are explicitly marked for compile-driven adjustment because the package is prerelease and must be verified against installed `1.0.0-alpha.489`.
@@ -0,0 +1,599 @@
# Platform-Neutral Join Leave Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Gitea issue #25 by making join/leave session interactions use platform-neutral command models while preserving Telegram callback behavior, seat limits, and waitlist semantics.
**Architecture:** Telegram callback routing remains in `UpdateRouter`, but it becomes an adapter that converts callback data into `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef` values. `JoinSessionHandler` and `LeaveSessionHandler` operate on those neutral values, persist players by `(platform, external_user_id)`, and update schedules through `IPlatformMessenger`.
**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Gitea Actions.
---
## Issue Context
- Issue: `#25 refactor: obobshchit JoinSession i LeaveSession pod platform-neutral interactions`
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`
- Version bump: patch, `2.1.0` -> `2.1.1`. The issue is labeled refactor, not breaking; do not use a major bump without explicit approval.
- Existing untracked file: `CLAUDE.md`; do not stage or modify it.
## File Map
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
- Reflection tests proving join/leave command records expose neutral properties and no Telegram-specific identity/message fields.
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
- Source-level regression tests for handler SQL and messenger boundaries.
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
- Add a migration test for nullable legacy `players.telegram_id`, required for non-Telegram player inserts.
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
- Drop `NOT NULL` from legacy Telegram-only player columns.
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- Change `JoinSessionCommand` to neutral properties and query/upsert players by platform identity.
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- Change `LeaveSessionCommand` to neutral properties and find participants by platform identity.
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- Convert Telegram callback data into neutral command values using `TelegramPlatformIds`.
- Modify: version files after implementation:
- `Directory.Build.props`
- `compose.yaml`
- `.gitea/workflows/deploy.yml`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
## Task 1: RED - Command Model Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
- [ ] **Step 1: Write failing command-shape tests**
```csharp
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionCommandTests
{
[Fact]
public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<JoinSessionCommand>("SessionId", typeof(Guid));
AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
}
[Fact]
public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(property => property.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
```
- [ ] **Step 2: Verify RED**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
```
Expected: FAIL because `JoinSessionCommand` and `LeaveSessionCommand` still expose `TelegramUserId`, `ChatId`, and `MessageId`, and do not expose `User`, `Group`, or `ScheduleMessage`.
## Task 2: RED - SQL and Boundary Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
- [ ] **Step 1: Write failing handler source tests**
```csharp
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionSqlTests
{
[Fact]
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
}
[Fact]
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
}
[Fact]
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
```
- [ ] **Step 2: Add failing migration assertion**
Append to `PlatformIdentityMigrationTests`:
```csharp
[Fact]
public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql");
Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal);
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
}
```
- [ ] **Step 3: Verify RED**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionSqlTests|MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId"
```
Expected: FAIL because handlers still use Telegram-specific properties and the V017 migration file does not exist.
## Task 3: GREEN - Add Migration
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
- [ ] **Step 1: Create the migration**
```sql
-- =============================================================
-- V017: Allow platform-neutral players
-- =============================================================
-- Legacy Telegram identity columns remain for backward compatibility,
-- but non-Telegram platform users do not have Telegram ids.
-- =============================================================
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
```
- [ ] **Step 2: Verify migration test turns green**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId
```
Expected: PASS.
## Task 4: GREEN - Refactor JoinSessionCommand and Handler
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- [ ] **Step 1: Replace command record**
Replace the existing `JoinSessionCommand` declaration with:
```csharp
public sealed record JoinSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
```
- [ ] **Step 2: Replace player upsert**
Use platform identity parameters:
```csharp
var platform = command.User.Platform.ToString();
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
: (long?)null;
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
? command.User.ExternalUsername
: null;
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
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,
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username
RETURNING id;",
new
{
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
},
transaction);
```
Add `using System.Globalization;` at the top.
- [ ] **Step 3: Update participant display query**
Change the participant projection to prefer platform-neutral username:
```sql
COALESCE(p.external_username, p.telegram_username) as TelegramUsername
```
- [ ] **Step 4: Update schedule message and interaction reply usage**
Use:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
```
and:
```csharp
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
```
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
- [ ] **Step 5: Verify command and SQL tests for join**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext|JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity"
```
Expected: PASS for join-focused tests.
## Task 5: GREEN - Refactor LeaveSessionCommand and Handler
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- [ ] **Step 1: Replace command record**
Replace the existing `LeaveSessionCommand` declaration with:
```csharp
public sealed record LeaveSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
```
- [ ] **Step 2: Replace participant lookup**
Use platform identity instead of Telegram id:
```csharp
var platform = command.User.Platform.ToString();
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
FOR UPDATE OF sp
""",
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
transaction);
```
- [ ] **Step 3: Update participant display query**
Change the participant projection to:
```sql
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername
```
- [ ] **Step 4: Update schedule message and interaction reply usage**
Use:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
```
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
- [ ] **Step 5: Verify leave tests**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext|LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity|SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference"
```
Expected: PASS.
## Task 6: GREEN - Convert Telegram Router to Neutral Commands
**Files:**
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- [ ] **Step 1: Add local conversion values in `HandleCallbackQueryAsync`**
After parsing `action`, add:
```csharp
var user = TelegramPlatformIds.User(
query.From.Id,
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
query.From.Username);
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
```
- [ ] **Step 2: Update join command construction**
```csharp
var command = new JoinSessionCommand(
SessionId: joinSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
```
- [ ] **Step 3: Update leave command construction**
```csharp
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
```
- [ ] **Step 4: Verify compile**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
```
Expected: PASS.
## Task 7: REFACTOR - Clean Up and Full Test Pass
**Files:**
- Modify only files already listed if cleanup is needed.
- [ ] **Step 1: Remove now-unused Telegram handler imports**
Check `JoinSessionHandler.cs` and `LeaveSessionHandler.cs` for unused:
```csharp
using GmRelay.Bot.Infrastructure.Telegram;
```
Remove it from handlers if no longer needed.
- [ ] **Step 2: Run focused tests**
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"
```
Expected: PASS.
- [ ] **Step 3: Run full test suite**
```powershell
dotnet test .\GM-Relay.slnx
```
Expected: PASS.
- [ ] **Step 4: Build solution**
```powershell
dotnet build .\GM-Relay.slnx
```
Expected: PASS.
## Task 8: Version Bump
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Update version from `2.1.0` to `2.1.1`**
Expected exact replacements:
```xml
<Version>2.1.1</Version>
```
```yaml
VERSION: 2.1.1
```
```yaml
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
```
```razor
<div class="nav-version">v2.1.1</div>
```
- [ ] **Step 2: Verify synchronized versions**
Run:
```powershell
rg "<Version>|image: git.codeanddice.ru/toutsu/gmrelay-|VERSION:|nav-version" Directory.Build.props compose.yaml .gitea\workflows\deploy.yml src\GmRelay.Web\Components\Layout\NavMenu.razor
```
Expected: all project image/app/deploy UI versions show `2.1.1`.
## Task 9: PR, CI, Review, Merge, Deploy, Release
**Files:**
- No additional source changes expected.
- [ ] **Step 1: Create branch after approval**
```powershell
git checkout -b refactor/issue-25-platform-neutral-join-leave
```
- [ ] **Step 2: Stage only intended files**
```powershell
git add docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
```
- [ ] **Step 3: Commit**
```powershell
git commit -m "refactor: make session join leave platform-neutral"
```
- [ ] **Step 4: Push and create Gitea PR**
```powershell
git push -u origin refactor/issue-25-platform-neutral-join-leave
```
PR title:
```text
refactor: make session join leave platform-neutral
```
PR body:
```markdown
## Summary
- Closes #25.
- Converts join/leave session interaction commands from Telegram-specific fields to platform-neutral `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef`.
- Persists and looks up session participants by `(platform, external_user_id)`.
- Keeps Telegram callback data and schedule update behavior intact.
## Test plan
- `dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"`
- `dotnet test .\GM-Relay.slnx`
- `dotnet build .\GM-Relay.slnx`
## Workflow
- [ ] CI passes
- [ ] Code review approved
- [ ] Deployed
- [ ] Release published
```
- [ ] **Step 5: Watch CI, request review, merge, deploy, release**
Use Gitea MCP for PR creation, CI polling, review, merge, deploy monitoring, and release `v2.1.1`. Close issue #25 after release and add a comment linking the PR and release.
## Self-Review
- Spec coverage: issue scope is covered by neutral command records, Telegram adapter conversion, platform identity SQL, messenger-based schedule updates, and tests.
- Placeholder scan: no `TBD`, `TODO`, or "fill later" steps remain.
- Type consistency: commands consistently use `PlatformUser User`, `string InteractionId`, `PlatformGroup Group`, and `PlatformMessageRef ScheduleMessage`.
@@ -0,0 +1,984 @@
# Discord /newsession и /listsessions — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development (TDD) for every task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Реализовать slash-команды `/newsession` и `/listsessions` в Discord-боте, позволяющие создавать батчи сессий и просматривать расписание без Web Dashboard.
**Architecture:** Каждая команда — отдельный vertical slice в `GmRelay.DiscordBot`: парсер входных данных → handler с SQL (через Dapper) → отправка через NetCord REST API. Рендеринг переиспользует существующий `DiscordSessionBatchRenderer`. Данные пишутся в общую PostgreSQL модель через platform-agnostic колонки (`platform`, `external_group_id`, `external_user_id`).
**Tech Stack:** .NET 10, NetCord 1.0.0-alpha.489, NetCord.Hosting.Services, Dapper, Npgsql, xUnit.
**Version Bump:** minor (2.3.0 → 2.4.0) — новый функционал.
---
## File Structure
| File | Responsibility |
|------|--------------|
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs` | Slash-команда `/newsession` с параметрами (title, time, seats, link) |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs` | Handler создания batch + sessions в БД, проверка прав |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs` | Slash-команда `/listsessions` |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs` | Handler запроса активных сессий и публикации embed |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs` | Проверка прав пользователя в guild (owner/admin/manager) |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Реализация `IPlatformMessenger` для отправки/обновления расписания в Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs` | TDD-тесты создания сессий из Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs` | TDD-тесты вывода расписания |
| `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs` | TDD-тесты проверки прав |
| `src/GmRelay.DiscordBot/Program.cs` | Регистрация DI: handlers, permission checker, platform messenger |
---
## Task 1: DiscordPermissionChecker
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs`
**Context:** Discord использует guild-роли. Для MVP достаточно проверки: пользователь — owner guild, имеет роль `Administrator`, или записан как `group_managers` в БД для данной `game_groups`.
### Step 1.1: Write the failing test
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPermissionCheckerTests
{
[Fact]
public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 123456789ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserHasAdministratorRole_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var adminRole = 999ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 987654321ul,
userRoles: new[] { adminRole },
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var managerId = 555ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: managerId,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { managerId });
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenRegularUser_ReturnsFalse()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 111ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { 222ul });
Assert.False(result);
}
}
```
### Step 1.2: Run test to verify it fails
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: FAIL — `DiscordPermissionChecker` not found.
### Step 1.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`:
```csharp
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPermissionChecker
{
// Discord Administrator permission bitflag
private const ulong AdministratorPermission = 0x8;
public bool CanManageSchedule(
ulong guildOwnerId,
ulong userId,
IEnumerable<ulong> userRoles,
IEnumerable<ulong> dbManagerUserIds)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
// NetCord provides permission resolution via GuildUser.Permissions;
// here we accept pre-resolved flag for simplicity.
// Actual command handler will pass resolved permissions.
return false;
}
public bool CanManageSchedule(ulong guildOwnerId, ulong userId, IEnumerable<ulong> dbManagerUserIds, ulong resolvedPermissions)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
}
}
```
### Step 1.4: Run test to verify it passes
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: PASS (4/4).
### Step 1.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs
git commit -m "feat(discord): add DiscordPermissionChecker for session management rights
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 2: DiscordListSessionsHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`
**Context:** Handler должен:
1. Найти `game_groups` по `external_group_id` = `guild_id`.
2. Выбрать предстоящие сессии (`scheduled_at > NOW()`, `status != Cancelled`).
3. Собрать участников.
4. Построить view через `SessionBatchViewBuilder`.
5. Отрендерить через `DiscordSessionBatchRenderer`.
6. Отправить embed + buttons в Discord channel.
### Step 2.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordListSessionsHandlerTests
{
[Fact]
public void BuildSchedule_WithSessions_ReturnsEmbedsAndButtons()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(sessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Planned, 4, "https://example.com")
};
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Single(actionRows);
}
[Fact]
public void BuildSchedule_WithCancelledSession_SkipsActionRows()
{
var cancelledSessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Cancelled, null, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Empty(actionRows);
}
}
```
### Step 2.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordListSessionsHandler` not found.
### Step 2.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`:
```csharp
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using NetCord.Rest;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
{
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
{
GuildId = guildId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionList = sessions.ToList();
if (sessionList.Count == 0)
return null;
var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC",
new { SessionIds = sessionIds });
var firstTitle = sessionList.First().Title;
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
public override async Task ExecuteAsync()
{
var guildId = Context.Guild?.Id.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var view = await _handler.BuildScheduleAsync(guildId, channelId, Context.CancellationToken);
if (view is null)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
return;
}
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
}
```
### Step 2.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: PASS.
### Step 2.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs
git commit -m "feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 3: DiscordNewSessionHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`
**Context:** Handler должен:
1. Проверить права пользователя (owner/admin/manager).
2. Upsert игрока (GM) в `players` с `platform = 'Discord'`.
3. Upsert `game_groups` с `platform = 'Discord'`, `external_group_id = guild_id`.
4. Создать batch + sessions.
5. Отправить rendered schedule в Discord channel.
6. Сохранить `platform_messages` reference.
### Step 3.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordNewSessionHandlerTests
{
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
Assert.Equal(19, 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 result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
}
```
### Step 3.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordNewSessionHandler` not found.
### Step 3.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`:
```csharp
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
{
if (result < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
{
if (altResult < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), 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,
ulong userId,
string userDisplayName,
IEnumerable<ulong> userRoles,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
// Resolve db managers
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 g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, userRoles, dbManagerUserIds))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
// Upsert player
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);
// Upsert group
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, '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 { GuildId = guildId, ChannelId = channelId },
transaction);
// Ensure manager record
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);
// Create batch + session
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);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("newsession", "Create a new game session")]
public class DiscordNewSessionCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler)
{
_handler = handler;
}
[SlashCommandOption("title", "Game title", Required = true)]
public string Title { get; set; } = string.Empty;
[SlashCommandOption("time", "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)", Required = true)]
public string Time { get; set; } = string.Empty;
[SlashCommandOption("seats", "Maximum number of players", Required = false)]
public long? Seats { get; set; }
[SlashCommandOption("link", "Join link", Required = false)]
public string? Link { get; set; }
public override async Task ExecuteAsync()
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
var timeResult = DiscordNewSessionHandler.ParseTimeInput(Time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {timeResult.Error}"));
return;
}
try
{
var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
userRoles: Context.GuildUser!.RoleIds,
guildOwnerId: guild.OwnerId,
title: Title,
scheduledAt: timeResult.Value,
maxPlayers: Seats is null ? null : (int)Seats.Value,
joinLink: Link,
Context.CancellationToken);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("✅ Сессия создана!"));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"⛅ {ex.Message}"));
}
catch (Exception ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("💥 Произошла ошибка при создании сессии."));
}
}
}
```
### Step 3.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: PASS.
### Step 3.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs
git commit -m "feat(discord): add /newsession slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 4: DiscordPlatformMessenger
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`
**Context:** Необходима реализация `IPlatformMessenger` для отправки schedule embeds и обновления существующих сообщений в Discord. Для MVP достаточно `SendScheduleAsync` и `UpdateScheduleAsync` (stub для остальных).
### Step 4.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`:
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPlatformMessengerTests
{
[Fact]
public void Constructor_ShouldAcceptRestClient()
{
// DiscordPlatformMessenger requires a NetCord.Rest.RestClient.
// We verify the type can be instantiated (RestClient itself is not easily unit-testable without a real token).
// This test proves the contract exists and compiles.
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
Assert.NotNull(constructor);
}
[Fact]
public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger()
{
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
}
}
```
### Step 4.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: FAIL — `DiscordPlatformMessenger` not found.
### Step 4.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`:
```csharp
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var msg = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
return new PlatformMessageRef(
PlatformKind.Discord,
message.Group.ExternalGroupId,
null,
msg.Id.ToString());
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
if (message.ExistingMessage is null)
return;
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
await restClient.ModifyMessageAsync(
channelId,
messageId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
// MVP: not needed for /newsession and /listsessions
return Task.CompletedTask;
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
// MVP: not needed (commands answer inline via SlashCommandContext)
return Task.CompletedTask;
}
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
}
```
### Step 4.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: PASS.
### Step 4.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs
git commit -m "feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 5: Wire up DI and Register Commands
**Files:**
- Modify: `src/GmRelay.DiscordBot/Program.cs`
### Step 5.1: Write the failing test (structure test)
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` — add test that asserts new handlers are registered:
```csharp
[Fact]
public void Program_ShouldRegisterDiscordSessionHandlers()
{
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", program);
Assert.Contains("IPlatformMessenger", program);
}
```
### Step 5.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: FAIL — asserts not found in Program.cs.
### Step 5.3: Write minimal implementation
Modify `src/GmRelay.DiscordBot/Program.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
// ... existing usings ...
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
// After host.Build():
host.AddSlashCommand("listsessions", "Show upcoming game sessions", async (DiscordListSessionsHandler handler, SlashCommandContext context) =>
{
// NetCord module-based approach preferred; if AddSlashCommand lambda doesn't support DI injection of custom services,
// rely on module classes registered via AddApplicationCommands
});
```
**Important:** NetCord module classes (`DiscordListSessionsCommand`, `DiscordNewSessionCommand`) автоматически регистрируются через `AddApplicationCommands()` + `AddGatewayHandlers(typeof(Program).Assembly)`. Constructor injection в модулях работает через DI контейнер. Никаких дополнительных `AddSlashCommand` для модулей не требуется.
Убедиться, что в Program.cs есть:
```csharp
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
```
### Step 5.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: PASS.
### Step 5.5: Commit
```bash
git add src/GmRelay.DiscordBot/Program.cs tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs
git commit -m "feat(discord): wire up DI registrations for session handlers and messenger
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 6: Build Verification
### Step 6.1: Build DiscordBot project
Run: `dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore`
Expected: Build succeeds (0 errors, 0 warnings).
### Step 6.2: Run all tests
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
Expected: All tests pass.
### Step 6.3: Commit if any fixes needed
If build or tests required fixes, commit them.
---
## Task 7: Version Bump
**Files to modify:**
- `Directory.Build.props`: `<Version>2.4.0</Version>`
- `compose.yaml`: обновить теги `gmrelay-bot`, `gmrelay-web`, `gmrelay-discord-bot``2.4.0`
- `.gitea/workflows/deploy.yml`: `VERSION: 2.4.0`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `<div class="nav-version">v2.4.0</div>`
### Step 7.1: Bump version
Apply изменения ко всем 4 файлам.
### Step 7.2: Update version test
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` — обновить `Version_ShouldBeSynchronizedForDiscordFeatureRelease` ожидаемое значение на `2.4.0`.
### Step 7.3: Run version test
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Version_ShouldBeSynchronizedForDiscordFeatureRelease" --verbosity normal`
Expected: PASS.
### Step 7.4: Commit
```bash
git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs
git commit -m "chore: bump version to 2.4.0
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Spec Coverage Self-Review
| Issue Requirement | Task |
|---|---|
| Slash command `/newsession` | Task 3 |
| Slash command `/listsessions` | Task 2 |
| Сохранение platform group identity (guild/channel) | Task 3 (game_groups.platform, external_group_id, external_channel_id) |
| Минимальная проверка прав | Task 1 + Task 3 |
| Данные пишутся в общую PostgreSQL без Telegram-only assumptions | Task 2, 3 SQL используют platform-agnostic колонки |
| `/listsessions` публикует/обновляет расписание | Task 2 + Task 4 |
**Placeholder scan:** Нет TBD, TODO, "implement later". Каждый шаг содержит конкретный код.
**Type consistency:** `DiscordPermissionChecker.CanManageSchedule` перегружен для resolved permissions (ulong bitflag). Handler передает `Context.GuildUser.RoleIds` и `guild.OwnerId`.
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md`.**
**Two execution options:**
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review
**Which approach?**
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
# Platform Messenger Scheduler Notifications Design
## Goal
Issue #31 moves scheduler-driven notifications and reschedule deadline message updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord support instead of no-op placeholders.
## Scope
- `SessionSchedulerService` remains the trigger orchestrator, but scheduler handlers stop depending on Telegram API types for outbound notification work.
- Confirmation requests, one-hour reminders, join-link notifications, RSVP follow-up messages, and reschedule deadline updates use platform-neutral contracts.
- Telegram keeps the current user-visible behavior: same message content, RSVP buttons, direct messages, topic/thread targeting, and stored legacy message ids.
- Discord receives full channel and direct notifications:
- confirmation requests are sent to the Discord channel with RSVP buttons;
- Discord RSVP button clicks update participant RSVP state, refresh the confirmation message, and send the same group/GM outcome notifications where applicable;
- one-hour reminders and join-link notifications are sent as Discord DMs when direct notifications are enabled;
- join-link notifications also post the channel message with participant mentions;
- reschedule deadline processing updates Discord vote and schedule messages through the same messenger boundary.
- Discord DM failures are non-fatal: log a warning and continue without posting a public fallback message.
## Architecture
The platform boundary should be semantic, not Telegram-shaped. `GmRelay.Shared.Platform` already owns `PlatformKind`, `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `IPlatformMessenger`; issue #31 extends that layer with notification-specific DTOs and messenger methods.
The scheduler handlers own database queries and notification eligibility. They load platform-neutral groups, users, message refs, and session data, then ask the platform messenger to send or update the platform message. Platform implementations own rendering details: Telegram renders HTML and inline keyboards; Discord renders embeds, components, channel messages, mentions, and DMs.
RSVP handling should become platform-neutral enough for both Telegram and Discord. The current `HandleRsvpHandler` logic is not duplicated. Its command changes from Telegram ids to `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `InteractionId`. Telegram update routing maps callback queries into that command; Discord component routing maps RSVP button interactions into the same command.
Reschedule finalization already has shared database logic in `RescheduleVotingFinalizer`. The remaining platform-specific deadline services should stop editing messages through `ITelegramBotClient` or Discord `RestClient` directly. They should load message refs and call `IPlatformMessenger` to update vote messages, schedule messages, and direct result notifications.
## Platform Contracts
Add semantic notification records in `GmRelay.Shared.Platform`, with names finalized during implementation planning:
- `PlatformSessionParticipant`: a `PlatformUser` plus RSVP, registration, and display metadata needed by notification renderers.
- `PlatformSessionNotification`: common session title, time, join link, notification mode, group, optional existing message, and participants.
- `PlatformConfirmationRequest`: confirmation-specific session notification with RSVP actions.
- `PlatformJoinLinkNotification`: join-link group/direct notification data.
- `PlatformOneHourReminder`: one-hour direct reminder data.
- `PlatformRsvpMessageUpdate`: refreshed confirmation message state after a participant responds.
- `PlatformRescheduleVoteUpdate`: finalized reschedule vote message state, including selected option or rejection reason.
Extend `IPlatformMessenger` with methods for these semantic operations while keeping existing schedule, group, private, interaction, and calendar methods intact for current flows:
- send and update confirmation request messages;
- send one-hour reminder direct notifications;
- send join-link channel and direct notifications;
- update finalized reschedule vote messages;
- send RSVP outcome messages to the group and GM recipients.
The exact method names should be chosen in the implementation plan after tests define the desired API, but each method should accept platform-neutral DTOs and return `PlatformMessageRef` when the caller must persist a sent message id.
## Telegram Behavior
Telegram implementation lives in `GmRelay.Bot.Infrastructure.Telegram.TelegramPlatformMessenger`.
It must preserve:
- `messageThreadId` handling for forum topics;
- HTML parse mode where the existing flow uses HTML;
- current confirmation and RSVP button callback payloads;
- `confirmation_message_id` and `link_message_id` storage in `sessions`;
- direct notification behavior controlled by `SessionNotificationMode`;
- warning-and-continue behavior for failed direct messages;
- existing schedule rendering through `TelegramSessionBatchRenderer` and `BatchMessageEditor`.
Telegram-specific inbound parsing remains at the Telegram boundary. `UpdateRouter` can still use `Telegram.Bot.Types`, but the command it passes into the RSVP handler should be platform-neutral.
## Discord Behavior
Discord implementation lives in `GmRelay.DiscordBot.Infrastructure.Discord.DiscordPlatformMessenger`.
It must support:
- channel messages through the configured channel id in `PlatformGroup.ExternalChannelId`;
- interactive RSVP buttons routed by `DiscordSessionInteractionModule`;
- ephemeral interaction replies via the existing `DiscordInteractionReplyCache` pattern;
- DMs through Discord user ids in `PlatformUser.ExternalUserId`;
- non-fatal DM failures with warning logs;
- Discord-friendly rendering, not raw Telegram HTML;
- persistence of Discord schedule and notification message refs in `platform_messages` where later updates need them.
The current Discord reschedule deadline service directly uses `RestClient` for vote and schedule message edits. This should be folded into `DiscordPlatformMessenger` so deadline services and future platform handlers do not need to know Discord API details.
## Data Flow
1. `SessionSchedulerService.TickAsync` asks `ISessionTriggerStore` for due confirmation, one-hour reminder, and join-link session ids.
2. Each handler loads the session, group platform identity, message refs, participants, RSVP state, and notification mode.
3. The handler builds a semantic platform notification DTO and calls `IPlatformMessenger`.
4. The messenger renders and sends/updates platform messages.
5. The handler persists sent message ids where required, using legacy `sessions.confirmation_message_id` and `sessions.link_message_id` for Telegram and `platform_messages` for Discord refs that need later updates.
6. Telegram callback queries and Discord component interactions both call the same platform-neutral RSVP handler.
7. Reschedule deadline services use `RescheduleVotingFinalizer`, then call `IPlatformMessenger` for vote message updates, schedule updates, and direct result notifications.
## Error Handling
- A failed trigger query still logs and lets the scheduler continue to the next trigger category.
- A failed send/update for one session logs and does not stop other sessions in the same tick.
- DM failures are warning-level and non-fatal for Telegram and Discord.
- A missing platform message ref logs a warning and skips only the update that needs the ref.
- Unsupported platform values throw at the messenger boundary, not inside scheduler orchestration.
- If Discord cannot parse a stored channel, message, or user id, it logs the bad external id and skips that platform send/update.
## Testing
Use TDD for implementation.
Focused tests should cover:
- `IPlatformMessenger` exposes semantic notification methods without referencing Telegram or Discord assemblies from `GmRelay.Shared`.
- `SendConfirmationHandler`, `SendOneHourReminderHandler`, `SendJoinLinkHandler`, `HandleRsvpHandler`, and reschedule deadline services do not call `ITelegramBotClient`, `BatchMessageEditor`, or Discord `RestClient` directly for notification output.
- Telegram source/regression tests preserve thread ids, callback payloads, message id persistence, and direct notification mode behavior.
- Discord source tests verify registration of scheduler handlers, RSVP component routes, and messenger methods.
- RSVP flow tests run through platform-neutral `PlatformUser` identity, including Discord users without Telegram ids.
- Discord messenger tests verify DMs are attempted, DM failures are swallowed after logging, channel notifications include buttons or mentions as appropriate, and message refs are returned.
- Full regression: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`, `dotnet build`, and `dotnet format --verify-no-changes --verbosity diagnostic`.
## Versioning
Current repository version is `2.6.0`. Although the Gitea issue is labeled `type:refactor`, the approved scope adds full Discord notification behavior. Proposed bump: `2.6.0` to `2.7.0`.
Synchronize:
- `Directory.Build.props`
- `compose.yaml` image tags for bot, discord, and web
- `.gitea/workflows/deploy.yml` `VERSION`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
## Out Of Scope
- Moving the entire scheduler hosted service into `GmRelay.Shared`.
- Removing legacy Telegram columns such as `telegram_chat_id`, `confirmation_message_id`, or `link_message_id`.
- Reworking Web dashboard Telegram behavior.
- Public fallback messages when a Discord DM is blocked.
## Self-Review
- Spec coverage: every issue acceptance criterion is represented by scheduler handler boundaries, messenger contracts, Telegram behavior preservation, and Discord implementation requirements.
- Placeholder scan: no TBD/TODO/fill-in-later sections remain.
- Internal consistency: the design uses semantic platform DTOs consistently and keeps SDK-specific rendering in platform implementations.
- Scope check: the work is large but still one coherent platform-notification refactor; moving the whole scheduler to shared remains explicitly out of scope.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# GM-Relay PostgreSQL Backup Restore Script
# Usage: ./scripts/restore.sh [backup_file]
# If no file is provided, uses the most recent backup.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Check required env
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
if [ -f "${PROJECT_ROOT}/.env" ]; then
# shellcheck source=/dev/null
set -a
. "${PROJECT_ROOT}/.env"
set +a
fi
fi
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "ERROR: POSTGRES_PASSWORD is not set. Please set it in your environment or .env file."
exit 1
fi
BACKUP_DIR="${PROJECT_ROOT}/backups"
# Determine backup file
if [ $# -ge 1 ]; then
BACKUP_FILE="$1"
else
BACKUP_FILE=$(find "${BACKUP_DIR}" -name 'gmrelay_db_*.sql.gz' -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n1 | cut -d' ' -f2-)
if [ -z "${BACKUP_FILE}" ]; then
echo "ERROR: No backup files found in ${BACKUP_DIR}."
exit 1
fi
fi
if [ ! -f "${BACKUP_FILE}" ]; then
echo "ERROR: Backup file not found: ${BACKUP_FILE}"
exit 1
fi
echo "=================================================="
echo " GM-Relay PostgreSQL Restore"
echo "=================================================="
echo ""
echo "Backup file: ${BACKUP_FILE}"
echo "Database: gmrelay_db"
echo "User: gmrelay"
echo ""
read -p "This will OVERWRITE the current database. Are you sure? [y/N] " CONFIRM
if [[ ! "${CONFIRM}" =~ ^[Yy]$ ]]; then
echo "Restore cancelled."
exit 0
fi
echo ""
echo "Restoring database from ${BACKUP_FILE}..."
# Restore using docker compose exec to leverage the running postgres container
COMPOSE_ARGS="-f ${PROJECT_ROOT}/compose.yaml"
docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
-U gmrelay \
-d gmrelay_db \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 2>/dev/null || true
gunzip -c "${BACKUP_FILE}" | docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
-U gmrelay \
-d gmrelay_db
echo ""
echo "=================================================="
echo " Restore completed successfully!"
echo "=================================================="
@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\GmRelay.Bot\GmRelay.Bot.csproj" />
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
<ProjectReference Include="..\GmRelay.Web\GmRelay.Web.csproj" />
</ItemGroup>
+4
View File
@@ -8,6 +8,10 @@ builder.AddProject<Projects.GmRelay_Bot>("bot")
.WithReference(postgres)
.WaitFor(postgres);
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres);
builder.AddProject<Projects.GmRelay_Web>("web")
.WithReference(postgres)
.WaitFor(postgres);
+687
View File
@@ -0,0 +1,687 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Aspire.Dashboard.Sdk.win-x64": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "KLB9rXwY8kg2taWwxsJFoK0cAuupSZurcv1zTyYMqLyNuwvYYjs65Yz3g/cgh22QlUfOT3tOh+Jzk5MdJhy5+w=="
},
"Aspire.Hosting.AppHost": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "4B/eoZPwOobxpMpvYnqe/EcXabjPhZJhfxlHXv5gdKd16duoWbHnvvAZJsVI3WUpakCwmsCiTrT4sNGfW8H+IQ==",
"dependencies": {
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Aspire.Hosting": "13.2.1",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"Aspire.Hosting.Orchestration.win-x64": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "39lRUH4WuCsBaYB7fZH1/r81SSJIXrA8WphBlAdP1QT95+1sKQHzXJuXU4nzKpBLv4oZmjcWzvA+FDMGZbWmkw=="
},
"Aspire.Hosting.PostgreSQL": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "7F/nmeplR9cYE/B/E1haRjnkoBRQ/voMXpnK/SNJoXSFs4Vb/g00CDDvI/xfH3SAV7Xq8ekWa9ZbX56JuQ+YiA==",
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Aspire.Hosting": "13.2.1",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.25",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Aspire.Hosting": {
"type": "Transitive",
"resolved": "13.2.1",
"contentHash": "GY/T5iK2F4K3Sk60VUeVnTX1MhCjSaX48+qPUjA/rI1x1ONHevHzFj+Gc3fNlGEaZGY8L87hSxwGrV+Bjd5EJw==",
"dependencies": {
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.25",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"AspNetCore.HealthChecks.NpgSql": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Npgsql": "8.0.3"
}
},
"AspNetCore.HealthChecks.Uris": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "XYdNlA437KeF8p9qOpZFyNqAN+c0FXt/JjTvzH/Qans0q0O3pPE8KPnn39ucQQjR/Roum1vLTP3kXiUs8VHyuA==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Microsoft.Extensions.Http": "8.0.0"
}
},
"Fractions": {
"type": "Transitive",
"resolved": "7.3.0",
"contentHash": "2bETFWLBc8b7Ut2SVi+bxhGVwiSpknHYGBh2PADyGWONLkTxT7bKyDRhF8ao+XUv90tq8Fl7GTPxSI5bacIRJw=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.33.5",
"contentHash": "XEzLpCTosZb5I6eGSPn7rAES0VfkJkn3Cqydh0W39POdZwkdhPhOmAROTFJF9g0ardst4ulNXRm/q/iXwNu+Qw=="
},
"Grpc.AspNetCore": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==",
"dependencies": {
"Google.Protobuf": "3.31.1",
"Grpc.AspNetCore.Server.ClientFactory": "2.76.0",
"Grpc.Tools": "2.76.0"
}
},
"Grpc.AspNetCore.Server": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==",
"dependencies": {
"Grpc.Net.Common": "2.76.0"
}
},
"Grpc.AspNetCore.Server.ClientFactory": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==",
"dependencies": {
"Grpc.AspNetCore.Server": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0"
}
},
"Grpc.Core.Api": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ=="
},
"Grpc.Net.Client": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==",
"dependencies": {
"Grpc.Net.Common": "2.76.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Grpc.Net.ClientFactory": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==",
"dependencies": {
"Grpc.Net.Client": "2.76.0",
"Microsoft.Extensions.Http": "8.0.0"
}
},
"Grpc.Net.Common": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==",
"dependencies": {
"Grpc.Core.Api": "2.76.0"
}
},
"Grpc.Tools": {
"type": "Transitive",
"resolved": "2.78.0",
"contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"Json.More.Net": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "qtwsyAsL55y2vB2/sK4Pjg3ZyVzD5KKSpV3lOAMHlnjFfsjQ/86eHJfQT9aV1YysVXzF4+xyHOZbh7Iu3YQ7Lg=="
},
"JsonPatch.Net": {
"type": "Transitive",
"resolved": "3.3.0",
"contentHash": "GIcMMDtzfzVfIpQgey8w7dhzcw6jG5nD4DDAdQCTmHfblkCvN7mI8K03to8YyUhKMl4PTR6D6nLSvWmyOGFNTg==",
"dependencies": {
"JsonPointer.Net": "5.2.0"
}
},
"JsonPointer.Net": {
"type": "Transitive",
"resolved": "5.2.0",
"contentHash": "qe1F7Tr/p4mgwLPU9P60MbYkp+xnL2uCPnWXGgzfR/AZCunAZIC0RZ32dLGJJEhSuLEfm0YF/1R3u5C7mEVq+w==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Json.More.Net": "2.1.0"
}
},
"KubernetesClient": {
"type": "Transitive",
"resolved": "18.0.13",
"contentHash": "X5IuxmydftB148XeULtc7rD5/RvqLuW5SzkIjFovPgJpvV4RAoRqNPruVB7GEFu1Xg+zHVIk88WqdV8JjbgHbA==",
"dependencies": {
"Fractions": "7.3.0",
"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=="
},
"Microsoft.Extensions.AI.Abstractions": {
"type": "Transitive",
"resolved": "10.3.0",
"contentHash": "hDjDvUERvUH3HBMs2MDusOcGJBjAHOG5pJIU2x/HZEa4e1UthNKt89cwMi3B+ogJo6skki1XFjfgGN3ksnVqvQ=="
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.3"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
},
"Microsoft.Extensions.Hosting": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Logging.Console": "10.0.5",
"Microsoft.Extensions.Logging.Debug": "10.0.5",
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "AiFvHYM8nP0wPC7bGPI3NHQlSYSLqjjT7DMJUuuxhd+7pz3O89iu2gdQfgACy5DxsXENiok5i1bMacJL7KR8jA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"System.Diagnostics.EventLog": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.6.3",
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
},
"Microsoft.VisualStudio.Threading.Only": {
"type": "Transitive",
"resolved": "17.13.61",
"contentHash": "vl5a2URJYCO5m+aZZtNlAXAMz28e2pUotRuoHD7RnCWOCeoyd8hWp5ZBaLNYq4iEj2oeJx5ZxiSboAjVmB20Qg==",
"dependencies": {
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Microsoft.VisualStudio.Validation": {
"type": "Transitive",
"resolved": "17.8.8",
"contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g=="
},
"ModelContextProtocol": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "W7UX8AQ1qMjXyCDcpP25u/L1W2vIIgfhLX/B2ZtTU1VUyILXdmVbdRjkQesKVPT/wPMpYXIHUcZJTPdsGfKSfQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.3",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.3",
"ModelContextProtocol.Core": "1.0.0"
}
},
"ModelContextProtocol.Core": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "QKboiQEq2MJMGeQ029Gy6xqge88abm0Px9lnG7hueOyf+EDCxi5SUATV+Df7GwT+NwWzkEsYG271bUQD+LGhEg==",
"dependencies": {
"Microsoft.Extensions.AI.Abstractions": "10.3.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.3"
}
},
"Nerdbank.Streams": {
"type": "Transitive",
"resolved": "2.12.87",
"contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==",
"dependencies": {
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.4",
"contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
},
"Npgsql": {
"type": "Transitive",
"resolved": "8.0.3",
"contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.6.5",
"contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg=="
},
"Semver": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "5.0.1"
}
},
"StreamJsonRpc": {
"type": "Transitive",
"resolved": "2.22.23",
"contentHash": "Ahq6uUFPnU9alny5h4agyX74th3PRq3NQCRNaDOqWcx20WT06mH/wENSk5IbHDc8BmfreQVEIBx5IXLBbsLFIA==",
"dependencies": {
"MessagePack": "2.5.192",
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
"Microsoft.VisualStudio.Validation": "17.8.8",
"Nerdbank.Streams": "2.12.87",
"Newtonsoft.Json": "13.0.3"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "La6ICwsdTKhVX+LKN+pvFjQRR3LhLwq3uKdi2knjLzRyPYBSydF4cjXidYxIiTcDD6XVYdsBWQEI8ZxiZ/OdIg=="
},
"YamlDotNet": {
"type": "Transitive",
"resolved": "16.3.0",
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
}
}
}
}
+8
View File
@@ -30,8 +30,16 @@ 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 \
&& rm -rf /var/lib/apt/lists/*
# Копируем только AOT-результаты из билда
COPY --from=build /app/publish .
EXPOSE 8081
USER $APP_UID
# Запуск скомпилированного AOT бинарного файла напрямую
ENTRYPOINT ["./GmRelay.Bot"]
@@ -1,315 +0,0 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
public sealed record HandleRsvpCommand(
Guid SessionId,
long TelegramUserId,
string Status,
string CallbackQueryId,
long ChatId,
int MessageId);
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext(
string Title,
DateTime ScheduledAt,
string Status,
long GmTelegramId,
long TelegramChatId);
internal sealed record ParticipantRsvp(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus);
public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<HandleRsvpHandler> logger)
{
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var participantExists = await connection.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
)
""",
new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (!participantExists)
{
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return;
}
var updated = await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = @Status,
responded_at = now()
WHERE session_id = @SessionId
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
AND registration_status = @Active
AND rsvp_status != @Status
""",
new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active },
transaction);
if (updated == 0)
{
var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие."
: "Вы уже отказались от участия.";
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: alreadyText,
cancellationToken: ct);
return;
}
var session = await connection.QuerySingleAsync<SessionContext>(
"""
SELECT s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
""",
new { command.SessionId },
transaction);
if (command.Status == RsvpStatus.Declined)
{
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId },
transaction);
await transaction.CommitAsync(ct);
try
{
await bot.SendMessage(
chatId: session.GmTelegramId,
text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: decision.CallbackText,
cancellationToken: ct);
}
else
{
var counts = await connection.QuerySingleAsync<RsvpCounts>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
AND registration_status = @Active
""",
new
{
command.SessionId,
Confirmed = RsvpStatus.Confirmed,
Declined = RsvpStatus.Declined,
Active = ParticipantRegistrationStatus.Active
},
transaction);
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (decision.ShouldMarkSessionConfirmed)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Confirmed, updated_at = now()
WHERE id = @SessionId
""",
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
transaction);
}
await transaction.CommitAsync(ct);
if (decision.ShouldNotifyGroup)
{
try
{
await bot.SendMessage(
chatId: session.TelegramChatId,
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
}
}
if (decision.ShouldNotifyGm)
{
try
{
await bot.SendMessage(
chatId: session.GmTelegramId,
text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
}
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: decision.CallbackText,
cancellationToken: ct);
}
await UpdateConfirmationMessage(command, session, ct);
}
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var participants = (await connection.QueryAsync<ParticipantRsvp>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY sp.responded_at NULLS LAST
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
var lines = new List<string>
{
$"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
string.Empty
};
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatName(participant)}");
}
foreach (var participant in declined)
{
lines.Add($" ❌ ~~{FormatName(participant)}~~");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatName(participant)}");
}
lines.Add(string.Empty);
if (confirmed.Count == participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
}
var text = string.Join("\n", lines);
var replyMarkup = confirmed.Count == participants.Count
? null
: new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}")
]
]);
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: text,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
}
}
private static string FormatName(ParticipantRsvp participant) =>
participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
}
@@ -1,149 +0,0 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
// ── DTOs for Dapper mapping ──────────────────────────────────────────
internal sealed record SessionInfo(
Guid Id,
string Title,
DateTime ScheduledAt,
Guid GroupId,
long TelegramChatId,
string NotificationMode);
internal sealed record ParticipantInfo(
long TelegramId,
string DisplayName,
string? TelegramUsername);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
/// Called by SessionSchedulerService at T-24h.
/// </summary>
public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendConfirmationHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Load session + group info
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
"""
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.status = @Planned
""",
new { SessionId = sessionId, Planned = SessionStatus.Planned });
if (session is null)
{
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
return;
}
// 2. Load non-GM participants
var participants = (await connection.QueryAsync<ParticipantInfo>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
if (participants.Count == 0)
{
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
return;
}
// 3. Build confirmation message
var playerList = string.Join("\n", participants.Select(p =>
$" ⏳ {FormatPlayerName(p)}"));
var text = $"""
🎲 Подтвердите участие в «{session.Title}»
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
{playerList}
Статус: ожидаем подтверждения (0/{participants.Count})
""";
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
]
]);
// 4. Send to group
var message = await bot.SendMessage(
chatId: session.TelegramChatId,
text: text,
replyMarkup: keyboard,
cancellationToken: ct);
// 5. Update session status and store message ID
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Status,
confirmation_message_id = @MessageId,
updated_at = now()
WHERE id = @SessionId
""",
new
{
SessionId = sessionId,
Status = SessionStatus.ConfirmationSent,
MessageId = message.MessageId
});
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"confirmation",
sessionId,
ct);
}
logger.LogInformation(
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
}
internal static string FormatPlayerName(ParticipantInfo p) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
}
@@ -1,12 +1,12 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender(
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<DirectSessionNotificationSender> logger)
{
public async Task SendAsync(
@@ -20,11 +20,11 @@ public sealed class DirectSessionNotificationSender(
{
try
{
await bot.SendMessage(
chatId: recipient.TelegramId,
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
await messenger.SendPrivateMessageAsync(
new PlatformPrivateMessage(
TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
htmlText),
ct);
}
catch (Exception ex)
{
@@ -1,131 +0,0 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record JoinLinkSession(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
long TelegramChatId,
string NotificationMode);
internal sealed record ConfirmedPlayer(
long TelegramId,
string DisplayName,
string? TelegramUsername);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Sends the join link to the group chat at T-5min, tagging all confirmed players.
/// Called by SessionSchedulerService.
/// </summary>
public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Load session
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
"""
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND s.link_message_id IS NULL
""",
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
return;
}
// 2. Load confirmed players
var players = (await connection.QueryAsync<ConfirmedPlayer>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.rsvp_status = @Confirmed
AND sp.registration_status = @Active
""",
new
{
SessionId = sessionId,
Confirmed = RsvpStatus.Confirmed,
Active = ParticipantRegistrationStatus.Active
})).ToList();
// 3. Build message with player mentions
var mentions = string.Join(", ", players.Select(p =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName));
var text = $"""
🎮 Игра «{session.Title}» начинается через 5 минут!
🔗 Ссылка на подключение:
{session.JoinLink}
Участники: {mentions}
Хорошей игры! 🎲
""";
// 4. Send
var message = await bot.SendMessage(
chatId: session.TelegramChatId,
text: text,
cancellationToken: ct);
// 5. Mark as sent (idempotent — link_message_id IS NULL guard in query)
await connection.ExecuteAsync(
"""
UPDATE sessions
SET link_message_id = @MessageId, updated_at = now()
WHERE id = @SessionId AND link_message_id IS NULL
""",
new { SessionId = sessionId, MessageId = message.MessageId });
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(
players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"join-link",
sessionId,
ct);
}
logger.LogInformation(
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
}
}
@@ -1,10 +1,10 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -13,14 +13,15 @@ public sealed record CancelSessionCommand(
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int? MessageThreadId,
int MessageId);
// DTOs for AOT compilation
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode);
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, string NotificationMode);
public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger)
{
@@ -28,12 +29,13 @@ public sealed class CancelSessionHandler(
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode,
EXISTS (
SELECT 1
@@ -49,13 +51,13 @@ public sealed class CancelSessionHandler(
if (session == null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", ct, showAlert: true);
return;
}
@@ -67,7 +69,7 @@ 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
@"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",
@@ -101,22 +103,25 @@ public sealed class CancelSessionHandler(
await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
view,
TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
ct);
await AnswerAsync(command.CallbackQueryId, "Сессия отменена!", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
// Опционально: написать отдельное сообщение в чат
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
$"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
@@ -132,7 +137,10 @@ public sealed class CancelSessionHandler(
catch (Exception ex)
{
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Ошибка при обновлении сообщения.", ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -4,6 +4,7 @@ using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -16,7 +17,7 @@ public sealed class CreateSessionHandler(
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
foreach (var timeInput in parseResult.PastTimeInputs)
{
@@ -54,13 +55,14 @@ public sealed class CreateSessionHandler(
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken);
return;
}
var title = parseResult.Title!;
var link = parseResult.Link!;
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username;
@@ -75,11 +77,14 @@ public sealed class CreateSessionHandler(
{
await connection.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username;
telegram_username = EXCLUDED.telegram_username,
platform = COALESCE(players.platform, 'Telegram'),
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username);
""",
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
@@ -92,10 +97,10 @@ public sealed class CreateSessionHandler(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.telegram_id = @GmId
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
) AS CanManage
FROM game_groups g
WHERE g.telegram_chat_id = @ChatId
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
""",
new { ChatId = chatId, GmId = gmId },
transaction);
@@ -105,8 +110,8 @@ public sealed class CreateSessionHandler(
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
@@ -117,7 +122,7 @@ public sealed class CreateSessionHandler(
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.telegram_id = @GmId
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
@@ -142,14 +147,31 @@ public sealed class CreateSessionHandler(
transaction);
}
int? messageThreadId = null;
if (message.Chat.IsForum)
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
if (topicDestination.ShouldCreateForumTopic)
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
try
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
TelegramTopicRouting.MissingForumTopicRightsMessage,
cancellationToken: cancellationToken);
return;
}
}
var batchId = Guid.NewGuid();
@@ -159,8 +181,8 @@ public sealed class CreateSessionHandler(
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
RETURNING id;
""",
new
@@ -171,26 +193,78 @@ public sealed class CreateSessionHandler(
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers));
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
Message batchMessage;
if (imageReference is not null && renderResult.Text.Length <= 1024)
{
// Картинка + расписание умещаются в одном Telegram-фото с подписью
try
{
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
if (imageReference is not null)
{
try
{
await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
}
}
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
@@ -215,4 +289,20 @@ public sealed class CreateSessionHandler(
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
}
}
internal static string? GetBatchImageReference(Message message, string? parsedImageUrl)
{
var attachedPhotoFileId = message.Photo?
.OrderByDescending(photo => photo.FileSize ?? 0)
.ThenByDescending(photo => photo.Width * photo.Height)
.FirstOrDefault()
?.FileId;
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
{
return attachedPhotoFileId;
}
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim();
}
}
@@ -5,6 +5,7 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
string? ImageUrl,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
@@ -27,6 +28,12 @@ internal static class NewSessionCommandParser
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
private static readonly string[] ImagePrefixes =
[
"\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:",
"\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:",
"\u041e\u0431\u043b\u043e\u0436\u043a\u0430:"
];
private static readonly string[] SeatLimitPrefixes =
[
"\u041c\u0435\u0441\u0442:",
@@ -49,6 +56,7 @@ internal static class NewSessionCommandParser
{
string? title = null;
string? link = null;
string? imageUrl = null;
int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
@@ -72,6 +80,14 @@ internal static class NewSessionCommandParser
continue;
}
var imagePrefix = ImagePrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (imagePrefix is not null)
{
imageUrl = line[imagePrefix.Length..].Trim();
continue;
}
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null)
@@ -157,6 +173,7 @@ internal static class NewSessionCommandParser
return new NewSessionParseResult(
title,
link,
imageUrl,
maxPlayers,
scheduledTimes,
pastTimeInputs,
@@ -1,8 +1,9 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -13,12 +14,12 @@ public sealed record PromoteWaitlistedPlayerCommand(
long ChatId,
int MessageId);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, int? MaxPlayers);
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<PromoteWaitlistedPlayerHandler> logger)
{
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
@@ -33,6 +34,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.max_players AS MaxPlayers,
EXISTS (
SELECT 1
@@ -51,14 +53,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (session is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (!session.CanManage)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", ct, showAlert: true);
return;
}
@@ -87,14 +89,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (waitlistedParticipants == 0)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
return;
}
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
return;
}
@@ -135,7 +137,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
SELECT id AS SessionId,
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers
max_players AS MaxPlayers,
join_link AS JoinLink
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
@@ -161,17 +164,16 @@ public sealed class PromoteWaitlistedPlayerHandler(
await transaction.CommitAsync(ct);
transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, messageId)),
ct);
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
}
catch (Exception ex)
{
@@ -184,7 +186,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
var errorText = transactionCommitted
? "Игрок повышен, но не удалось обновить сообщение расписания."
: "Ошибка при обновлении листа ожидания.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, errorText, ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -1,8 +1,10 @@
using System.Text;
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -11,30 +13,31 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
IPlatformMessenger messenger,
IConfiguration configuration)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId
AND s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.telegram_chat_id = @ChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
@@ -54,24 +57,57 @@ public sealed class ExportCalendarHandler(
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
// Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary
// In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text.
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
using var stream = new MemoryStream(bytes);
var inputFile = InputFile.FromStream(stream, "schedule.ics");
await botClient.SendDocument(
chatId: message.Chat.Id,
document: inputFile,
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
messageThreadId: message.MessageThreadId,
cancellationToken: cancellationToken);
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = message.From?.Id;
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
new { ChatId = message.Chat.Id });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
}
}
@@ -1,6 +1,7 @@
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -12,7 +13,13 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource,
@@ -29,7 +36,9 @@ public sealed class DeleteSessionHandler(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
@@ -57,15 +66,23 @@ public sealed class DeleteSessionHandler(
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
// 3. Check if any sessions are left in the batch
var remainingInBatch = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId",
new { BatchId = session.BatchId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
// 4. If no sessions left and we have a forum topic, delete the topic
if (remainingInBatch == 0 && session.ThreadId.HasValue)
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (session.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
{
try
{
@@ -113,34 +130,20 @@ public sealed class DeleteSessionHandler(
if (sessionsList.Count == 0)
{
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch {}
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
return;
}
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var s in sessionsList)
{
var seats = s.MaxPlayers.HasValue
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessionsList.First().CanManage;
var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
var renderResult = SessionListMessageRenderer.Render(sessionsList);
try
{
await bot.EditMessageText(
command.ChatId,
command.MessageId,
text,
renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
catch (Exception ex)
@@ -3,11 +3,60 @@ using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
internal static class SessionListMessageRenderer
{
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessions.Count > 0 && sessions.First().CanManage;
if (!canManage)
{
return (text, null);
}
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
]);
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
]);
}
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
]);
}
return (text, new InlineKeyboardMarkup(buttons));
}
}
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
@@ -53,27 +102,13 @@ public sealed class ListSessionsHandler(
return;
}
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var s in sessionsList)
{
var seats = s.MaxPlayers.HasValue
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessionsList.First().CanManage;
var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
var renderResult = SessionListMessageRenderer.Render(sessionsList);
await botClient.SendMessage(
chatId: message.Chat.Id,
text: text,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
@@ -1,11 +1,14 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -13,13 +16,7 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
// ── Handler ──────────────────────────────────────────────────────────
@@ -32,6 +29,7 @@ internal sealed record VoteParticipantDto(
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
@@ -56,6 +54,7 @@ public sealed class HandleRescheduleTimeInputHandler(
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
@@ -81,11 +80,10 @@ public sealed class HandleRescheduleTimeInputHandler(
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
await bot.SendMessage(
chatId: chatId,
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
ct);
return true;
}
@@ -160,6 +158,7 @@ public sealed class HandleRescheduleTimeInputHandler(
var voteMsg = await bot.SendMessage(
chatId: chatId,
messageThreadId: proposal.ThreadId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
@@ -223,6 +222,8 @@ public sealed class HandleRescheduleTimeInputHandler(
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
@@ -237,11 +238,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await transaction.CommitAsync(ct);
await bot.SendMessage(
chatId: chatId,
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
ct);
// Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct);
@@ -356,7 +356,7 @@ public sealed class HandleRescheduleTimeInputHandler(
await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers 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 FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -375,16 +375,14 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(
proposal.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
ct);
}
else
{
@@ -1,5 +1,7 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -12,16 +14,10 @@ public sealed record HandleRescheduleVoteCommand(
long ChatId,
int MessageId);
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger)
{
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
@@ -46,20 +42,13 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal is null)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
return;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
showAlert: true,
cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
return;
}
@@ -78,10 +67,7 @@ public sealed class HandleRescheduleVoteHandler(
if (playerId is null)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
return;
}
@@ -169,9 +155,9 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
}
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Ваш голос учтён. До дедлайна его можно изменить.",
cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -1,7 +1,8 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -12,6 +13,7 @@ public sealed record InitiateRescheduleCommand(
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int? MessageThreadId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
@@ -27,7 +29,7 @@ internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
/// </summary>
public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<InitiateRescheduleHandler> logger)
{
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
@@ -52,14 +54,13 @@ public sealed class InitiateRescheduleHandler(
if (session is null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
return;
}
@@ -75,38 +76,43 @@ public sealed class InitiateRescheduleHandler(
if (hasActive)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
return;
}
// 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
VALUES (@SessionId, @GmId, 'AwaitingTime')
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
""",
new { command.SessionId, GmId = command.TelegramUserId });
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
// 4. Prompt GM in chat
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
await bot.SendMessage(
chatId: command.ChatId,
text: $"""
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
var prompt = string.Join(
"\n",
new[]
{
$"⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.",
"",
"Формат:",
"<code>25.04.2026 19:30",
"26.04.2026 18:00",
"Дедлайн: 25.04.2026 12:00</code>",
"",
"Дедлайн должен быть в будущем и раньше первого предложенного времени."
});
Формат:
<code>25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00</code>
Дедлайн должен быть в будущем и раньше первого предложенного времени.
""",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
prompt,
ct);
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -1,29 +1,25 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
internal sealed record TelegramProposalFieldsDto(
int? VoteMessageId,
int? BatchMessageId,
long TelegramChatId,
string NotificationMode);
int? ThreadId);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
IPlatformMessenger messenger,
PlatformDirectNotificationSender directSender,
RescheduleVotingFinalizer finalizer,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -47,17 +43,7 @@ public sealed class RescheduleVotingDeadlineService(
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= now()
ORDER BY voting_deadline_at
LIMIT 25
""")).ToList();
var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct);
foreach (var proposalId in proposalIds)
{
@@ -75,218 +61,109 @@ public sealed class RescheduleVotingDeadlineService(
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
if (result.SourcePlatform != "Telegram")
{
logger.LogInformation(
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
proposalId,
result.SourcePlatform);
return;
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.vote_message_id AS VoteMessageId,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= now()
FOR UPDATE
""",
new { ProposalId = proposalId },
transaction);
new { ProposalId = proposalId });
if (proposal is null)
if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
return;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.Id,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.Id },
transaction);
}
var directRecipients = participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
var directRecipients = result.Participants
.Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName))
.ToList();
await transaction.CommitAsync(ct);
await TryUpdateVoteMessage(result, telegramFields, ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
if (selectedOption is not null)
if (result.SelectedOption is not null)
{
await TryUpdateBatchMessage(proposal, ct);
await TryUpdateBatchMessage(result, telegramFields, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
await SendDirectResult(result, directRecipients, ct);
}
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.Id,
proposal.SessionId,
decision.Outcome);
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
result.ProposalId,
result.SessionId);
}
private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
if (proposal.VoteMessageId is null)
if (telegramFields.VoteMessageId is null)
return;
try
{
var resultText = selectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes)}
{resultText}
""";
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.VoteMessageId.Value,
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
await messenger.UpdateRescheduleVoteAsync(
new PlatformRescheduleVoteUpdate(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
TelegramPlatformIds.Message(
telegramFields.TelegramChatId,
telegramFields.ThreadId,
telegramFields.VoteMessageId.Value),
result.ProposalId,
result.SessionId,
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Decision,
result.SelectedOption,
result.Options,
result.Votes,
result.Participants),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
}
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct)
private async Task TryUpdateBatchMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
try
{
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 FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
"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",
new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
@@ -300,62 +177,49 @@ public sealed class RescheduleVotingDeadlineService(
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
new { result.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
if (telegramFields.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view,
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
ct);
}
else
{
await bot.SendMessage(
chatId: proposal.TelegramChatId,
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
parseMode: ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".",
ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
}
}
private async Task SendDirectResult(
DueRescheduleProposalDto proposal,
IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
RescheduleVotingFinalizerResult result,
IReadOnlyList<PlatformUser> recipients,
CancellationToken ct)
{
var htmlText = selectedOption is not null
? $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
"""
: $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)}
""";
await directSender.SendAsync(
result.SelectedOption is not null
? PlatformDirectSessionNotificationKind.RescheduleApproved
: PlatformDirectSessionNotificationKind.RescheduleRejected,
recipients,
htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId,
result.SessionId,
result.Title,
result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt,
joinLink: null,
actorDisplayName: null,
reason: result.SelectedOption is null ? result.Decision.Reason : null,
ct);
}
}
@@ -0,0 +1,101 @@
using System.Net;
namespace GmRelay.Bot.Infrastructure.Health;
public sealed class BotHealthCheckHostedService : IHostedService
{
private readonly ILogger<BotHealthCheckHostedService> _logger;
private readonly string _prefix;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _listenerTask;
public BotHealthCheckHostedService(
ILogger<BotHealthCheckHostedService> logger,
IConfiguration configuration)
{
_logger = logger;
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8081/")!;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(_prefix);
_listener.Start();
_logger.LogInformation("Health check server started on {Prefix}", _prefix);
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
_listener?.Stop();
if (_listenerTask != null)
{
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
}
_listener?.Close();
_logger.LogInformation("Health check server stopped");
}
private async Task ListenAsync(CancellationToken cancellationToken)
{
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
}
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in health check listener");
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
var response = context.Response;
try
{
var request = context.Request;
if (request.Url?.AbsolutePath == "/health")
{
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = "application/json";
var body = "{\"status\":\"healthy\"}"u8.ToArray();
await response.OutputStream.WriteAsync(body);
}
else
{
response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling health check request");
}
finally
{
response.Close();
}
}
}
@@ -0,0 +1,13 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Scheduling;
public sealed class SystemClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class FakeSystemClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
}
@@ -1,157 +0,0 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
/// <summary>
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
/// Two triggers:
/// T-24h: send confirmation request with inline keyboard
/// T-5min: send join link to all confirmed players
///
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
/// </summary>
public sealed class SessionSchedulerService(
NpgsqlDataSource dataSource,
SendConfirmationHandler confirmationHandler,
SendOneHourReminderHandler oneHourReminderHandler,
SendJoinLinkHandler joinLinkHandler,
ILogger<SessionSchedulerService> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Session scheduler started (interval: {Interval})", TickInterval);
using var timer = new PeriodicTimer(TickInterval);
// Run immediately on startup, then on each tick
do
{
try
{
await ProcessConfirmationTriggers(stoppingToken);
await ProcessOneHourReminderTriggers(stoppingToken);
await ProcessJoinLinkTriggers(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Scheduler tick failed, will retry next tick");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
logger.LogInformation("Session scheduler stopped");
}
/// <summary>
/// T-1h trigger: process direct reminders according to the session notification mode.
/// </summary>
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status IN (@Confirmed, @ConfirmationSent)
AND scheduled_at - @LeadTime <= now()
AND one_hour_reminder_processed_at IS NULL
""",
new
{
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent,
LeadTime = OneHourReminderLeadTime
});
foreach (var sessionId in sessionIds)
{
try
{
await oneHourReminderHandler.HandleAsync(sessionId, ct);
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
}
}
}
/// <summary>
/// T-24h trigger: find sessions that need confirmation requests sent.
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
/// </summary>
private async Task ProcessConfirmationTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Planned
AND scheduled_at - @LeadTime <= now()
""",
new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime });
foreach (var sessionId in sessionIds)
{
try
{
await confirmationHandler.HandleAsync(sessionId, ct);
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
}
}
}
/// <summary>
/// T-5min trigger: find confirmed sessions that need join links sent.
/// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent.
/// </summary>
private async Task ProcessJoinLinkTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Confirmed
AND scheduled_at - @LeadTime <= now()
AND link_message_id IS NULL
""",
new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime });
foreach (var sessionId in sessionIds)
{
try
{
await joinLinkHandler.HandleAsync(sessionId, ct);
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
}
}
}
}
@@ -0,0 +1,51 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <summary>
/// Handles editing batch messages that may be either text or photo messages.
/// When the batch was created with SendPhoto (image + caption), we need
/// EditMessageCaption instead of EditMessageText.
/// </summary>
public static class BatchMessageEditor
{
/// <summary>
/// Edits a batch message, automatically detecting whether it is a text or photo message.
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
/// </summary>
public static async Task EditBatchMessageAsync(
ITelegramBotClient bot,
long chatId,
int messageId,
string text,
InlineKeyboardMarkup? replyMarkup,
CancellationToken ct = default)
{
try
{
await bot.EditMessageText(
chatId: chatId,
messageId: messageId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (global::Telegram.Bot.Exceptions.ApiRequestException ex)
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
{
// The batch message is a photo — use EditMessageCaption instead.
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
await bot.EditMessageCaption(
chatId: chatId,
messageId: messageId,
caption: caption,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
}
}
@@ -0,0 +1,29 @@
using System.Globalization;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Telegram;
internal static class TelegramPlatformIds
{
public static PlatformGroup Group(long chatId, int? threadId = null, string? displayName = null) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
displayName ?? "Telegram chat",
ExternalChannelId: chatId.ToString(CultureInfo.InvariantCulture),
ExternalThreadId: threadId?.ToString(CultureInfo.InvariantCulture));
public static PlatformUser User(long telegramId, string displayName, string? username = null) =>
new(
PlatformKind.Telegram,
telegramId.ToString(CultureInfo.InvariantCulture),
displayName,
username);
public static PlatformMessageRef Message(long chatId, int? threadId, int messageId) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
threadId?.ToString(CultureInfo.InvariantCulture),
messageId.ToString(CultureInfo.InvariantCulture));
}
@@ -0,0 +1,455 @@
using System.Globalization;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramPlatformMessenger(
ITelegramBotClient bot,
ILogger<TelegramPlatformMessenger> logger) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var chatId = ParseLong(message.Group.ExternalGroupId);
var threadId = ParseNullableInt(message.Group.ExternalThreadId);
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
Message sentMessage;
if (!string.IsNullOrWhiteSpace(message.ImageReference) && renderResult.Text.Length <= 1024)
{
try
{
sentMessage = await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(message.ImageReference),
caption: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for group {ExternalGroupId}", message.Group.ExternalGroupId);
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
}
else
{
if (!string.IsNullOrWhiteSpace(message.ImageReference))
{
await TrySendScheduleImageOnly(chatId, threadId, message.View.Title, message.ImageReference, ct);
}
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
return new PlatformMessageRef(
PlatformKind.Telegram,
message.Group.ExternalGroupId,
message.Group.ExternalThreadId,
sentMessage.MessageId.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var existingMessage = message.ExistingMessage;
if (existingMessage is null)
{
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
}
EnsureTelegram(existingMessage.Platform);
if (!string.Equals(message.Group.ExternalGroupId, existingMessage.ExternalGroupId, StringComparison.Ordinal) ||
!string.Equals(message.Group.ExternalThreadId, existingMessage.ExternalThreadId, StringComparison.Ordinal))
{
throw new ArgumentException("Existing schedule message reference must match the schedule group.", nameof(message));
}
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
EnsureTelegram(group.Platform);
return bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(message.Recipient.ExternalUserId),
text: message.HtmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) =>
bot.AnswerCallbackQuery(
callbackQueryId: reply.InteractionId,
text: reply.Text,
showAlert: reply.ShowAlert,
cancellationToken: ct);
public async Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
EnsureTelegram(file.Group.Platform);
using var stream = new MemoryStream(file.Content);
await bot.SendDocument(
chatId: ParseLong(file.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(file.Group.ExternalThreadId),
document: InputFile.FromStream(stream, file.FileName),
caption: file.CaptionHtml,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(file.Actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct)
{
EnsureTelegram(request.Group.Platform);
var chatId = ParseLong(request.Group.ExternalGroupId);
var threadId = ParseNullableInt(request.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
{
var request = update.Request;
EnsureTelegram(request.Group.Platform);
var existingMessage = request.ExistingMessage
?? throw new ArgumentException("Existing confirmation message reference is required.", nameof(update));
EnsureTelegram(existingMessage.Platform);
await bot.EditMessageText(
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: update.DisableActions ? null : BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
PlatformJoinLinkNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Group.Platform);
var chatId = ParseLong(notification.Group.ExternalGroupId);
var threadId = ParseNullableInt(notification.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildJoinLinkText(notification),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public Task SendDirectSessionNotificationAsync(
PlatformDirectSessionNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(notification.Recipient.ExternalUserId),
text: BuildDirectNotificationText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
{
switch (notification.Kind)
{
case PlatformRsvpOutcomeKind.GroupAllConfirmed:
if (notification.Group is null)
{
throw new ArgumentException("Group notification requires a group.", nameof(notification));
}
EnsureTelegram(notification.Group.Platform);
await bot.SendMessage(
chatId: ParseLong(notification.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(notification.Group.ExternalThreadId),
text: $"🎉 Игра «{notification.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct);
break;
case PlatformRsvpOutcomeKind.GmAllConfirmed:
case PlatformRsvpOutcomeKind.GmPlayerDeclined:
foreach (var recipient in notification.Recipients)
{
EnsureTelegram(recipient.Platform);
await bot.SendMessage(
chatId: ParseLong(recipient.ExternalUserId),
text: BuildRsvpOutcomeDirectText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
break;
default:
throw new ArgumentOutOfRangeException(nameof(notification), notification.Kind, "Unknown RSVP outcome kind.");
}
}
public Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
{
EnsureTelegram(update.Group.Platform);
EnsureTelegram(update.ExistingMessage.Platform);
var resultText = update.SelectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {update.SelectedOption.DisplayOrder}: <b>{update.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(update.Decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
update.Title,
update.CurrentScheduledAt,
update.VotingDeadlineAt,
update.Options,
update.Participants,
update.Votes)}
{resultText}
""";
return bot.EditMessageText(
chatId: ParseLong(update.ExistingMessage.ExternalGroupId),
messageId: ParseInt(update.ExistingMessage.ExternalMessageId),
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
private async Task<Message> SendScheduleTextMessage(
long chatId,
int? threadId,
string text,
InlineKeyboardMarkup markup,
CancellationToken ct) =>
await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: markup,
cancellationToken: ct);
private static string BuildConfirmationText(PlatformConfirmationRequest request)
{
var confirmed = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
var pending = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
var lines = new List<string>
{
$"🎲 Подтвердите участие в «{System.Net.WebUtility.HtmlEncode(request.Title)}»",
$"📅 {request.ScheduledAt.FormatMoscow()} (МСК)",
string.Empty
};
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatTelegramParticipant(participant)}");
}
foreach (var participant in declined)
{
lines.Add($" ❌ <s>{FormatTelegramParticipant(participant)}</s>");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatTelegramParticipant(participant)}");
}
lines.Add(string.Empty);
if (request.Participants.Count > 0 && confirmed.Count == request.Participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{request.Participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{request.Participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{request.Participants.Count})");
}
return string.Join("\n", lines);
}
private static string BuildJoinLinkText(PlatformJoinLinkNotification notification)
{
var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(FormatTelegramParticipant));
return $"""
🎮 Игра «{notification.Title}» начинается через 5 минут!
🔗 Ссылка на подключение:
{notification.JoinLink}
Участники: {mentions}
Хорошей игры! 🎲
""";
}
private static string BuildDirectNotificationText(PlatformDirectSessionNotification notification) =>
notification.Kind switch
{
PlatformDirectSessionNotificationKind.ConfirmationRequest => $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""",
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.RescheduleApproved => $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Новое время: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
""",
PlatformDirectSessionNotificationKind.RescheduleRejected => $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Время остаётся прежним: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)}
""",
_ => BuildFallbackDirectText(notification)
};
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
private static string BuildRsvpOutcomeDirectText(PlatformRsvpOutcomeNotification notification) =>
notification.Kind switch
{
PlatformRsvpOutcomeKind.GmAllConfirmed =>
$"✅ Все подтвердили участие в «{System.Net.WebUtility.HtmlEncode(notification.Title)}» ({notification.ScheduledAt.FormatMoscow()} МСК).",
PlatformRsvpOutcomeKind.GmPlayerDeclined =>
$"🚨 Отмена! {System.Net.WebUtility.HtmlEncode(notification.ActorDisplayName ?? "Игрок")} не сможет прийти на игру «{System.Net.WebUtility.HtmlEncode(notification.Title)}».",
_ => System.Net.WebUtility.HtmlEncode(notification.Title)
};
private static InlineKeyboardMarkup BuildRsvpKeyboard(Guid sessionId) =>
new([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
]
]);
private static string FormatTelegramParticipant(PlatformSessionParticipant participant) =>
participant.User.ExternalUsername is not null
? $"@{participant.User.ExternalUsername}"
: System.Net.WebUtility.HtmlEncode(participant.User.DisplayName);
private async Task TrySendScheduleImageOnly(
long chatId,
int? threadId,
string title,
string imageReference,
CancellationToken ct)
{
try
{
await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for chat {ChatId}", chatId);
}
}
private static InlineKeyboardMarkup? BuildActionsMarkup(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return null;
}
return new InlineKeyboardMarkup(
actions.Select(action => new[]
{
Uri.TryCreate(action.Payload, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? InlineKeyboardButton.WithUrl(action.Label, action.Payload)
: InlineKeyboardButton.WithCallbackData(action.Label, action.Payload)
}));
}
private static void EnsureTelegram(PlatformKind platform)
{
if (platform != PlatformKind.Telegram)
{
throw new NotSupportedException($"Telegram messenger cannot send messages for platform {platform}.");
}
}
private static long ParseLong(string value) => long.Parse(value, CultureInfo.InvariantCulture);
private static int ParseInt(string value) => int.Parse(value, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, CultureInfo.InvariantCulture);
}
@@ -0,0 +1,63 @@
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public static class TelegramSessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
{
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
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))
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
}
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var actionRow = session.AvailableActions
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
.ToArray();
if (actionRow.Length > 0)
buttons.Add(actionRow);
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
}
@@ -0,0 +1,40 @@
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed record TelegramTopicDestination(
int? MessageThreadId,
bool ShouldCreateForumTopic,
bool TopicCreatedByBot);
public static class TelegramTopicRouting
{
public const string MissingForumTopicRightsMessage =
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите команду.";
public static TelegramTopicDestination ResolveNewScheduleDestination(
bool chatIsForum,
int? incomingMessageThreadId)
{
if (!chatIsForum)
{
return new TelegramTopicDestination(null, ShouldCreateForumTopic: false, TopicCreatedByBot: false);
}
if (incomingMessageThreadId.HasValue)
{
return new TelegramTopicDestination(
incomingMessageThreadId,
ShouldCreateForumTopic: false,
TopicCreatedByBot: false);
}
return new TelegramTopicDestination(null, ShouldCreateForumTopic: true, TopicCreatedByBot: true);
}
public static bool ShouldDeleteForumTopic(bool topicCreatedByBot, int remainingSessionsInTopic) =>
topicCreatedByBot && remainingSessionsInTopic == 0;
public static bool IsMissingForumTopicRightsError(string apiError) =>
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
}
@@ -1,7 +1,8 @@
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.ListSessions;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -42,17 +43,26 @@ public sealed class UpdateRouter(
await HandleCallbackQueryAsync(query, ct);
break;
case { Message: { Text: { } text } message } when text.StartsWith('/'):
await HandleCommandAsync(message, text, ct);
break;
case { Message: { } message }:
var commandText = GetCommandText(message);
if (commandText.StartsWith("/", StringComparison.Ordinal))
{
await HandleCommandAsync(message, commandText, ct);
break;
}
if (message.Text is not null)
{
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
}
// Non-command text messages — check for reschedule time input
case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'):
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
break;
}
}
internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{
if (query.Data is not { } data || query.Message is not { } message)
@@ -60,18 +70,22 @@ public sealed class UpdateRouter(
var parts = data.Split(':', 3);
var action = parts[0];
var user = TelegramPlatformIds.User(
query.From.Id,
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
query.From.Username);
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
{
var command = new JoinSessionCommand(
SessionId: joinSessionId,
TelegramUserId: query.From.Id,
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
TelegramUsername: query.From.Username,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
await joinSessionHandler.HandleAsync(command, ct);
return;
}
@@ -80,10 +94,10 @@ public sealed class UpdateRouter(
{
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
await leaveSessionHandler.HandleAsync(command, ct);
return;
@@ -96,6 +110,7 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId);
await cancelSessionHandler.HandleAsync(command, ct);
@@ -135,6 +150,7 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId);
await initiateRescheduleHandler.HandleAsync(command, ct);
@@ -171,11 +187,11 @@ public sealed class UpdateRouter(
var command = new HandleRsvpCommand(
SessionId: sessionId,
TelegramUserId: query.From.Id,
User: user,
Status: status,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
InteractionId: query.Id,
Group: group,
ConfirmationMessage: scheduleMessage);
await rsvpHandler.HandleAsync(command, ct);
}
@@ -216,14 +232,15 @@ public sealed class UpdateRouter(
Время: 15.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
/help эта справка
""",
cancellationToken: ct);
@@ -0,0 +1,11 @@
CREATE TABLE calendar_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT UNIQUE NOT NULL,
user_telegram_id BIGINT NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
filter_type SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX ix_calendar_subscriptions_user_telegram_id ON calendar_subscriptions (user_telegram_id);
@@ -0,0 +1,66 @@
-- =============================================================
-- Attendance statistics view for GM analytics
-- Returns per-player aggregated metrics for a given game group.
-- NOTE: waitlist count reflects CURRENT registration_status only.
-- Full historical waitlist tracking will come with #15.
-- =============================================================
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
RETURNS TABLE (
player_id UUID,
display_name VARCHAR,
telegram_username VARCHAR,
total_sessions BIGINT,
confirmed_count BIGINT,
declined_count BIGINT,
no_response_count BIGINT,
waitlisted_count BIGINT,
cancellation_affected_count BIGINT,
attendance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
WITH player_sessions AS (
SELECT
sp.player_id,
s.id AS session_id,
sp.rsvp_status,
sp.registration_status,
s.status AS session_status,
s.scheduled_at
FROM session_participants sp
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = p_group_id
),
player_totals AS (
SELECT
ps.player_id,
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
FROM player_sessions ps
GROUP BY ps.player_id
)
SELECT
pt.player_id,
p.display_name,
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
pt.total_sessions,
pt.confirmed_count,
pt.declined_count,
pt.no_response_count,
pt.waitlisted_count,
pt.cancellation_affected_count,
ROUND(
100.0 * pt.confirmed_count
/ NULLIF(pt.total_sessions, 0),
1
) AS attendance_rate
FROM player_totals pt
JOIN players p ON p.id = pt.player_id
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
END;
$$ LANGUAGE plpgsql STABLE;
@@ -0,0 +1,16 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE session_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
actor_telegram_id BIGINT NOT NULL,
actor_name VARCHAR(255) NOT NULL,
change_type VARCHAR(50) NOT NULL
CHECK (change_type IN ('Title','Time','Link','MaxPlayers','Status','WaitlistPromote','PlayerRemoved','BatchRescheduled','Cancelled')),
old_value TEXT,
new_value TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_session_audit_log_session_id ON session_audit_log(session_id);
CREATE INDEX ix_session_audit_log_changed_at ON session_audit_log(changed_at);
@@ -0,0 +1,13 @@
ALTER TABLE sessions
ADD COLUMN confirmation_sent_at TIMESTAMPTZ;
-- Update existing ConfirmationSent sessions to have a sentinel value
-- so they don't get re-processed after migration
UPDATE sessions
SET confirmation_sent_at = now()
WHERE status = 'ConfirmationSent';
-- Partial index for efficient T-24h query
CREATE INDEX ix_sessions_confirmation_reminders ON sessions (scheduled_at)
WHERE status = 'Planned'
AND confirmation_sent_at IS NULL;
@@ -0,0 +1,6 @@
ALTER TABLE sessions
ADD COLUMN topic_created_by_bot BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE sessions
SET topic_created_by_bot = TRUE
WHERE thread_id IS NOT NULL;
@@ -0,0 +1,119 @@
-- =============================================================
-- V016: Add platform identity columns and platform_messages table
-- =============================================================
-- Scope: Prepare schema for multi-platform support (Discord, etc).
-- Legacy telegram_* columns are retained for backward compatibility.
-- =============================================================
-- -- Players: platform-agnostic identity
ALTER TABLE players
ADD COLUMN platform VARCHAR(50),
ADD COLUMN external_user_id VARCHAR(255),
ADD COLUMN external_username VARCHAR(255);
CREATE UNIQUE INDEX ix_players_platform_external_user_id
ON players (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL;
-- -- Game groups: platform-agnostic identity
ALTER TABLE game_groups
ADD COLUMN platform VARCHAR(50),
ADD COLUMN external_group_id VARCHAR(255),
ADD COLUMN external_channel_id VARCHAR(255);
CREATE UNIQUE INDEX ix_game_groups_platform_external_group_id
ON game_groups (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL;
-- -- Backfill existing Telegram data
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- -- Platform messages: store per-platform message references
CREATE TABLE platform_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(50) NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
batch_id UUID,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
external_channel_id VARCHAR(255),
external_thread_id VARCHAR(255),
external_message_id VARCHAR(255) NOT NULL,
purpose VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_platform_messages_group_id ON platform_messages(group_id);
CREATE INDEX ix_platform_messages_batch_id ON platform_messages(batch_id);
CREATE INDEX ix_platform_messages_session_id ON platform_messages(session_id);
CREATE INDEX ix_platform_messages_platform_message
ON platform_messages (platform, external_message_id);
-- -- Recreate attendance stats function for new columns (prod back-compat)
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
RETURNS TABLE (
player_id UUID,
display_name VARCHAR,
telegram_username VARCHAR,
total_sessions BIGINT,
confirmed_count BIGINT,
declined_count BIGINT,
no_response_count BIGINT,
waitlisted_count BIGINT,
cancellation_affected_count BIGINT,
attendance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
WITH player_sessions AS (
SELECT
sp.player_id,
s.id AS session_id,
sp.rsvp_status,
sp.registration_status,
s.status AS session_status,
s.scheduled_at
FROM session_participants sp
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = p_group_id
),
player_totals AS (
SELECT
ps.player_id,
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
FROM player_sessions ps
GROUP BY ps.player_id
)
SELECT
pt.player_id,
p.display_name,
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
pt.total_sessions,
pt.confirmed_count,
pt.declined_count,
pt.no_response_count,
pt.waitlisted_count,
pt.cancellation_affected_count,
ROUND(
100.0 * pt.confirmed_count
/ NULLIF(pt.total_sessions, 0),
1
) AS attendance_rate
FROM player_totals pt
JOIN players p ON p.id = pt.player_id
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
END;
$$ LANGUAGE plpgsql STABLE;
@@ -0,0 +1,9 @@
-- =============================================================
-- V017: Allow platform-neutral players
-- =============================================================
-- Legacy Telegram identity columns remain for backward compatibility,
-- but non-Telegram platform users do not have Telegram ids.
-- =============================================================
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
@@ -0,0 +1,19 @@
-- =============================================================
-- V018: Add platform columns to reschedule_proposals
-- =============================================================
-- Add platform columns to reschedule_proposals to support Discord reschedule voting.
-- proposed_by is made nullable so Discord proposals can leave it NULL
-- (Discord snowflakes don't fit in BIGINT safely).
-- =============================================================
ALTER TABLE reschedule_proposals
ALTER COLUMN proposed_by DROP NOT NULL;
ALTER TABLE reschedule_proposals
ADD COLUMN source_platform VARCHAR(50),
ADD COLUMN proposed_by_external_user_id VARCHAR(255);
UPDATE reschedule_proposals
SET source_platform = 'Telegram',
proposed_by_external_user_id = proposed_by::TEXT
WHERE source_platform IS NULL;
+25 -7
View File
@@ -1,14 +1,18 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Health;
using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -49,14 +53,20 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
return new TelegramBotClient(token);
});
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
// ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
@@ -67,6 +77,7 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.Expor
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
@@ -74,10 +85,17 @@ builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredServic
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>();
// ── Clock and scheduling ──────────────────────────────────────────────
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
// ── Health check server ──────────────────────────────────────────────
builder.Services.AddHostedService<BotHealthCheckHostedService>();
var host = builder.Build();
// ── Run database migrations on startup ───────────────────────────────
+3
View File
@@ -9,5 +9,8 @@
"Telegram": {
"BotToken": "",
"MiniAppUrl": ""
},
"Web": {
"BaseUrl": ""
}
}
+695
View File
@@ -0,0 +1,695 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Aspire.Npgsql": {
"type": "Direct",
"requested": "[13.2.2, )",
"resolved": "13.2.2",
"contentHash": "nEYgziWN7hksgEQEWy24JypcMCU8gKYcIIyPL05JfdXxUWuPRLotH/KOeuHevAjSEOYkL3dtGakBkJAuPobGmA==",
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"Npgsql.DependencyInjection": "10.0.1",
"Npgsql.OpenTelemetry": "10.0.1",
"OpenTelemetry.Extensions.Hosting": "1.15.0"
}
},
"Dapper": {
"type": "Direct",
"requested": "[2.1.72, )",
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Dapper.AOT": {
"type": "Direct",
"requested": "[1.0.48, )",
"resolved": "1.0.48",
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
},
"dbup-postgresql": {
"type": "Direct",
"requested": "[7.0.1, )",
"resolved": "7.0.1",
"contentHash": "mRnmENWWPuuMZ538gOd1mZnzucx6FQk0anmw3EABjGfcbp24FDb9QdGepYrDiaM8K9s5/gd49+5cmBOlniH/lg==",
"dependencies": {
"Npgsql": "10.0.1",
"dbup-core": "6.1.1"
}
},
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Logging.Console": "10.0.5",
"Microsoft.Extensions.Logging.Debug": "10.0.5",
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "A+5ZuQ0f449tM+MQrhf6R9ZX7lYpjk/ODEwLYKrnF6111rtARx8fVsm4YznUnQiKnnXfaXNBqgxmil6RW3L3SA=="
},
"Npgsql": {
"type": "Direct",
"requested": "[10.0.2, )",
"resolved": "10.0.2",
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Telegram.Bot": {
"type": "Direct",
"requested": "[22.9.5.3, )",
"resolved": "22.9.5.3",
"contentHash": "7u8rZU9Vx9XEyIm6pB+dAlITsi1v63I+hKo7IEXGiQZnVjzvZgPs9yDCP17/Cwm7lgjCNEqknlbv/yoBnsUYFw==",
"dependencies": {
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
}
},
"AspNetCore.HealthChecks.NpgSql": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Npgsql": "8.0.3"
}
},
"dbup-core": {
"type": "Transitive",
"resolved": "6.1.1",
"contentHash": "kgpuyJVEFJHoIj/slnc994Go88aoeZqNDfGHDBr4sh7CsEWwJhOTCt/FJqO4ziUImL5L0NEY0kxxOiNgPKI2Fw==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "CNrEjaOCZ8d1HtB0mvpiX4EWxLkee2xy+CsYXxmsEYJSFgw3OmF9pIhP/tCTeYBHhpsKJj5wM63G8IBFGxAcsw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.2",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2"
}
},
"Microsoft.Extensions.Compliance.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "1a4xDAT6fRyP8t419q3WvWMmMslDTvI7OAZLWBhn5rysFG0bl5xFenTswd1xAbT/3u3mx4Xyb5bPx+V+18tJeQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
},
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Z/OI261l7LnxyODKPx0trQyIHFyicCR/akfn64lGOjPcf4FpAZ7ePAGl2HPvQBUBSNfPTF0gWeCfuFmyftMgYA==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "3qMK1D40D10kb5TdBtFJpzz6/WH0NinWs68ZZS8jCFgHMXDiOjGiPOneMmIocCP/wnUUW4Hzf8lMsIE1xIGxDA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
},
"Microsoft.Extensions.Features": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "X7tm2aV2w3lN9roSSGhl19lz4w76HvdiuKNhIv2XOiorYII9XCm66o/z9IJ0+QwkgvEv5gMZDM6rV6uwABHEQQ=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "egUPC0xydb1ugCMcRyJ6zaOGOzx7N4coOVlGeLcIsXhUf1xHHwZeX+ob7JuG0dXExFduHYE/t+4/4y8BLlBKmw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Logging": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"Microsoft.Extensions.Http.Diagnostics": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "I0FBgF6yZRwYH9E3KQ2vHm80YZ7YBj+52GDsmOWXPBv/p15b/wUoNupV9kw3LnSNVsWMqlGbiuZgBnHpMwPh+Q==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.Telemetry": "10.2.0"
}
},
"Microsoft.Extensions.Http.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Lg+OjBW+ODDbM4Ax4LoERvQ1dqSZ8I2gQc2+B0/WOWl2+PunLJ3xb3x8MtHGfcb/Mp98RoMpwRKm6Aj9mzXwrA==",
"dependencies": {
"Microsoft.Extensions.Http.Diagnostics": "10.2.0",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Resilience": "10.2.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"System.Diagnostics.EventLog": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
},
"Microsoft.Extensions.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "v4WOdAOFxB3AcsUkZWNcHL3mYzs4KAPtHO8rkoQlFKOBoD3KyjjAL+h3tRwSK5i4UpF/yhxsQRY0JxKj4osxxw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.2.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0",
"Polly.Extensions": "8.4.2",
"Polly.RateLimiting": "8.4.2"
}
},
"Microsoft.Extensions.ServiceDiscovery": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "AHTPfiKodj66xA8RwRkFD4q11V2AvzcuDsujv6ViPkOPtvBEYcPVplHakK56pPzWlX08MDS+TAQXfFXAeP7J5w==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.ServiceDiscovery.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "sANlOvfqfw/yfych4CLlHSKSWzIie6mQG7w83gVur1foNOafyHxcgpoQMvBf+KiB4Tpls6P1/Z77IIQSK8hxFg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.Configuration.Binder": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Features": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2",
"Microsoft.Extensions.Primitives": "10.0.2"
}
},
"Microsoft.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "ssW5gosYlewNH/ISTyaLD/XfJT4GSjwShOUKv61fpXrqVmHkhuIA/5bBAGStM1XbzJjt9IG2vzfdHTu4zlX9Ew==",
"dependencies": {
"Microsoft.Extensions.AmbientMetadata.Application": "10.2.0",
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.2.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.Telemetry.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "6V4V6NX6RLUYWwV89DeW/4zK5xOycYHWhsfMXSpKVGgMHfXcczmbk6hBeqTnRPzhpATYcOWlmA6hk1jgdxUugA==",
"dependencies": {
"Microsoft.Extensions.Compliance.Abstractions": "10.2.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"Npgsql.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Npgsql": "10.0.1"
}
},
"Npgsql.OpenTelemetry": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==",
"dependencies": {
"Npgsql": "10.0.1",
"OpenTelemetry.API": "1.14.0"
}
},
"OpenTelemetry": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
}
},
"OpenTelemetry.Api": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
},
"OpenTelemetry.Api.ProviderBuilderExtensions": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
"OpenTelemetry.Api": "1.15.3"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
"dependencies": {
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Extensions.Hosting": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Instrumentation.AspNetCore": {
"type": "Transitive",
"resolved": "1.15.2",
"contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Http": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.0",
"Microsoft.Extensions.Options": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Runtime": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
"dependencies": {
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
},
"Polly.Extensions": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Polly.Core": "8.4.2"
}
},
"Polly.RateLimiting": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
"dependencies": {
"Polly.Core": "8.4.2",
"System.Threading.RateLimiting": "8.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
},
"System.Threading.RateLimiting": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
},
"gmrelay.servicedefaults": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Http.Resilience": "[10.2.0, )",
"Microsoft.Extensions.ServiceDiscovery": "[10.2.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
"OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )"
}
},
"gmrelay.shared": {
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
}
},
"net10.0/win-x64": {
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg==",
"dependencies": {
"runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.5"
}
},
"runtime.win-x64.Microsoft.DotNet.ILCompiler": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vblLkpVhSDYOmrEW0jypX7YVtLg7idU1QzUyx45ZdZ2sFUSSf3mYFCr0FW3+KZgXWpN1ve9ZPrxNywvHISF4bA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace GmRelay.DiscordBot;
public sealed class DiscordOptions
{
public string? Token { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Token))
{
throw new InvalidOperationException(
"Discord:Token is required. Set via environment variable Discord__Token or user secrets.");
}
}
}
+25
View File
@@ -0,0 +1,25 @@
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
WORKDIR /src
COPY ["src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", "src/GmRelay.DiscordBot/"]
COPY ["src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj", "src/GmRelay.ServiceDefaults/"]
COPY ["src/GmRelay.Shared/GmRelay.Shared.csproj", "src/GmRelay.Shared/"]
RUN dotnet restore "src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj"
COPY src/ src/
WORKDIR /src/src/GmRelay.DiscordBot
RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final
WORKDIR /app
# Install wget for healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"]
@@ -0,0 +1,38 @@
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
public async Task ExecuteAsync()
{
var guildId = Context.Guild?.Id.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
if (view is null)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
return;
}
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
}
@@ -0,0 +1,65 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
{
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
{
GuildId = guildId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionList = sessions.ToList();
if (sessionList.Count == 0)
return null;
var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC",
new { SessionIds = sessionIds });
var firstTitle = sessionList.First().Title;
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
}
}
@@ -0,0 +1,87 @@
using GmRelay.DiscordBot.Rendering;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("newsession", "Create a new game session")]
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;
}
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)
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"X {timeResult.Error}"));
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
try
{
var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
title: title,
scheduledAt: timeResult.Value,
maxPlayers: seats is null ? null : (int)seats.Value,
joinLink: link,
CancellationToken.None);
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithContent(":white_check_mark: **Session created successfully!**")
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guild.Id);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: An error occurred while creating the session."));
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -0,0 +1,148 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
{
if (result < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
{
if (altResult < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), 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,
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 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 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);
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 (@GuildId, '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 { 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);
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
@@ -0,0 +1,117 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordRescheduleHandler _handler;
private readonly ILogger<DiscordRescheduleCommand> _logger;
public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger<DiscordRescheduleCommand> logger)
{
_handler = handler;
_logger = logger;
}
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
[SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2,
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
if (!Guid.TryParse(sessionIdText, out var sessionId))
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Некорректный ID сессии."));
return;
}
var options = new List<string> { option1, option2 };
if (!string.IsNullOrWhiteSpace(option3))
options.Add(option3);
var parsedOptions = new List<DateTimeOffset>();
foreach (var opt in options)
{
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
if (!result.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {opt}: {result.Error}"));
return;
}
parsedOptions.Add(result.Value);
}
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
if (!deadlineResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}"));
return;
}
if (deadlineResult.Value >= parsedOptions.Min())
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени."));
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
try
{
var result = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
sessionId: sessionId,
options: parsedOptions,
deadline: deadlineResult.Value,
CancellationToken.None);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC."));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
}
catch (InvalidOperationException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":warning: {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: Ошибка при запуске голосования."));
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -0,0 +1,155 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using NetCord;
using NetCord.Rest;
using Npgsql;
public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList<RescheduleOptionDto> Options, DateTimeOffset Deadline);
public sealed class DiscordRescheduleHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
RestClient restClient,
ILogger<DiscordRescheduleHandler> logger)
{
public async Task<DiscordRescheduleResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
IReadOnlyList<DateTimeOffset> options,
DateTimeOffset deadline,
CancellationToken ct)
{
// 1. Permission check + read-only validation (before Discord message)
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var dbManagerUserIds = await readConnection.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 g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии.");
}
// 2. Ensure player exists
await readConnection.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",
new { Name = userDisplayName, UserId = userId.ToString() });
// 3. Verify session exists
var session = await readConnection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
"""
SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled });
if (session is null)
throw new InvalidOperationException("Сессия не найдена или отменена.");
// 4. Check no active proposal
var hasActive = await readConnection.ExecuteScalarAsync<bool>(
"SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))",
new { SessionId = sessionId });
if (hasActive)
throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии.");
// 5. Load participants for rendering
var participants = (await readConnection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
// 6. Prepare proposal data
var proposalId = Guid.NewGuid();
var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList();
// 7. Build and send Discord vote message BEFORE transaction
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []);
var channelIdUlong = ulong.Parse(channelId);
// NOTE: Discord message is sent before DB transaction to avoid orphaned proposals
// if the send fails. There is a negligible race window where the message is visible
// before the DB commit; in practice users cannot click faster than the transaction commits.
var sentMessage = await restClient.SendMessageAsync(
channelIdUlong,
new MessageProperties()
.WithEmbeds(new[] { embed })
.WithComponents(new[] { actionRow }));
// 8. Create proposal + options + platform_messages in transaction
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at)
VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline)
""",
new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime },
transaction);
foreach (var option in optionDtos)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder },
transaction);
}
await connection.ExecuteAsync(
"""
INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose)
VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote')
""",
new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() },
transaction);
await transaction.CommitAsync(ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message");
try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ }
throw;
}
logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId);
return new DiscordRescheduleResult(proposalId, optionDtos, deadline);
}
}
internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt);
@@ -0,0 +1,131 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId,
string GuildId, string ChannelId, string MessageId);
public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource,
RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + option
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
if (proposal is null)
return "Голосование уже завершено или не найдено.";
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId);
try
{
await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options =>
{
options.Embeds = new[] { embed };
options.Components = new[] { actionRow };
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
}
return "Ваш голос учтён. До дедлайна его можно изменить.";
}
}
@@ -0,0 +1,196 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
public sealed class DiscordRescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
RescheduleVotingFinalizer finalizer,
IPlatformMessenger messenger,
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await ProcessDueProposals(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
private async Task ProcessDueProposals(CancellationToken ct)
{
try
{
var proposalIds = await finalizer.GetDueProposalIdsAsync("Discord", ct);
foreach (var id in proposalIds)
{
await TryFinalizeAsync(id, ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process Discord reschedule proposals");
}
}
private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct)
{
try
{
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
if (result.SourcePlatform != "Discord")
return;
await TryUpdateDiscordVoteMessage(result, ct);
if (result.SelectedOption is not null)
{
await TryUpdateBatchScheduleAsync(result, ct);
}
logger.LogInformation(
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposalId,
result.SessionId,
result.Decision.Outcome);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId);
}
}
private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var msgRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
"""
SELECT g.external_group_id AS ExternalGroupId,
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
pm.external_message_id AS ExternalMessageId
FROM platform_messages pm
JOIN game_groups g ON g.id = pm.group_id
WHERE pm.session_id = @SessionId AND pm.purpose = 'reschedule_vote' AND pm.platform = 'Discord'
ORDER BY pm.created_at DESC
LIMIT 1
""",
new { result.SessionId });
if (msgRef is null)
return;
var group = CreateDiscordGroup(msgRef);
await messenger.UpdateRescheduleVoteAsync(
new PlatformRescheduleVoteUpdate(
group,
new PlatformMessageRef(
PlatformKind.Discord,
msgRef.ExternalGroupId,
null,
msgRef.ExternalMessageId),
result.ProposalId,
result.SessionId,
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Decision,
result.SelectedOption,
result.Options,
result.Votes,
result.Participants),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId);
}
}
private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
"""
SELECT g.external_group_id AS ExternalGroupId,
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
pm.external_message_id AS ExternalMessageId
FROM platform_messages pm
JOIN game_groups g ON g.id = pm.group_id
WHERE pm.batch_id = @BatchId AND pm.purpose = 'schedule' AND pm.platform = 'Discord'
ORDER BY pm.created_at DESC
LIMIT 1
""",
new { result.BatchId });
if (batchRef is null)
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",
new { result.BatchId })).ToList();
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC
""",
new { result.BatchId })).ToList();
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
var group = CreateDiscordGroup(batchRef);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
group,
view,
new PlatformMessageRef(
PlatformKind.Discord,
batchRef.ExternalGroupId,
null,
batchRef.ExternalMessageId)),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId);
}
}
private static PlatformGroup CreateDiscordGroup(PlatformMessageRefDto message) =>
new(
PlatformKind.Discord,
message.ExternalGroupId,
message.ExternalGroupId,
message.ExternalChannelId);
internal sealed record PlatformMessageRefDto(
string ExternalGroupId,
string ExternalChannelId,
string ExternalMessageId);
}
@@ -0,0 +1,64 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordSessionInteractionInput(
Guid SessionId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId,
ulong UserId,
string Username,
string? DisplayName);
public static class DiscordSessionInteractionMapper
{
public static bool TryParseCustomId(string customId, string expectedAction, out Guid sessionId)
{
sessionId = default;
var parts = customId.Split(':', 2);
return parts.Length == 2
&& string.Equals(parts[0], expectedAction, StringComparison.Ordinal)
&& Guid.TryParse(parts[1], out sessionId);
}
public static JoinSessionCommand CreateJoinCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
public static LeaveSessionCommand CreateLeaveCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
private static PlatformUser CreateUser(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.UserId.ToString(System.Globalization.CultureInfo.InvariantCulture),
string.IsNullOrWhiteSpace(input.DisplayName) ? input.Username : input.DisplayName,
input.Username);
private static PlatformGroup CreateGroup(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
input.GuildId,
input.ChannelId);
private static PlatformMessageRef CreateMessageRef(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
null,
input.MessageId);
}
@@ -0,0 +1,201 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using System.Globalization;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ComponentInteractions;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
HandleRsvpHandler rsvpHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
{
[ComponentInteraction("join_session")]
public async Task JoinAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("leave_session")]
public async Task LeaveAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("rsvp")]
public async Task RsvpAsync(string status, string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var rsvpStatus = status switch
{
"confirm" => RsvpStatus.Confirmed,
"decline" => RsvpStatus.Declined,
_ => null
};
if (rsvpStatus is null)
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await rsvpHandler.HandleAsync(
new HandleRsvpCommand(
parsedSessionId,
new PlatformUser(
PlatformKind.Discord,
Context.User.Id.ToString(CultureInfo.InvariantCulture),
string.IsNullOrWhiteSpace(Context.User.GlobalName) ? Context.User.Username : Context.User.GlobalName,
Context.User.Username),
rsvpStatus,
input.InteractionId,
new PlatformGroup(
PlatformKind.Discord,
input.GuildId,
input.GuildId,
input.ChannelId),
new PlatformMessageRef(
PlatformKind.Discord,
input.GuildId,
null,
input.MessageId)),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("reschedule_vote")]
public async Task RescheduleVoteAsync(string optionId)
{
if (!Guid.TryParse(optionId, out var parsedOptionId))
{
await RespondAsync(CreateEphemeralReply("Vote button is outdated."));
return;
}
var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing
var voteInput = new DiscordRescheduleVoteInput(
parsedOptionId,
Context.User.Id,
Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
input.GuildId,
input.ChannelId,
input.MessageId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
string replyText;
try
{
replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId);
await CompleteResponseAsync("Не удалось обработать голос.");
return;
}
await CompleteResponseAsync(replyText);
}
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
{
var guild = Context.Guild
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
var message = Context.Interaction.Message
?? throw new InvalidOperationException("Session button interaction must include a message.");
return new DiscordSessionInteractionInput(
SessionId: sessionId,
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
GuildId: guild.Id.ToString(CultureInfo.InvariantCulture),
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
UserId: Context.User.Id,
Username: Context.User.Username,
DisplayName: Context.User.GlobalName);
}
private async Task CompleteWithStoredReplyAsync(string interactionId)
{
var reply = interactionReplies.Take(interactionId);
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
}
private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text);
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Services" Version="1.0.0-alpha.489" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,17 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordInteractionReplyCache
{
private readonly ConcurrentDictionary<string, PlatformInteractionReply> replies = new(StringComparer.Ordinal);
public void Store(PlatformInteractionReply reply) =>
replies[reply.InteractionId] = reply;
public PlatformInteractionReply? Take(string interactionId) =>
replies.TryRemove(interactionId, out var reply)
? reply
: null;
}
@@ -0,0 +1,22 @@
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPermissionChecker
{
private const ulong AdministratorPermission = 0x8;
public bool CanManageSchedule(
ulong guildOwnerId,
ulong userId,
IEnumerable<ulong> dbManagerUserIds,
ulong resolvedPermissions)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
}
}
@@ -0,0 +1,372 @@
using System.Globalization;
using System.Text;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger : IPlatformMessenger
{
private readonly RestClient restClient;
private readonly DiscordInteractionReplyCache interactionReplies;
private readonly ILogger<DiscordPlatformMessenger>? logger;
public DiscordPlatformMessenger(
RestClient restClient,
DiscordInteractionReplyCache interactionReplies)
: this(restClient, interactionReplies, logger: null)
{
}
public DiscordPlatformMessenger(
RestClient restClient,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordPlatformMessenger>? logger)
{
this.restClient = restClient;
this.interactionReplies = interactionReplies;
this.logger = logger;
}
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = GetChannelId(message.Group);
var msg = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows));
return new PlatformMessageRef(
PlatformKind.Discord,
message.Group.ExternalGroupId,
null,
msg.Id.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
if (message.ExistingMessage is null)
return;
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = GetChannelId(message.Group);
var messageId = ParseSnowflake(message.ExistingMessage.ExternalMessageId);
await restClient.ModifyMessageAsync(
channelId,
messageId,
options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
}
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
interactionReplies.Store(reply);
return Task.CompletedTask;
}
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
return Task.CompletedTask;
}
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(
PlatformConfirmationRequest request,
CancellationToken ct)
{
var channelId = GetChannelId(request.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds([BuildConfirmationEmbed(request)])
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
return new PlatformMessageRef(
PlatformKind.Discord,
request.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
{
if (update.Request.ExistingMessage is null)
return;
var channelId = GetChannelId(update.Request.Group);
var messageId = ParseSnowflake(update.Request.ExistingMessage.ExternalMessageId);
var components = BuildRsvpRows(update.Request.SessionId, update.DisableActions);
await restClient.ModifyMessageAsync(
channelId,
messageId,
options =>
{
options.Embeds = [BuildConfirmationEmbed(update.Request)];
options.Components = components;
});
}
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
PlatformJoinLinkNotification notification,
CancellationToken ct)
{
var channelId = GetChannelId(notification.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
return new PlatformMessageRef(
PlatformKind.Discord,
notification.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
public async Task SendDirectSessionNotificationAsync(
PlatformDirectSessionNotification notification,
CancellationToken ct)
{
try
{
await SendDirectContentAsync(
notification.Recipient,
BuildDirectContent(notification),
ct);
}
catch (Exception ex)
{
logger?.LogWarning(
ex,
"Failed to send Discord direct notification {NotificationKind} for session {SessionId} to user {ExternalUserId}",
notification.Kind,
notification.SessionId,
notification.Recipient.ExternalUserId);
}
}
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
{
if (notification.Kind == PlatformRsvpOutcomeKind.GroupAllConfirmed && notification.Group is not null)
{
await restClient.SendMessageAsync(
GetChannelId(notification.Group),
BuildRsvpGroupOutcomeContent(notification));
return;
}
var directKind = notification.Kind == PlatformRsvpOutcomeKind.GmPlayerDeclined
? PlatformDirectSessionNotificationKind.RsvpDeclined
: PlatformDirectSessionNotificationKind.RsvpAllConfirmed;
foreach (var recipient in notification.Recipients)
{
await SendDirectSessionNotificationAsync(
new PlatformDirectSessionNotification(
directKind,
recipient,
notification.SessionId,
notification.Title,
notification.ScheduledAt,
ActorDisplayName: notification.ActorDisplayName),
ct);
}
}
public async Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
{
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
update.Title,
update.CurrentScheduledAt,
update.VotingDeadlineAt,
update.Options,
update.Participants,
update.Votes);
var disabledRow = new ActionRowProperties();
foreach (var button in actionRow.OfType<ButtonProperties>())
{
disabledRow.Add(new ButtonProperties(
button.CustomId,
button.Label ?? string.Empty,
ButtonStyle.Secondary)
{
Disabled = true
});
}
var updatedEmbed = embed.WithDescription(
$"{embed.Description}\n\n{BuildRescheduleResultText(update)}");
await restClient.ModifyMessageAsync(
GetChannelId(update.Group),
ParseSnowflake(update.ExistingMessage.ExternalMessageId),
options =>
{
options.Embeds = [updatedEmbed];
options.Components = [disabledRow];
});
}
private static EmbedProperties BuildConfirmationEmbed(PlatformConfirmationRequest request)
{
var embed = new EmbedProperties()
.WithTitle($"Подтверждение: {request.Title}")
.WithDescription(BuildConfirmationDescription(request))
.WithColor(new Color(0x5865F2));
return embed.AddFields(
[
BuildParticipantField("Подтвердили", request.Participants, RsvpStatus.Confirmed),
BuildParticipantField("Отказались", request.Participants, RsvpStatus.Declined),
BuildParticipantField("Ожидаем ответ", request.Participants, RsvpStatus.Pending)
]);
}
private static string BuildConfirmationDescription(PlatformConfirmationRequest request) =>
$"Время: **{request.ScheduledAt.FormatMoscow()}** (МСК)\n" +
"Подтвердите участие кнопкой ниже.";
private static EmbedFieldProperties BuildParticipantField(
string title,
IReadOnlyList<PlatformSessionParticipant> participants,
string status)
{
var values = participants
.Where(participant => participant.RsvpStatus == status)
.Select(FormatDiscordParticipant)
.ToList();
return new EmbedFieldProperties()
.WithName(title)
.WithValue(values.Count == 0 ? "—" : string.Join("\n", values))
.WithInline();
}
private static EmbedProperties BuildJoinLinkEmbed(PlatformJoinLinkNotification notification)
{
var mentions = notification.ConfirmedPlayers.Count == 0
? "—"
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
return new EmbedProperties()
.WithTitle($"Ссылка на игру: {notification.Title}")
.WithDescription(
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
$"Ссылка: {notification.JoinLink}\n\n" +
$"Участники: {mentions}")
.WithUrl(notification.JoinLink)
.WithColor(new Color(0x57F287));
}
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
{
var row = new ActionRowProperties();
row.Add(new ButtonProperties($"rsvp:confirm:{sessionId}", "Буду", ButtonStyle.Success)
{
Disabled = disabled
});
row.Add(new ButtonProperties($"rsvp:decline:{sessionId}", "Не смогу", ButtonStyle.Danger)
{
Disabled = disabled
});
return [row];
}
private static string BuildDirectContent(PlatformDirectSessionNotification notification)
{
var builder = new StringBuilder();
builder.AppendLine(notification.Kind switch
{
PlatformDirectSessionNotificationKind.ConfirmationRequest => "Нужно подтвердить участие",
PlatformDirectSessionNotificationKind.OneHourReminder => "Напоминание: сессия через час",
PlatformDirectSessionNotificationKind.JoinLink => "Ссылка на игру",
PlatformDirectSessionNotificationKind.RsvpAllConfirmed => "Все игроки подтвердили участие",
PlatformDirectSessionNotificationKind.RsvpDeclined => "Игрок отказался от участия",
PlatformDirectSessionNotificationKind.RescheduleApproved => "Сессия перенесена",
PlatformDirectSessionNotificationKind.RescheduleRejected => "Перенос сессии отклонен",
_ => "Уведомление по сессии"
});
builder.AppendLine();
builder.AppendLine($"**{notification.Title}**");
builder.AppendLine($"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)");
if (!string.IsNullOrWhiteSpace(notification.JoinLink))
builder.AppendLine($"Ссылка: {notification.JoinLink}");
if (!string.IsNullOrWhiteSpace(notification.ActorDisplayName))
builder.AppendLine($"Игрок: {notification.ActorDisplayName}");
if (!string.IsNullOrWhiteSpace(notification.Reason))
builder.AppendLine($"Причина: {notification.Reason}");
return builder.ToString();
}
private static string BuildRsvpGroupOutcomeContent(PlatformRsvpOutcomeNotification notification) =>
$"Все участники подтвердили сессию **{notification.Title}** на " +
$"**{notification.ScheduledAt.FormatMoscow()}** (МСК).";
private static string BuildRescheduleResultText(PlatformRescheduleVoteUpdate update)
{
if (update.SelectedOption is not null)
{
return "Голосование завершено. " +
$"Победил вариант {update.SelectedOption.DisplayOrder}: " +
$"**{update.SelectedOption.ProposedAt.FormatMoscow()}** (МСК).";
}
return $"Голосование завершено. {update.Decision.Reason}";
}
private async Task SendDirectContentAsync(PlatformUser recipient, string content, CancellationToken ct)
{
var userId = ParseSnowflake(recipient.ExternalUserId);
var dm = await restClient.GetDMChannelAsync(userId, cancellationToken: ct);
await restClient.SendMessageAsync(
dm.Id,
new MessageProperties().WithContent(content),
cancellationToken: ct);
}
private static string FormatDiscordParticipant(PlatformSessionParticipant participant) =>
$"{Mention(participant.User)} ({participant.User.DisplayName})";
private static string Mention(PlatformUser user) => $"<@{user.ExternalUserId}>";
private static ulong GetChannelId(PlatformGroup group)
{
var channelId = group.ExternalChannelId ?? group.ExternalGroupId
?? throw new InvalidOperationException("Discord group has no channel or group identifier.");
return ParseSnowflake(channelId);
}
private static ulong ParseSnowflake(string value) =>
ulong.Parse(value, CultureInfo.InvariantCulture);
}
@@ -0,0 +1,101 @@
using System.Net;
namespace GmRelay.DiscordBot.Infrastructure.Health;
public sealed class DiscordHealthCheckHostedService : IHostedService
{
private readonly ILogger<DiscordHealthCheckHostedService> _logger;
private readonly string _prefix;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _listenerTask;
public DiscordHealthCheckHostedService(
ILogger<DiscordHealthCheckHostedService> logger,
IConfiguration configuration)
{
_logger = logger;
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8082/")!;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(_prefix);
_listener.Start();
_logger.LogInformation("Discord health check server started on {Prefix}", _prefix);
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
_listener?.Stop();
if (_listenerTask != null)
{
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
}
_listener?.Close();
_logger.LogInformation("Discord health check server stopped");
}
private async Task ListenAsync(CancellationToken cancellationToken)
{
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
}
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Discord health check listener");
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
var response = context.Response;
try
{
var request = context.Request;
if (request.Url?.AbsolutePath == "/health")
{
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = "application/json";
var body = "{\"status\":\"healthy\"}"u8.ToArray();
await response.OutputStream.WriteAsync(body);
}
else
{
response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling Discord health check request");
}
finally
{
response.Close();
}
}
}
@@ -0,0 +1,43 @@
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
public sealed class DiscordGatewayLifecycleLogger(
ILogger<DiscordGatewayLifecycleLogger> logger)
: IConnectGatewayHandler,
IReadyGatewayHandler,
IDisconnectGatewayHandler,
IResumeGatewayHandler
{
public ValueTask HandleAsync()
{
logger.LogInformation("Discord gateway connected");
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync(ReadyEventArgs arg)
{
logger.LogInformation(
"Discord gateway ready for application {ApplicationId} in {GuildCount} guilds",
arg.ApplicationId,
arg.GuildIds.Count);
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync(DisconnectEventArgs arg)
{
logger.LogWarning(
"Discord gateway disconnected; reconnect scheduled: {Reconnect}",
arg.Reconnect);
return ValueTask.CompletedTask;
}
ValueTask IResumeGatewayHandler.HandleAsync()
{
logger.LogInformation("Discord gateway session resumed");
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,14 @@
using System.Text.RegularExpressions;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
internal static partial class SecretRedactor
{
public static string RedactConnectionString(string connectionString)
{
return PasswordPattern().Replace(connectionString, "$1***");
}
[GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")]
private static partial Regex PasswordPattern();
}
@@ -0,0 +1,6 @@
namespace GmRelay.DiscordBot.Infrastructure;
public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
+93
View File
@@ -0,0 +1,93 @@
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Health;
using GmRelay.DiscordBot.Infrastructure.Logging;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NetCord;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
using NetCord.Hosting.Services.ApplicationCommands;
using NetCord.Hosting.Services.ComponentInteractions;
using NetCord.Services.ComponentInteractions;
using Npgsql;
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
var discordOptions = builder.Configuration
.GetRequiredSection("Discord")
.Get<DiscordOptions>() ?? new DiscordOptions();
discordOptions.Validate();
builder.Services.AddSingleton(discordOptions);
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var connectionString = config.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException(
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup");
logger.LogInformation(
"Configured PostgreSQL data source with connection string {ConnectionString}",
SecretRedactor.RedactConnectionString(connectionString));
return NpgsqlDataSource.Create(connectionString);
});
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord));
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
builder.Services
.AddDiscordGateway(options =>
{
options.Token = discordOptions.Token;
options.Intents = GatewayIntents.Guilds;
})
.AddApplicationCommands()
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
.AddGatewayHandlers(typeof(Program).Assembly);
var host = builder.Build();
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
await host.RunAsync();
@@ -0,0 +1,67 @@
namespace GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using NetCord;
using NetCord.Rest;
public static class DiscordRescheduleVotingRenderer
{
public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render(
string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes)
{
var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)");
sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)");
sb.AppendLine();
sb.AppendLine("Выберите один из вариантов:");
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов");
if (optionVotes.Count > 0)
{
sb.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}");
}
}
if (pending.Count > 0)
{
sb.AppendLine();
sb.AppendLine($"Не проголосовали: {string.Join(", ", pending)}");
}
sb.AppendLine();
sb.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
sb.AppendLine("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
var embed = new EmbedProperties()
.WithTitle($"🔄 Перенос сессии «{title}»")
.WithDescription(sb.ToString())
.WithColor(new Color(0xFEE75C));
var actionRow = new ActionRowProperties();
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
actionRow.Add(new ButtonProperties(
$"reschedule_vote:{option.OptionId}",
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
ButtonStyle.Primary));
}
return (embed, actionRow);
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
}
@@ -0,0 +1,125 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Rendering;
public static class DiscordSessionBatchRenderer
{
public static (IReadOnlyList<EmbedProperties> Embeds, IReadOnlyList<ActionRowProperties> ActionRows) Render(SessionBatchViewModel view)
{
var embeds = new List<EmbedProperties>();
var actionRows = new List<ActionRowProperties>();
foreach (var session in view.Sessions)
{
var embed = BuildEmbed(view.Title, session);
embeds.Add(embed);
if (session.AvailableActions.Count > 0)
{
var actionRow = new ActionRowProperties();
foreach (var action in session.AvailableActions)
{
actionRow.Add(new ButtonProperties(
$"{action.ActionKey}:{action.SessionId}",
action.Label,
ButtonStyle.Primary));
}
actionRows.Add(actionRow);
}
}
return (embeds, actionRows);
}
private static EmbedProperties BuildEmbed(string title, SessionViewItem session)
{
var embed = new EmbedProperties()
.WithTitle($"{title} — {session.ScheduledAt.FormatMoscow()}");
if (SessionStatus.IsCancelled(session.Status))
{
embed = embed.WithDescription("❌ Сессия отменена");
}
else
{
embed = embed.WithDescription(BuildPlayerDescription(session));
}
var fields = new List<EmbedFieldProperties>
{
new EmbedFieldProperties()
.WithName("👥 Заполненность")
.WithValue(session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount}")
.WithInline(),
new EmbedFieldProperties()
.WithName("⏳ Лист ожидания")
.WithValue(session.WaitlistedPlayers.Count > 0
? session.WaitlistedPlayers.Count.ToString()
: "—")
.WithInline(),
new EmbedFieldProperties()
.WithName("📊 Статус")
.WithValue(FormatStatus(session.Status))
.WithInline()
};
if (!string.IsNullOrEmpty(session.JoinLink))
{
embed = embed.WithUrl(session.JoinLink);
}
embed = embed.WithColor(GetColor(session));
embed = embed.AddFields(fields);
return embed;
}
private static string BuildPlayerDescription(SessionViewItem session)
{
if (session.ActivePlayers.Count == 0)
return "👥 Пока никто не записался";
var lines = session.ActivePlayers
.Select(p => $"• {p.DisplayName}")
.ToList();
if (session.WaitlistedPlayers.Count > 0)
{
lines.Add("");
lines.Add($"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):");
lines.AddRange(session.WaitlistedPlayers.Select(p => $"• {p.DisplayName}"));
}
return string.Join('\n', lines);
}
private static string FormatStatus(string status) => status switch
{
SessionStatus.Planned => "Запланирована",
SessionStatus.ConfirmationSent => "Ожидает подтверждения",
SessionStatus.Confirmed => "Подтверждена",
SessionStatus.Cancelled => "Отменена",
_ => status
};
private static Color GetColor(SessionViewItem session)
{
if (SessionStatus.IsCancelled(session.Status))
return new Color(0xED4245);
if (session.Status == SessionStatus.Confirmed)
return new Color(0x5865F2);
if (session.MaxPlayers.HasValue && session.ActivePlayerCount >= session.MaxPlayers.Value)
return new Color(0xFEE75C);
return new Color(0x57F287);
}
}
+679
View File
@@ -0,0 +1,679 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Aspire.Npgsql": {
"type": "Direct",
"requested": "[13.2.2, )",
"resolved": "13.2.2",
"contentHash": "nEYgziWN7hksgEQEWy24JypcMCU8gKYcIIyPL05JfdXxUWuPRLotH/KOeuHevAjSEOYkL3dtGakBkJAuPobGmA==",
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"Npgsql.DependencyInjection": "10.0.1",
"Npgsql.OpenTelemetry": "10.0.1",
"OpenTelemetry.Extensions.Hosting": "1.15.0"
}
},
"Dapper": {
"type": "Direct",
"requested": "[2.1.72, )",
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Logging.Console": "10.0.5",
"Microsoft.Extensions.Logging.Debug": "10.0.5",
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"NetCord.Hosting": {
"type": "Direct",
"requested": "[1.0.0-alpha.489, )",
"resolved": "1.0.0-alpha.489",
"contentHash": "yQcvgY3uu98ndoLXpiFhJ5kungoWVLd7xnO18GmukRPVsRzyOKgxe/Ycp8DLYTtiQG9Wyg1pV4Iv6rvo+zck4w==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "10.0.8",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.8",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8",
"Microsoft.Extensions.Options.DataAnnotations": "10.0.8",
"NetCord": "1.0.0-alpha.489"
}
},
"NetCord.Hosting.Services": {
"type": "Direct",
"requested": "[1.0.0-alpha.489, )",
"resolved": "1.0.0-alpha.489",
"contentHash": "Md46+zLB9UWYLM7PVlATytkjAC9602wBNKO7m5eaBiDdEvZOPsUrR6NJJr2YtJoKjttbvhte5ayDXj8WGGsevQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "10.0.8",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.8",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8",
"Microsoft.Extensions.Options.DataAnnotations": "10.0.8",
"NetCord.Hosting": "1.0.0-alpha.489",
"NetCord.Services": "1.0.0-alpha.489"
}
},
"NetCord.Services": {
"type": "Direct",
"requested": "[1.0.0-alpha.489, )",
"resolved": "1.0.0-alpha.489",
"contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==",
"dependencies": {
"NetCord": "1.0.0-alpha.489"
}
},
"Npgsql": {
"type": "Direct",
"requested": "[10.0.2, )",
"resolved": "10.0.2",
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"AspNetCore.HealthChecks.NpgSql": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Npgsql": "8.0.3"
}
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "CNrEjaOCZ8d1HtB0mvpiX4EWxLkee2xy+CsYXxmsEYJSFgw3OmF9pIhP/tCTeYBHhpsKJj5wM63G8IBFGxAcsw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.2",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2"
}
},
"Microsoft.Extensions.Compliance.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "1a4xDAT6fRyP8t419q3WvWMmMslDTvI7OAZLWBhn5rysFG0bl5xFenTswd1xAbT/3u3mx4Xyb5bPx+V+18tJeQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
"Microsoft.Extensions.Primitives": "10.0.8"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.8"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.8",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A=="
},
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Z/OI261l7LnxyODKPx0trQyIHFyicCR/akfn64lGOjPcf4FpAZ7ePAGl2HPvQBUBSNfPTF0gWeCfuFmyftMgYA==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
"Microsoft.Extensions.Options": "10.0.8"
}
},
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "3qMK1D40D10kb5TdBtFJpzz6/WH0NinWs68ZZS8jCFgHMXDiOjGiPOneMmIocCP/wnUUW4Hzf8lMsIE1xIGxDA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
},
"Microsoft.Extensions.Features": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "X7tm2aV2w3lN9roSSGhl19lz4w76HvdiuKNhIv2XOiorYII9XCm66o/z9IJ0+QwkgvEv5gMZDM6rV6uwABHEQQ=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.8"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.8",
"Microsoft.Extensions.Logging.Abstractions": "10.0.8"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "egUPC0xydb1ugCMcRyJ6zaOGOzx7N4coOVlGeLcIsXhUf1xHHwZeX+ob7JuG0dXExFduHYE/t+4/4y8BLlBKmw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Logging": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"Microsoft.Extensions.Http.Diagnostics": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "I0FBgF6yZRwYH9E3KQ2vHm80YZ7YBj+52GDsmOWXPBv/p15b/wUoNupV9kw3LnSNVsWMqlGbiuZgBnHpMwPh+Q==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.Telemetry": "10.2.0"
}
},
"Microsoft.Extensions.Http.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Lg+OjBW+ODDbM4Ax4LoERvQ1dqSZ8I2gQc2+B0/WOWl2+PunLJ3xb3x8MtHGfcb/Mp98RoMpwRKm6Aj9mzXwrA==",
"dependencies": {
"Microsoft.Extensions.Http.Diagnostics": "10.2.0",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Resilience": "10.2.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"System.Diagnostics.EventLog": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
"Microsoft.Extensions.Primitives": "10.0.8"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
"Microsoft.Extensions.Configuration.Binder": "10.0.8",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
"Microsoft.Extensions.Options": "10.0.8",
"Microsoft.Extensions.Primitives": "10.0.8"
}
},
"Microsoft.Extensions.Options.DataAnnotations": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "HhxwIGECGGJ8ox2kvm6/hkN/w1ZyKrO5uu/rLAL51V0ypPdahoNf+dHS6Er/DJs2aeUmH38ZTTzACfLy1O6w3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
"Microsoft.Extensions.Options": "10.0.8"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.8",
"contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ=="
},
"Microsoft.Extensions.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "v4WOdAOFxB3AcsUkZWNcHL3mYzs4KAPtHO8rkoQlFKOBoD3KyjjAL+h3tRwSK5i4UpF/yhxsQRY0JxKj4osxxw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.2.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0",
"Polly.Extensions": "8.4.2",
"Polly.RateLimiting": "8.4.2"
}
},
"Microsoft.Extensions.ServiceDiscovery": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "AHTPfiKodj66xA8RwRkFD4q11V2AvzcuDsujv6ViPkOPtvBEYcPVplHakK56pPzWlX08MDS+TAQXfFXAeP7J5w==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.ServiceDiscovery.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "sANlOvfqfw/yfych4CLlHSKSWzIie6mQG7w83gVur1foNOafyHxcgpoQMvBf+KiB4Tpls6P1/Z77IIQSK8hxFg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.Configuration.Binder": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Features": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2",
"Microsoft.Extensions.Primitives": "10.0.2"
}
},
"Microsoft.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "ssW5gosYlewNH/ISTyaLD/XfJT4GSjwShOUKv61fpXrqVmHkhuIA/5bBAGStM1XbzJjt9IG2vzfdHTu4zlX9Ew==",
"dependencies": {
"Microsoft.Extensions.AmbientMetadata.Application": "10.2.0",
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.2.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.Telemetry.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "6V4V6NX6RLUYWwV89DeW/4zK5xOycYHWhsfMXSpKVGgMHfXcczmbk6hBeqTnRPzhpATYcOWlmA6hk1jgdxUugA==",
"dependencies": {
"Microsoft.Extensions.Compliance.Abstractions": "10.2.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"NetCord": {
"type": "Transitive",
"resolved": "1.0.0-alpha.489",
"contentHash": "/rM73l1pwwJCWHi7YrIiSVc+GVL0lV+k+amqNJUMINjLO+c5bKWj9PoNNoMhiPZoaORO4k6Uxp8EQfoQj3AYtA=="
},
"Npgsql.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Npgsql": "10.0.1"
}
},
"Npgsql.OpenTelemetry": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==",
"dependencies": {
"Npgsql": "10.0.1",
"OpenTelemetry.API": "1.14.0"
}
},
"OpenTelemetry": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
}
},
"OpenTelemetry.Api": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
},
"OpenTelemetry.Api.ProviderBuilderExtensions": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
"OpenTelemetry.Api": "1.15.3"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
"dependencies": {
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Extensions.Hosting": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Instrumentation.AspNetCore": {
"type": "Transitive",
"resolved": "1.15.2",
"contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Http": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.0",
"Microsoft.Extensions.Options": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Runtime": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
"dependencies": {
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
},
"Polly.Extensions": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Polly.Core": "8.4.2"
}
},
"Polly.RateLimiting": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
"dependencies": {
"Polly.Core": "8.4.2",
"System.Threading.RateLimiting": "8.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
},
"System.Threading.RateLimiting": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
},
"gmrelay.servicedefaults": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Http.Resilience": "[10.2.0, )",
"Microsoft.Extensions.ServiceDiscovery": "[10.2.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
"OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )"
}
},
"gmrelay.shared": {
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
}
}
}
}
@@ -0,0 +1,181 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.Extensions.Http.Resilience": {
"type": "Direct",
"requested": "[10.2.0, )",
"resolved": "10.2.0",
"contentHash": "Lg+OjBW+ODDbM4Ax4LoERvQ1dqSZ8I2gQc2+B0/WOWl2+PunLJ3xb3x8MtHGfcb/Mp98RoMpwRKm6Aj9mzXwrA==",
"dependencies": {
"Microsoft.Extensions.Http.Diagnostics": "10.2.0",
"Microsoft.Extensions.Resilience": "10.2.0"
}
},
"Microsoft.Extensions.ServiceDiscovery": {
"type": "Direct",
"requested": "[10.2.0, )",
"resolved": "10.2.0",
"contentHash": "AHTPfiKodj66xA8RwRkFD4q11V2AvzcuDsujv6ViPkOPtvBEYcPVplHakK56pPzWlX08MDS+TAQXfFXAeP7J5w==",
"dependencies": {
"Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.2.0"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Direct",
"requested": "[1.15.3, )",
"resolved": "1.15.3",
"contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
"dependencies": {
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Extensions.Hosting": {
"type": "Direct",
"requested": "[1.15.3, )",
"resolved": "1.15.3",
"contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
"dependencies": {
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Instrumentation.AspNetCore": {
"type": "Direct",
"requested": "[1.15.2, )",
"resolved": "1.15.2",
"contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Http": {
"type": "Direct",
"requested": "[1.15.1, )",
"resolved": "1.15.1",
"contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Runtime": {
"type": "Direct",
"requested": "[1.15.1, )",
"resolved": "1.15.1",
"contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
"dependencies": {
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "CNrEjaOCZ8d1HtB0mvpiX4EWxLkee2xy+CsYXxmsEYJSFgw3OmF9pIhP/tCTeYBHhpsKJj5wM63G8IBFGxAcsw=="
},
"Microsoft.Extensions.Compliance.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "1a4xDAT6fRyP8t419q3WvWMmMslDTvI7OAZLWBhn5rysFG0bl5xFenTswd1xAbT/3u3mx4Xyb5bPx+V+18tJeQ=="
},
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Z/OI261l7LnxyODKPx0trQyIHFyicCR/akfn64lGOjPcf4FpAZ7ePAGl2HPvQBUBSNfPTF0gWeCfuFmyftMgYA=="
},
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "3qMK1D40D10kb5TdBtFJpzz6/WH0NinWs68ZZS8jCFgHMXDiOjGiPOneMmIocCP/wnUUW4Hzf8lMsIE1xIGxDA=="
},
"Microsoft.Extensions.Http.Diagnostics": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "I0FBgF6yZRwYH9E3KQ2vHm80YZ7YBj+52GDsmOWXPBv/p15b/wUoNupV9kw3LnSNVsWMqlGbiuZgBnHpMwPh+Q==",
"dependencies": {
"Microsoft.Extensions.Telemetry": "10.2.0"
}
},
"Microsoft.Extensions.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "v4WOdAOFxB3AcsUkZWNcHL3mYzs4KAPtHO8rkoQlFKOBoD3KyjjAL+h3tRwSK5i4UpF/yhxsQRY0JxKj4osxxw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.2.0",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0",
"Polly.Extensions": "8.4.2",
"Polly.RateLimiting": "8.4.2"
}
},
"Microsoft.Extensions.ServiceDiscovery.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "sANlOvfqfw/yfych4CLlHSKSWzIie6mQG7w83gVur1foNOafyHxcgpoQMvBf+KiB4Tpls6P1/Z77IIQSK8hxFg=="
},
"Microsoft.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "ssW5gosYlewNH/ISTyaLD/XfJT4GSjwShOUKv61fpXrqVmHkhuIA/5bBAGStM1XbzJjt9IG2vzfdHTu4zlX9Ew==",
"dependencies": {
"Microsoft.Extensions.AmbientMetadata.Application": "10.2.0",
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.2.0",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.Telemetry.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "6V4V6NX6RLUYWwV89DeW/4zK5xOycYHWhsfMXSpKVGgMHfXcczmbk6hBeqTnRPzhpATYcOWlmA6hk1jgdxUugA==",
"dependencies": {
"Microsoft.Extensions.Compliance.Abstractions": "10.2.0"
}
},
"OpenTelemetry": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
}
},
"OpenTelemetry.Api": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
},
"OpenTelemetry.Api.ProviderBuilderExtensions": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
"dependencies": {
"OpenTelemetry.Api": "1.15.3"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
},
"Polly.Extensions": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
"dependencies": {
"Polly.Core": "8.4.2"
}
},
"Polly.RateLimiting": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
"dependencies": {
"Polly.Core": "8.4.2"
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
[module: Dapper.DapperAot]
@@ -0,0 +1,7 @@
namespace GmRelay.Shared.Domain;
public enum CalendarSubscriptionFilter
{
AllMyGroups = 0,
SpecificGroup = 1
}
+1 -1
View File
@@ -21,7 +21,7 @@ public static class MoscowTime
public static bool TryParseMoscow(string text, out DateTimeOffset utcTime)
{
if (DateTime.TryParseExact(text, new[] { "dd.MM.yyyy HH:mm", "dd.MM.yyyy H:mm", "d.MM.yyyy HH:mm" },
if (DateTime.TryParseExact(text, new[] { "dd.MM.yyyy HH:mm", "dd.MM.yyyy H:mm", "d.MM.yyyy HH:mm" },
System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var localDt))
{
utcTime = new DateTimeOffset(localDt, MoscowOffset).ToUniversalTime();
@@ -0,0 +1,356 @@
using System.Globalization;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
public sealed record HandleRsvpCommand(
Guid SessionId,
PlatformUser User,
string Status,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ConfirmationMessage);
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record RsvpSessionContext(
Guid GroupId,
string Title,
DateTime ScheduledAt,
string Status);
internal sealed record ParticipantRsvpRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm);
internal sealed record RsvpRecipientRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ILogger<HandleRsvpHandler> logger)
{
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var participantExists = await connection.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND COALESCE(p.platform, 'Telegram') = @Platform
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
)
""",
new
{
command.SessionId,
Platform = command.User.Platform.ToString(),
command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (!participantExists)
{
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(
command.InteractionId,
"Вы не являетесь участником этой сессии."),
ct);
return;
}
var updated = await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = @Status,
responded_at = now()
WHERE session_id = @SessionId
AND player_id = (
SELECT id
FROM players
WHERE COALESCE(platform, 'Telegram') = @Platform
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
LIMIT 1
)
AND registration_status = @Active
AND rsvp_status != @Status
""",
new
{
command.SessionId,
command.Status,
Platform = command.User.Platform.ToString(),
command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (updated == 0)
{
var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие."
: "Вы уже отказались от участия.";
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.InteractionId, alreadyText),
ct);
return;
}
var session = await connection.QuerySingleAsync<RsvpSessionContext>(
"""
SELECT s.group_id AS GroupId,
s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId },
transaction);
if (command.Status == RsvpStatus.Declined)
{
var decision = RsvpFlowRules.Evaluate(
command.Status,
session.Status,
totalParticipants: 0,
confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
var gmRecipients = (await GetGmRecipientsAsync(connection, session.GroupId, transaction))
.ToList();
await transaction.CommitAsync(ct);
if (gmRecipients.Count > 0)
{
await messenger.SendRsvpOutcomeAsync(
new PlatformRsvpOutcomeNotification(
PlatformRsvpOutcomeKind.GmPlayerDeclined,
Group: null,
gmRecipients,
command.SessionId,
session.Title,
session.ScheduledAt,
ActorDisplayName: command.User.DisplayName),
ct);
}
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
ct);
}
else
{
var counts = await connection.QuerySingleAsync<RsvpCounts>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
AND registration_status = @Active
""",
new
{
command.SessionId,
Confirmed = RsvpStatus.Confirmed,
Declined = RsvpStatus.Declined,
Active = ParticipantRegistrationStatus.Active
},
transaction);
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (decision.ShouldMarkSessionConfirmed)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Confirmed, updated_at = now()
WHERE id = @SessionId
""",
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
transaction);
}
var gmRecipients = decision.ShouldNotifyGm
? (await GetGmRecipientsAsync(connection, session.GroupId, transaction)).ToList()
: [];
await transaction.CommitAsync(ct);
if (decision.ShouldNotifyGroup)
{
await messenger.SendRsvpOutcomeAsync(
new PlatformRsvpOutcomeNotification(
PlatformRsvpOutcomeKind.GroupAllConfirmed,
command.Group,
[],
command.SessionId,
session.Title,
session.ScheduledAt),
ct);
}
if (decision.ShouldNotifyGm && gmRecipients.Count > 0)
{
await messenger.SendRsvpOutcomeAsync(
new PlatformRsvpOutcomeNotification(
PlatformRsvpOutcomeKind.GmAllConfirmed,
Group: null,
gmRecipients,
command.SessionId,
session.Title,
session.ScheduledAt),
ct);
}
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
ct);
}
await UpdateConfirmationMessage(command, session, ct);
}
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command,
RsvpSessionContext session,
CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY sp.responded_at NULLS LAST
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active }))
.Select(ToParticipant)
.ToList();
var disableActions = participants.Count > 0 &&
participants.All(participant => participant.RsvpStatus == RsvpStatus.Confirmed);
await messenger.UpdateConfirmationRequestAsync(
new PlatformRsvpMessageUpdate(
new PlatformConfirmationRequest(
command.Group,
command.SessionId,
session.Title,
session.ScheduledAt,
participants,
command.ConfirmationMessage),
disableActions),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
}
}
private static async Task<IEnumerable<PlatformUser>> GetGmRecipientsAsync(
NpgsqlConnection connection,
Guid groupId,
NpgsqlTransaction transaction)
{
var rows = await connection.QueryAsync<RsvpRecipientRow>(
"""
SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
UNION
SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
FROM game_groups g
JOIN players p ON p.telegram_id = g.gm_telegram_id
WHERE g.id = @GroupId
AND g.gm_telegram_id IS NOT NULL
""",
new { GroupId = groupId },
transaction);
return rows.Select(row => new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername));
}
private static PlatformSessionParticipant ToParticipant(ParticipantRsvpRow row) =>
new(
new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername),
row.RsvpStatus,
row.RegistrationStatus,
row.IsGm);
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
}
@@ -1,8 +1,8 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
internal sealed record RsvpFlowDecision(
public sealed record RsvpFlowDecision(
string CallbackText,
bool ShouldAlertGm,
bool ShouldRevertSessionToConfirmationSent,
@@ -10,7 +10,7 @@ internal sealed record RsvpFlowDecision(
bool ShouldNotifyGroup,
bool ShouldNotifyGm);
internal static class RsvpFlowRules
public static class RsvpFlowRules
{
public static RsvpFlowDecision Evaluate(
string requestedStatus,
@@ -21,7 +21,7 @@ internal static class RsvpFlowRules
if (requestedStatus == RsvpStatus.Declined)
{
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
CallbackText: "Вы отказались от участия.",
ShouldAlertGm: true,
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
ShouldMarkSessionConfirmed: false,
@@ -32,7 +32,7 @@ internal static class RsvpFlowRules
var everyoneConfirmed = confirmedParticipants == totalParticipants;
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
CallbackText: "Вы подтвердили участие!",
ShouldAlertGm: false,
ShouldRevertSessionToConfirmationSent: false,
ShouldMarkSessionConfirmed: everyoneConfirmed,
@@ -0,0 +1,6 @@
namespace GmRelay.Shared.Features.Confirmation.SendConfirmation;
public interface ISendConfirmationHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -0,0 +1,217 @@
using System.Globalization;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Confirmation.SendConfirmation;
internal sealed record ConfirmationSessionRow(
Guid Id,
string Title,
DateTime ScheduledAt,
Guid GroupId,
string Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId,
int? ThreadId,
string NotificationMode);
internal sealed record ConfirmationParticipantRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm);
public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
PlatformDirectNotificationSender directSender,
ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<ConfirmationSessionRow>(
"""
SELECT s.id,
s.title,
s.scheduled_at AS ScheduledAt,
s.group_id AS GroupId,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.status = @Planned
""",
new { SessionId = sessionId, Planned = SessionStatus.Planned });
if (session is null)
{
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
return;
}
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY sp.created_at ASC
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }))
.Select(ToParticipant)
.ToList();
if (participants.Count == 0)
{
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
return;
}
var group = CreateGroup(session);
var message = await messenger.SendConfirmationRequestAsync(
new PlatformConfirmationRequest(
group,
session.Id,
session.Title,
session.ScheduledAt,
participants),
ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Status,
confirmation_message_id = @MessageId,
confirmation_sent_at = now(),
updated_at = now()
WHERE id = @SessionId
AND confirmation_sent_at IS NULL
""",
new
{
SessionId = sessionId,
Status = SessionStatus.ConfirmationSent,
MessageId = TryGetTelegramMessageId(message)
});
await PersistPlatformMessageAsync(
connection,
message,
session.GroupId,
session.Id,
batchId: null,
purpose: "confirmation");
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
PlatformDirectSessionNotificationKind.ConfirmationRequest,
participants.Select(p => p.User),
session.Id,
session.Title,
session.ScheduledAt,
joinLink: null,
actorDisplayName: null,
reason: null,
ct);
}
logger.LogInformation(
"Confirmation sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}",
sessionId,
session.Title,
message.Platform,
message.ExternalMessageId);
}
private static PlatformSessionParticipant ToParticipant(ConfirmationParticipantRow row) =>
new(
new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername),
row.RsvpStatus,
row.RegistrationStatus,
row.IsGm);
private static PlatformGroup CreateGroup(ConfirmationSessionRow row) =>
new(
ParsePlatform(row.Platform),
row.ExternalGroupId,
row.DisplayName,
row.ExternalChannelId,
row.ThreadId?.ToString(CultureInfo.InvariantCulture));
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
private static int? TryGetTelegramMessageId(PlatformMessageRef message) =>
message.Platform == PlatformKind.Telegram &&
int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)
? messageId
: null;
private static Task PersistPlatformMessageAsync(
NpgsqlConnection connection,
PlatformMessageRef message,
Guid groupId,
Guid? sessionId,
Guid? batchId,
string purpose) =>
connection.ExecuteAsync(
"""
INSERT INTO platform_messages (
platform,
group_id,
batch_id,
session_id,
external_channel_id,
external_thread_id,
external_message_id,
purpose)
VALUES (
@Platform,
@GroupId,
@BatchId,
@SessionId,
@ExternalChannelId,
@ExternalThreadId,
@ExternalMessageId,
@Purpose)
""",
new
{
Platform = message.Platform.ToString(),
GroupId = groupId,
BatchId = batchId,
SessionId = sessionId,
ExternalChannelId = message.ExternalGroupId,
message.ExternalThreadId,
message.ExternalMessageId,
Purpose = purpose
});
}
@@ -0,0 +1,50 @@
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
namespace GmRelay.Shared.Features.Notifications;
public sealed class PlatformDirectNotificationSender(
IPlatformMessenger messenger,
ILogger<PlatformDirectNotificationSender> logger)
{
public async Task SendAsync(
PlatformDirectSessionNotificationKind kind,
IEnumerable<PlatformUser> recipients,
Guid sessionId,
string title,
DateTime scheduledAt,
string? joinLink,
string? actorDisplayName,
string? reason,
CancellationToken ct)
{
foreach (var recipient in recipients)
{
try
{
await messenger.SendDirectSessionNotificationAsync(
new PlatformDirectSessionNotification(
kind,
recipient,
sessionId,
title,
scheduledAt,
joinLink,
actorDisplayName,
reason),
ct);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to send {NotificationKind} notification for session {SessionId} to {Platform} user {ExternalUserId} ({DisplayName})",
kind,
sessionId,
recipient.Platform,
recipient.ExternalUserId,
recipient.DisplayName);
}
}
}
}
@@ -0,0 +1,6 @@
namespace GmRelay.Shared.Features.Reminders.SendJoinLink;
public interface ISendJoinLinkHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -0,0 +1,228 @@
using System.Globalization;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Reminders.SendJoinLink;
internal sealed record JoinLinkSessionRow(
Guid Id,
Guid GroupId,
string Title,
string JoinLink,
DateTime ScheduledAt,
string Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId,
int? ThreadId,
string NotificationMode);
internal sealed record JoinLinkPlayerRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm);
public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
PlatformDirectNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSessionRow>(
"""
SELECT s.id,
s.group_id AS GroupId,
s.title,
s.join_link AS JoinLink,
s.scheduled_at AS ScheduledAt,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND (
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
OR (
COALESCE(g.platform, 'Telegram') <> 'Telegram'
AND NOT EXISTS (
SELECT 1
FROM platform_messages pm
WHERE pm.session_id = s.id
AND pm.platform = COALESCE(g.platform, 'Telegram')
AND pm.purpose = 'join_link'
)
)
)
""",
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
return;
}
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.rsvp_status = @Confirmed
AND sp.registration_status = @Active
ORDER BY sp.created_at ASC
""",
new
{
SessionId = sessionId,
Confirmed = RsvpStatus.Confirmed,
Active = ParticipantRegistrationStatus.Active
}))
.Select(ToParticipant)
.ToList();
var group = CreateGroup(session);
var message = await messenger.SendJoinLinkNotificationAsync(
new PlatformJoinLinkNotification(
group,
session.Id,
session.Title,
session.ScheduledAt,
session.JoinLink,
players),
ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET link_message_id = @MessageId, updated_at = now()
WHERE id = @SessionId
""",
new
{
SessionId = sessionId,
MessageId = TryGetTelegramMessageId(message)
});
await PersistPlatformMessageAsync(
connection,
message,
session.GroupId,
session.Id,
batchId: null,
purpose: "join_link");
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
PlatformDirectSessionNotificationKind.JoinLink,
players.Select(p => p.User),
session.Id,
session.Title,
session.ScheduledAt,
session.JoinLink,
actorDisplayName: null,
reason: null,
ct);
}
logger.LogInformation(
"Join link sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}",
sessionId,
session.Title,
message.Platform,
message.ExternalMessageId);
}
private static PlatformSessionParticipant ToParticipant(JoinLinkPlayerRow row) =>
new(
new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername),
row.RsvpStatus,
row.RegistrationStatus,
row.IsGm);
private static PlatformGroup CreateGroup(JoinLinkSessionRow row) =>
new(
ParsePlatform(row.Platform),
row.ExternalGroupId,
row.DisplayName,
row.ExternalChannelId,
row.ThreadId?.ToString(CultureInfo.InvariantCulture));
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
private static int? TryGetTelegramMessageId(PlatformMessageRef message) =>
message.Platform == PlatformKind.Telegram &&
int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)
? messageId
: null;
private static Task PersistPlatformMessageAsync(
NpgsqlConnection connection,
PlatformMessageRef message,
Guid groupId,
Guid? sessionId,
Guid? batchId,
string purpose) =>
connection.ExecuteAsync(
"""
INSERT INTO platform_messages (
platform,
group_id,
batch_id,
session_id,
external_channel_id,
external_thread_id,
external_message_id,
purpose)
VALUES (
@Platform,
@GroupId,
@BatchId,
@SessionId,
@ExternalChannelId,
@ExternalThreadId,
@ExternalMessageId,
@Purpose)
""",
new
{
Platform = message.Platform.ToString(),
GroupId = groupId,
BatchId = batchId,
SessionId = sessionId,
ExternalChannelId = message.ExternalGroupId,
message.ExternalThreadId,
message.ExternalMessageId,
Purpose = purpose
});
}
@@ -0,0 +1,6 @@
namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder;
public interface ISendOneHourReminderHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -1,27 +1,35 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder;
internal sealed record OneHourReminderSession(
internal sealed record OneHourReminderSessionRow(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
string NotificationMode);
internal sealed record OneHourReminderRecipientRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
public sealed class SendOneHourReminderHandler(
NpgsqlDataSource dataSource,
DirectSessionNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger)
PlatformDirectNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSessionRow>(
"""
SELECT id,
title,
@@ -46,10 +54,12 @@ public sealed class SendOneHourReminderHandler(
return;
}
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
@@ -62,20 +72,27 @@ public sealed class SendOneHourReminderHandler(
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Declined = RsvpStatus.Declined
})).ToList();
}))
.Select(row => new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername))
.ToList();
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
{
var text = $"""
<b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct);
await directSender.SendAsync(
PlatformDirectSessionNotificationKind.OneHourReminder,
recipients,
session.Id,
session.Title,
session.ScheduledAt,
session.JoinLink,
actorDisplayName: null,
reason: null,
ct);
}
await connection.ExecuteAsync(
@@ -94,4 +111,7 @@ public sealed class SendOneHourReminderHandler(
session.Title,
session.NotificationMode);
}
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
}
@@ -1,31 +1,32 @@
using System.Globalization;
using Dapper;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand(
Guid SessionId,
long TelegramUserId,
string DisplayName,
string? TelegramUsername,
string CallbackQueryId,
long ChatId,
int MessageId);
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger)
{
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
@@ -33,17 +34,40 @@ public sealed class JoinSessionHandler(
try
{
// 1. Убеждаемся, что игрок есть в базе
var platform = command.User.Platform.ToString();
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
: (long?)null;
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
? command.User.ExternalUsername
: null;
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
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,
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username
RETURNING id;",
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
new
{
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
},
transaction);
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
@"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers
FROM sessions
WHERE id = @SessionId
FOR UPDATE",
@@ -53,7 +77,14 @@ public sealed class JoinSessionHandler(
if (batchInfo is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
}
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
}
@@ -74,7 +105,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!"
: "Вы уже записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct);
await AnswerAsync(command.InteractionId, alreadyText, ct);
return;
}
@@ -108,13 +139,13 @@ public sealed class JoinSessionHandler(
if (inserted == 0)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct);
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
return;
}
// Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers
@"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",
@@ -123,7 +154,7 @@ public sealed class JoinSessionHandler(
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
p.telegram_username as TelegramUsername,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -136,20 +167,18 @@ public sealed class JoinSessionHandler(
transactionCommitted = true;
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
await AnswerAsync(command.InteractionId, callbackText, ct);
}
catch (Exception ex)
{
@@ -162,7 +191,10 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
await AnswerAsync(command.InteractionId, errorText, ct);
}
}
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
}

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