Compare commits

...

106 Commits

Author SHA1 Message Date
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
Toutsu 8220f2060f fix: refresh mini app login state
Deploy Telegram Bot / build-and-push (push) Successful in 4m23s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 17:03:53 +03:00
Toutsu 41f2ea6e90 feat: add telegram mini app dashboard
Deploy Telegram Bot / build-and-push (push) Successful in 23s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 14:56:55 +03:00
Toutsu 5082dd4fcf fix: stack sidebar template nav item
Deploy Telegram Bot / build-and-push (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-04-28 10:36:52 +03:00
Toutsu cfbda4ca05 fix: move campaign templates to dedicated tab
Deploy Telegram Bot / build-and-push (push) Successful in 3m28s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 10:22:12 +03:00
Toutsu 0218890a7a feat: add campaign templates and recurring schedules
Deploy Telegram Bot / build-and-push (push) Successful in 3m49s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 10:01:18 +03:00
Toutsu a1ec688ec8 feat: add multi-option reschedule voting
Deploy Telegram Bot / build-and-push (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:58:32 +03:00
Toutsu 2529df4157 feat: support co-gm group delegation
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:27:16 +03:00
133 changed files with 13286 additions and 1194 deletions
+11
View File
@@ -6,8 +6,19 @@ TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Найти его можно в информации о боте у @BotFather. # Найти его можно в информации о боте у @BotFather.
TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp
# Используется ботом для кнопки меню Telegram и кнопки /start.
TELEGRAM_MINI_APP_URL=
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase POSTGRES_PASSWORD=StrongPasswordForDatabase
# Локальный порт веб-интерфейса GM-Relay # Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080 GMRELAY_WEB_PORT=8080
# === Backup ===
# Сколько дней хранить дампы PostgreSQL (default: 7)
BACKUP_RETENTION_DAYS=7
# Имя Docker volume для резервных копий БД
BACKUP_VOLUME_NAME=game_pgbackups
+28 -2
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.5.0 VERSION: 2.1.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -51,9 +51,34 @@ jobs:
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }} 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 Web image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 2: Запускаем эти образы на самом сервере # ЧАСТЬ 2: Запускаем эти образы на самом сервере
deploy: deploy:
needs: build-and-push needs: scan-images
runs-on: ubuntu-latest # Тот же локальный раннер runs-on: ubuntu-latest # Тот же локальный раннер
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -64,6 +89,7 @@ jobs:
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
- name: Deploy Containers - name: Deploy Containers
run: | run: |
+78
View File
@@ -0,0 +1,78 @@
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 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.
+6 -1
View File
@@ -1,10 +1,15 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.5.0</Version> <Version>2.1.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="all" />
</ItemGroup>
</Project> </Project>
+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.
+111 -101
View File
@@ -4,160 +4,170 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.5.0`. **Текущая версия:** `v2.0.1`.
--- ---
## ✨ Ключевые возможности ## ✨ Key Features
### 🤖 Telegram Бот ### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания. - **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. - **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**. - **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков. - **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через 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` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы.
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают Telegram-сообщения расписания.
--- ---
## 🛠 Технологический стек ## 🛠 Технологический стек
- **Язык**: C# 14 (.NET 10) | Компонент | Технология |
- **Архитектура**: Vertical Slice Architecture, общая библиотека (`GmRelay.Shared`) для доменной логики. |---|---|
- **Бот**: Telegram.Bot, Native AOT. | Язык | C# 14 (.NET 10) |
- **Веб-интерфейс**: Blazor Server. | Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
- **Оркестрация**: .NET Aspire (`GmRelay.AppHost`). | Бот | Telegram.Bot, **Native AOT** |
- **База данных**: PostgreSQL | Веб | Blazor Server |
- **ORM**: Dapper (с использованием Dapper.AOT для source generators). | Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
- **Миграции**: DbUp. | БД | PostgreSQL |
- **Развертывание**: Docker Compose + Multi-arch (AMD64/ARM64). | ORM | Dapper + **Dapper.AOT** (source generators) |
| Миграции | DbUp |
| Развёртывание | Docker Compose, Multi-arch (**AMD64/ARM64**) |
> [!NOTE]
> При использовании Dapper в режиме Native AOT все SQL-запросы используют строго типизированные DTO; динамические типы (`dynamic`) не поддерживаются.
--- ---
## 🚀 Быстрый старт (Docker Compose) ## 🚀 Быстрый старт (Docker Compose)
Проект использует Docker Compose для одновременного запуска базы данных, бота и веб-интерфейса. **Требования:** Docker и Docker Compose.
### 1. Подготовка
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
### 2. Настройка окружения
Скопируйте файл-шаблон и заполните его значениями:
### 1. Настройка окружения
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Отредактируйте `.env`: **Ключевые переменные `.env`:**
```env ```env
# Токен вашего бота от @BotFather (используется и для бота, и как секретный ключ для веб-авторизации) # Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
TELEGRAM_BOT_TOKEN=ваш_токен_здесь TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Имя вашего бота в Telegram (без @), например: GmRelayBot. # Имя бота без @ (для Telegram Login Widget)
# Найти его можно в информации о боте у @BotFather.
# Используется для работы виджета авторизации (Telegram Login Widget).
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# Пароль для базы данных PostgreSQL # HTTPS URL Mini App, например https://your-domain.example/miniapp
POSTGRES_PASSWORD=ваш_надежный_пароль TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
# Локальный порт веб-интерфейса GM-Relay POSTGRES_PASSWORD=ваш_надежный_пароль
GMRELAY_WEB_PORT=8080 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`.
### 3. Запуск ### 2. Запуск
Выполните команду:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
Инфраструктура автоматически:
- Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
- Запустит бота (применив миграции БД).
- Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
--- **Автоматически выполняется:**
- создание Docker-сети и volume PostgreSQL;
- подъём PostgreSQL (`db:5432`);
- запуск бота с плавной миграцией (DbUp);
- запуск веб-приложения с подключением к БД и Telegram API.
## ⚙️ Настройка бота в Telegram ### 3. Первоначальная настройка
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
Чтобы бот работал корректно: ## 💾 Backup и восстановление
1. **Добавьте бота в группу** (или Супергруппу/Форум).
2. **Назначьте бота Администратором**.
3. **Необходимые права**:
* `Выбор тем` (Managed Topics) — **обязательно** для Форумов.
* `Отправка сообщений`.
* `Закрепление сообщений` — рекомендуется.
> [!TIP] Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
--- ### Как это работает
- **Каждый день в 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
Используйте команду `/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. ### Ручное создание дампа
```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
### Bulk-операции в Web Dashboard # Или указать конкретный файл
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может: ./scripts/restore.sh backups/gmrelay_db_20260512_030000.sql.gz
- обновить общий `title` и `link` сразу у всех сессий batch; ```
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков. > [!WARNING]
> Восстановление **перезаписывает текущую базу данных**. Убедитесь, что вы понимаете последствия, прежде чем запускать `restore.sh`.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам. ### Переменные окружения (опциональные)
```env
### Другие команды BACKUP_RETENTION_DAYS=7
- `/listsessions` — Показать список всех актуальных игр в этой группе. BACKUP_VOLUME_NAME=game_pgbackups
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. ```
- `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату.
--- ---
## 🏗 Разработка и запуск локально (.NET Aspire) ## 🗂 Структура репозитория
Для локальной разработки проще всего использовать .NET Aspire: ```
├── src/
1. Установите [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) и workload Aspire. │ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
2. Откройте решение `GM-Relay.slnx`. │ ├── GmRelay.Bot/ # Telegram-бот (Native AOT)
3. Установите переменные окружения (или user secrets) для `GmRelay.AppHost`. │ ├── GmRelay.Migrator/ # DbUp-миграции
4. Запустите проект `GmRelay.AppHost`. Aspire Dashboard запустится автоматически, предоставляя удобный мониторинг БД, бота и веб-интерфейса. │ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
│ ├── GmRelay.Shared/ # Общие доменные модели
> [!NOTE] │ ├── GmRelay.Web/ # Blazor Server dashboard
> При использовании **Dapper** в режиме Native AOT, все SQL-запросы используют строго типизированные DTO. Динамические типы (`dynamic`) не поддерживаются. │ └── GmRelay.Worker/ # Background workers
├── tests/
│ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute
├── compose.yaml # Docker Compose (AMD64 + ARM64)
└── .env.example # Шаблон переменных окружения
```
--- ---
## 📜 Лицензия ## 📜 Лицензия
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
MIT License. См. [LICENSE](./LICENSE).
---
*Построено с ❤️ для TTRPG-сообщества.*
+48 -2
View File
@@ -16,8 +16,40 @@ services:
timeout: 3s timeout: 3s
retries: 10 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: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.5.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -25,11 +57,17 @@ services:
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
networks: networks:
- gmrelay - gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.5.0 image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -38,18 +76,26 @@ services:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}" - "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
ports: ports:
- "${GMRELAY_WEB_PORT:-8080}:8080" - "${GMRELAY_WEB_PORT:-8080}:8080"
volumes: volumes:
- web_keys:/app/dataprotection-keys - web_keys:/app/dataprotection-keys
networks: networks:
- gmrelay - gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes: volumes:
pgdata: pgdata:
name: ${POSTGRES_VOLUME_NAME:-game_pgdata} name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
web_keys: web_keys:
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys} name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
pgbackups:
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
networks: networks:
gmrelay: gmrelay:
@@ -0,0 +1,65 @@
# 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 ──► (issue #26)
```
### Изменённые компоненты
| Компонент | Было | Стало |
|---|---|---|
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
| `DiscordSessionBatchRenderer` | — | `GmRelay.Shared.Rendering` (stub) |
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
## Consequences
### Positive
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
- Можно добавить `DiscordSessionBatchRenderer` без изменений в `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 (потребитель новой архитектуры).
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
@@ -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,69 @@
# Telegram Mini App Dashboard 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 Telegram Mini App mobile dashboard that reuses the existing Web Dashboard and validates Telegram WebApp `initData` on the server.
**Architecture:** Extend `TelegramAuthService` for WebApp init data, add a `/miniapp` Blazor entry page plus `/auth/telegram-webapp` endpoint, and add bot entry points through an inline WebApp button and optional menu button setup. Existing application/domain services remain the only write path.
**Tech Stack:** .NET 10, Blazor Server, Telegram.Bot, xUnit, Dapper/Npgsql-backed existing services.
---
### Task 1: Telegram WebApp Authentication
**Files:**
- Modify: `src/GmRelay.Web/Services/TelegramAuthService.cs`
- Modify: `src/GmRelay.Web/Program.cs`
- Test: `tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs`
- [ ] Write failing tests for valid WebApp `initData`, tampered hash, and expired auth date.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramAuthServiceTests`.
- [ ] Implement WebApp HMAC verification using the Telegram `WebAppData` secret derivation.
- [ ] Add `/auth/telegram-webapp` endpoint that signs in using the same claims as `/auth/telegram`.
- [ ] Re-run the filtered tests.
### Task 2: Mini App Entry Page
**Files:**
- Create: `src/GmRelay.Web/Components/Pages/MiniApp.razor`
- Modify: `src/GmRelay.Web/Components/App.razor`
- Modify: `src/GmRelay.Web/wwwroot/app.css`
- Test: `tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs`
- [ ] Write failing tests that assert `/miniapp`, `telegram-web-app.js`, `authenticateTelegramMiniApp`, and Mini App CSS hooks exist.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter MiniAppDashboardTests`.
- [ ] Implement `/miniapp` to post `Telegram.WebApp.initData` to `/auth/telegram-webapp`, expand/ready the Mini App, and show fallback login when opened outside Telegram.
- [ ] Add CSS for a mobile-first Mini App shell and compact dashboard spacing.
- [ ] Re-run the filtered tests.
### Task 3: Bot Entry Points
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs`
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- Modify: `src/GmRelay.Bot/Program.cs`
- Test: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs`
- [ ] Write failing tests that assert `/start` exposes a WebApp button and startup registers the menu button service.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramMiniAppEntryPointTests`.
- [ ] Add a configurable `Telegram:MiniAppUrl` entry point; when missing, keep existing command behavior.
- [ ] Add hosted service that calls `SetChatMenuButton` with `MenuButtonWebApp` only when the URL is configured.
- [ ] Re-run the filtered tests.
### Task 4: Docs, Versions, and Release Prep
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/wwwroot/app.css`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- Modify: `README.md`
- Wiki: `Home`, `Быстрый старт`, `Руководство ГМа`, `Развёртывание`, `Архитектура`, `Разработка`
- [ ] Update project/container/workflow/UI versions to `1.9.0`.
- [ ] Document `TELEGRAM_MINI_APP_URL`, BotFather `/setmenubutton`, `/miniapp`, and WebApp auth.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"`.
- [ ] Run `dotnet build GM-Relay.slnx -c Release`.
- [ ] Commit, push, close issue #17, update wiki, create tag/release `v1.9.0`.
@@ -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,44 @@
# Telegram Mini App Dashboard Design
## Goal
Issue #17 adds a Telegram Mini App dashboard as the mobile entry point for the existing Web Dashboard. Owner and co-GM users must be able to open the dashboard from Telegram, pass server-side Telegram WebApp `initData` validation, and manage only their own groups.
## Scope
- Add Mini App authentication using Telegram WebApp `initData`.
- Add a `/miniapp` entry page that signs the user into the existing cookie auth flow, then opens the regular dashboard UI in mobile-first mode.
- Reuse `AuthorizedSessionService`, `SessionService`, and existing Blazor pages for groups, sessions, templates, waitlist promotion, edit forms, and bulk batch operations.
- Add bot entry points: a Mini App button in `/start` and a configurable default menu button when `Telegram:MiniAppUrl` is set.
- Update README, wiki, deployment config, and visible version strings to `1.9.0`.
## Architecture
The Mini App is not a second dashboard implementation. It is a Telegram-authenticated entrance into the existing Blazor dashboard. This keeps authorization, domain operations, Telegram message synchronization, and Web Dashboard behavior in one place.
`TelegramAuthService` gains a second verification method for WebApp `initData`. The server accepts the raw URL-encoded init payload at `/auth/telegram-webapp`, verifies the Telegram HMAC with the bot token, extracts the user id/name from the embedded `user` JSON, and issues the same auth cookie as the login widget endpoint.
`/miniapp` loads `telegram-web-app.js`, posts `window.Telegram.WebApp.initData` to the server endpoint, expands the WebApp viewport, and redirects to `/`. If a user opens `/miniapp` outside Telegram, the page shows the regular login fallback.
## Data Flow
1. User opens the Mini App from the bot menu button or `/start` inline button.
2. Telegram injects `initData` into the WebApp JavaScript API.
3. `/miniapp` posts `{ initData }` to `/auth/telegram-webapp`.
4. The server verifies the WebApp signature and expiry.
5. The server creates the same claims used by Telegram Login Widget.
6. Existing Blazor pages load groups through `AuthorizedSessionService`.
7. Any edit, waitlist, template, or batch action still goes through existing services and keeps Telegram messages synchronized.
## Error Handling
- Missing or invalid init data returns `401` and leaves the user on the Mini App page.
- Expired auth data is rejected with the same 24-hour window used by the Login Widget.
- A verified Telegram user with no owner/co-GM groups sees the existing empty dashboard state.
- Direct navigation to a foreign group/session still redirects to `/access-denied` through existing authorization checks.
## Testing
- Unit tests cover valid and invalid WebApp `initData`.
- File-level regression tests ensure `/miniapp`, `/auth/telegram-webapp`, Telegram WebApp script loading, bot Mini App button, menu button setup, and mobile Mini App CSS hooks remain present.
- Existing `AuthorizedSessionServiceTests` continue covering owner/co-GM access behavior.
+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 "=================================================="
+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 FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
WORKDIR /app WORKDIR /app
# Устанавливаем wget для healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
# Копируем только AOT-результаты из билда # Копируем только AOT-результаты из билда
COPY --from=build /app/publish . COPY --from=build /app/publish .
EXPOSE 8081
USER $APP_UID
# Запуск скомпилированного AOT бинарного файла напрямую # Запуск скомпилированного AOT бинарного файла напрямую
ENTRYPOINT ["./GmRelay.Bot"] ENTRYPOINT ["./GmRelay.Bot"]
@@ -21,7 +21,8 @@ internal sealed record SessionContext(
DateTime ScheduledAt, DateTime ScheduledAt,
string Status, string Status,
long GmTelegramId, long GmTelegramId,
long TelegramChatId); long TelegramChatId,
int? ThreadId);
internal sealed record ParticipantRsvp( internal sealed record ParticipantRsvp(
long TelegramId, long TelegramId,
@@ -95,7 +96,8 @@ public sealed class HandleRsvpHandler(
s.scheduled_at AS ScheduledAt, s.scheduled_at AS ScheduledAt,
s.status AS Status, s.status AS Status,
g.gm_telegram_id AS GmTelegramId, g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId WHERE s.id = @SessionId
@@ -191,6 +193,7 @@ public sealed class HandleRsvpHandler(
{ {
await bot.SendMessage( await bot.SendMessage(
chatId: session.TelegramChatId, chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.", text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct); cancellationToken: ct);
} }
@@ -0,0 +1,6 @@
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
public interface ISendConfirmationHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -15,6 +15,7 @@ internal sealed record SessionInfo(
DateTime ScheduledAt, DateTime ScheduledAt,
Guid GroupId, Guid GroupId,
long TelegramChatId, long TelegramChatId,
int? ThreadId,
string NotificationMode); string NotificationMode);
internal sealed record ParticipantInfo( internal sealed record ParticipantInfo(
@@ -32,7 +33,7 @@ public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<SendConfirmationHandler> logger) ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
{ {
public async Task HandleAsync(Guid sessionId, CancellationToken ct) public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{ {
@@ -43,6 +44,7 @@ public sealed class SendConfirmationHandler(
""" """
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId, SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
@@ -99,18 +101,21 @@ public sealed class SendConfirmationHandler(
// 4. Send to group // 4. Send to group
var message = await bot.SendMessage( var message = await bot.SendMessage(
chatId: session.TelegramChatId, chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: text, text: text,
replyMarkup: keyboard, replyMarkup: keyboard,
cancellationToken: ct); cancellationToken: ct);
// 5. Update session status and store message ID // 5. Update session status, store message ID, and mark confirmation sent
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions UPDATE sessions
SET status = @Status, SET status = @Status,
confirmation_message_id = @MessageId, confirmation_message_id = @MessageId,
confirmation_sent_at = now(),
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
AND confirmation_sent_at IS NULL
""", """,
new new
{ {
@@ -1,12 +1,12 @@
using Telegram.Bot; using GmRelay.Bot.Infrastructure.Telegram;
using Telegram.Bot.Types.Enums; using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Notifications; namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName); public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender( public sealed class DirectSessionNotificationSender(
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<DirectSessionNotificationSender> logger) ILogger<DirectSessionNotificationSender> logger)
{ {
public async Task SendAsync( public async Task SendAsync(
@@ -20,11 +20,11 @@ public sealed class DirectSessionNotificationSender(
{ {
try try
{ {
await bot.SendMessage( await messenger.SendPrivateMessageAsync(
chatId: recipient.TelegramId, new PlatformPrivateMessage(
text: htmlText, TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
parseMode: ParseMode.Html, htmlText),
cancellationToken: ct); ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,6 @@
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
public interface ISendJoinLinkHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -14,6 +14,7 @@ internal sealed record JoinLinkSession(
string JoinLink, string JoinLink,
DateTime ScheduledAt, DateTime ScheduledAt,
long TelegramChatId, long TelegramChatId,
int? ThreadId,
string NotificationMode); string NotificationMode);
internal sealed record ConfirmedPlayer( internal sealed record ConfirmedPlayer(
@@ -31,7 +32,7 @@ public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger) ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
{ {
public async Task HandleAsync(Guid sessionId, CancellationToken ct) public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{ {
@@ -42,6 +43,7 @@ public sealed class SendJoinLinkHandler(
""" """
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt, SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
@@ -94,6 +96,7 @@ public sealed class SendJoinLinkHandler(
// 4. Send // 4. Send
var message = await bot.SendMessage( var message = await bot.SendMessage(
chatId: session.TelegramChatId, chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: text, text: text,
cancellationToken: ct); cancellationToken: ct);
@@ -0,0 +1,6 @@
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
public interface ISendOneHourReminderHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -15,7 +15,7 @@ internal sealed record OneHourReminderSession(
public sealed class SendOneHourReminderHandler( public sealed class SendOneHourReminderHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger) ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
{ {
public async Task HandleAsync(Guid sessionId, CancellationToken ct) public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{ {
@@ -1,10 +1,10 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using GmRelay.Bot.Infrastructure.Telegram;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -13,14 +13,15 @@ public sealed record CancelSessionCommand(
long TelegramUserId, long TelegramUserId,
string CallbackQueryId, string CallbackQueryId,
long ChatId, long ChatId,
int? MessageThreadId,
int MessageId); int MessageId);
// DTOs for AOT compilation // DTOs for AOT compilation
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId, string NotificationMode); internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, string NotificationMode);
public sealed class CancelSessionHandler( public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger) ILogger<CancelSessionHandler> logger)
{ {
@@ -28,24 +29,35 @@ public sealed class CancelSessionHandler(
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает ГМ данной сессии // 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>( var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId, s.notification_mode as NotificationMode """
FROM sessions s SELECT s.title AS Title,
JOIN game_groups g ON s.group_id = g.id s.batch_id AS BatchId,
WHERE s.id = @SessionId", s.batch_message_id AS BatchMessageId,
new { command.SessionId }, transaction); s.notification_mode AS NotificationMode,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null) if (session == null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", ct, showAlert: true);
return; return;
} }
@@ -57,7 +69,7 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( 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 FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at", ORDER BY scheduled_at",
@@ -91,22 +103,25 @@ public sealed class CancelSessionHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
try try
{ {
await bot.EditMessageText( var messageId = session.BatchMessageId ?? command.MessageId;
chatId: command.ChatId, await messenger.UpdateScheduleAsync(
messageId: command.MessageId, new PlatformScheduleMessage(
text: renderResult.Text, TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, view,
replyMarkup: renderResult.Markup, TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
cancellationToken: ct); 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); var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
@@ -122,7 +137,10 @@ public sealed class CancelSessionHandler(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId); 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,9 +4,12 @@ using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler( public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient botClient, ITelegramBotClient botClient,
@@ -14,7 +17,7 @@ public sealed class CreateSessionHandler(
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) 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) foreach (var timeInput in parseResult.PastTimeInputs)
{ {
@@ -40,17 +43,26 @@ public sealed class CreateSessionHandler(
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid) if (!parseResult.IsValid)
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, 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", 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); cancellationToken: cancellationToken);
return; return;
} }
var title = parseResult.Title!; var title = parseResult.Title!;
var link = parseResult.Link!; var link = parseResult.Link!;
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id; var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}"); var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username; var gmUsername = message.From.Username;
@@ -65,33 +77,101 @@ public sealed class CreateSessionHandler(
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
INSERT INTO players (telegram_id, display_name, telegram_username) INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username) VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
ON CONFLICT (telegram_id) DO UPDATE ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name, 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 }, new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction); transaction);
var groupId = await connection.ExecuteScalarAsync<Guid>( var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
""" """
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) SELECT g.id AS GroupId,
VALUES (@ChatId, @ChatName, @GmId) EXISTS (
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name SELECT 1
RETURNING id; FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
) AS CanManage
FROM game_groups g
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
""", """,
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, new { ChatId = chatId, GmId = gmId },
transaction); transaction);
int? messageThreadId = null; Guid groupId;
if (message.Chat.IsForum) if (existingGroup is null)
{ {
var topic = await botClient.CreateForumTopic( groupId = await connection.ExecuteScalarAsync<Guid>(
chatId: chatId, """
name: $"🎲 Игры: {title}", INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
cancellationToken: cancellationToken); VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
messageThreadId = topic.MessageThreadId; RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
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 },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
cancellationToken: cancellationToken);
return;
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
new { ChatName = chatTitle, GroupId = groupId },
transaction);
}
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
if (topicDestination.ShouldCreateForumTopic)
{
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(); var batchId = Guid.NewGuid();
@@ -101,8 +181,8 @@ public sealed class CreateSessionHandler(
{ {
var sessionId = await connection.ExecuteScalarAsync<Guid>( var sessionId = await connection.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players) 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, @MaxPlayers) VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
RETURNING id; RETURNING id;
""", """,
new new
@@ -113,26 +193,78 @@ public sealed class CreateSessionHandler(
Link = link, Link = link,
ScheduledAt = scheduledAt, ScheduledAt = scheduledAt,
ThreadId = messageThreadId, ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
MaxPlayers = parseResult.MaxPlayers, MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned Status = SessionStatus.Planned
}, },
transaction); 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); await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); 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( Message batchMessage;
chatId: chatId,
messageThreadId: messageThreadId, if (imageReference is not null && renderResult.Text.Length <= 1024)
text: renderResult.Text, {
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, // Картинка + расписание умещаются в одном Telegram-фото с подписью
replyMarkup: renderResult.Markup, try
cancellationToken: cancellationToken); {
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( await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
@@ -157,4 +289,20 @@ public sealed class CreateSessionHandler(
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken); 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();
}
} }
@@ -1,9 +1,9 @@
using Dapper; using Dapper;
using Npgsql; using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -21,7 +21,7 @@ internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxP
public sealed class JoinSessionHandler( public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<JoinSessionHandler> logger) ILogger<JoinSessionHandler> logger)
{ {
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct) public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
@@ -34,9 +34,14 @@ public sealed class JoinSessionHandler(
{ {
// 1. Убеждаемся, что игрок есть в базе // 1. Убеждаемся, что игрок есть в базе
var playerId = await connection.ExecuteScalarAsync<Guid>( var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username) @"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @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 ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
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)
RETURNING id;", RETURNING id;",
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername }, new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
transaction); transaction);
@@ -53,7 +58,7 @@ public sealed class JoinSessionHandler(
if (batchInfo is null) if (batchInfo is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
@@ -74,7 +79,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!" ? "Вы уже в листе ожидания!"
: "Вы уже записаны!"; : "Вы уже записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, alreadyText, ct);
return; return;
} }
@@ -108,13 +113,13 @@ public sealed class JoinSessionHandler(
if (inserted == 0) if (inserted == 0)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct);
return; return;
} }
// Загружаем весь батч для перерисовки // Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( 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 FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at", ORDER BY scheduled_at",
@@ -136,20 +141,18 @@ public sealed class JoinSessionHandler(
transactionCommitted = true; transactionCommitted = true;
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync(
await bot.EditMessageText( new PlatformScheduleMessage(
chatId: command.ChatId, TelegramPlatformIds.Group(command.ChatId),
messageId: command.MessageId, view,
text: renderResult.Text, TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, ct);
replyMarkup: renderResult.Markup,
cancellationToken: ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания." ? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!"; : "Вы успешно записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, callbackText, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -162,7 +165,10 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted 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) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
} }
@@ -1,8 +1,9 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -19,7 +20,7 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler( public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<LeaveSessionHandler> logger) ILogger<LeaveSessionHandler> logger)
{ {
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
@@ -46,14 +47,14 @@ public sealed class LeaveSessionHandler(
if (session is null) if (session is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (SessionStatus.IsCancelled(session.Status)) if (SessionStatus.IsCancelled(session.Status))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct);
return; return;
} }
@@ -75,7 +76,7 @@ public sealed class LeaveSessionHandler(
if (participant is null) if (participant is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct);
return; return;
} }
@@ -156,7 +157,8 @@ public sealed class LeaveSessionHandler(
SELECT id AS SessionId, SELECT id AS SessionId,
scheduled_at AS ScheduledAt, scheduled_at AS ScheduledAt,
status AS Status, status AS Status,
max_players AS MaxPlayers max_players AS MaxPlayers,
join_link AS JoinLink
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at ORDER BY scheduled_at
@@ -182,15 +184,13 @@ public sealed class LeaveSessionHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
transactionCommitted = true; transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
await bot.EditMessageText( new PlatformScheduleMessage(
chatId: command.ChatId, TelegramPlatformIds.Group(command.ChatId),
messageId: command.MessageId, view,
text: renderResult.Text, TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, ct);
replyMarkup: renderResult.Markup,
cancellationToken: ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания." ? "Вы удалены из листа ожидания."
@@ -198,7 +198,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии." ? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, callbackText, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -211,7 +211,10 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted 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) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
} }
@@ -5,40 +5,66 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult( internal sealed record NewSessionParseResult(
string? Title, string? Title,
string? Link, string? Link,
string? ImageUrl,
int? MaxPlayers, int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes, IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs, IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs, IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs) IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{ {
public bool IsValid => public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) && !string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) && !string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 && ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0; InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
} }
internal static class NewSessionCommandParser internal static class NewSessionCommandParser
{ {
private const int MaxRecurringSessionCount = 52;
private const int MaxRecurringIntervalDays = 365;
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:"; private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; 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 = private static readonly string[] SeatLimitPrefixes =
[ [
"\u041c\u0435\u0441\u0442:", "\u041c\u0435\u0441\u0442:",
"\u041b\u0438\u043c\u0438\u0442:", "\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:" "\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
]; ];
private static readonly string[] RecurringCountPrefixes =
[
"\u0418\u0433\u0440:",
"\u0421\u0435\u0441\u0441\u0438\u0439:",
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
];
private static readonly string[] RecurringIntervalPrefixes =
[
"\u0428\u0430\u0433:",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc) public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{ {
string? title = null; string? title = null;
string? link = null; string? link = null;
string? imageUrl = null;
int? maxPlayers = null; int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>(); var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>(); var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>(); var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>(); var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries)) foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{ {
@@ -54,6 +80,14 @@ internal static class NewSessionCommandParser
continue; 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 => var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null) if (seatLimitPrefix is not null)
@@ -71,6 +105,42 @@ internal static class NewSessionCommandParser
continue; continue;
} }
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringCountPrefix is not null)
{
var recurringInput = line[recurringCountPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedCount) &&
parsedCount is >= 1 and <= MaxRecurringSessionCount)
{
recurringCount = parsedCount;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringIntervalPrefix is not null)
{
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedInterval) &&
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
{
recurringIntervalDays = parsedInterval;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase)) if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
@@ -92,13 +162,23 @@ internal static class NewSessionCommandParser
scheduledTimes.Add(scheduledAt); scheduledTimes.Add(scheduledAt);
} }
if (recurringCount.HasValue && scheduledTimes.Count == 1)
{
var firstScheduledTime = scheduledTimes[0];
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
.ToList();
}
return new NewSessionParseResult( return new NewSessionParseResult(
title, title,
link, link,
imageUrl,
maxPlayers, maxPlayers,
scheduledTimes, scheduledTimes,
pastTimeInputs, pastTimeInputs,
invalidTimeInputs, invalidTimeInputs,
invalidSeatLimitInputs); invalidSeatLimitInputs,
invalidRecurringInputs);
} }
} }
@@ -1,8 +1,9 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -13,12 +14,12 @@ public sealed record PromoteWaitlistedPlayerCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers); internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, int? MaxPlayers);
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName); internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler( public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<PromoteWaitlistedPlayerHandler> logger) ILogger<PromoteWaitlistedPlayerHandler> logger)
{ {
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct) public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
@@ -33,27 +34,33 @@ public sealed class PromoteWaitlistedPlayerHandler(
""" """
SELECT s.title AS Title, SELECT s.title AS Title,
s.batch_id AS BatchId, s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
g.gm_telegram_id AS GmId EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId WHERE s.id = @SessionId
FOR UPDATE FOR UPDATE
""", """,
new { command.SessionId }, new { command.SessionId, command.TelegramUserId },
transaction); transaction);
if (session is null) if (session is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", ct, showAlert: true);
return; return;
} }
@@ -82,14 +89,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (waitlistedParticipants == 0) if (waitlistedParticipants == 0)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
return; return;
} }
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants)) if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
return; return;
} }
@@ -130,7 +137,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
SELECT id AS SessionId, SELECT id AS SessionId,
scheduled_at AS ScheduledAt, scheduled_at AS ScheduledAt,
status AS Status, status AS Status,
max_players AS MaxPlayers max_players AS MaxPlayers,
join_link AS JoinLink
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at ORDER BY scheduled_at
@@ -156,17 +164,16 @@ public sealed class PromoteWaitlistedPlayerHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
transactionCommitted = true; 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( await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
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);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -179,7 +186,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
var errorText = transactionCommitted 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 System.Text;
using Dapper; using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql; using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar; namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -11,30 +13,31 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
public sealed class ExportCalendarHandler( public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient botClient) IPlatformMessenger messenger,
IConfiguration configuration)
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{ {
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>( var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
FROM sessions s + " FROM sessions s"
JOIN game_groups g ON s.group_id = g.id + " JOIN game_groups g ON s.group_id = g.id"
WHERE g.telegram_chat_id = @ChatId + " WHERE g.telegram_chat_id = @ChatId"
AND s.status = @Planned + " AND s.status = @Planned"
AND s.scheduled_at > NOW() + " AND s.scheduled_at > NOW()"
ORDER BY s.scheduled_at ASC", + " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned }); new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
if (sessionsList.Count == 0) if (sessionsList.Count == 0)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(
chatId: message.Chat.Id, TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
text: "📭 У этой группы нет запланированных сессий для экспорта.", "📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken: cancellationToken); cancellationToken);
return; return;
} }
@@ -54,24 +57,57 @@ public sealed class ExportCalendarHandler(
sb.AppendLine($"DTSTART:{dtStart}"); sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}"); sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}"); 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:VEVENT");
} }
sb.AppendLine("END:VCALENDAR"); sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString()); var bytes = Encoding.UTF8.GetBytes(sb.ToString());
using var stream = new MemoryStream(bytes);
var inputFile = InputFile.FromStream(stream, "schedule.ics");
await botClient.SendDocument( // Create calendar subscription
chatId: message.Chat.Id, string? subscriptionUrl = null;
document: inputFile, var baseUrl = configuration["Web:BaseUrl"];
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", var senderId = message.From?.Id;
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
messageThreadId: message.MessageThreadId, {
cancellationToken: cancellationToken); 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 Dapper;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.ListSessions; namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -12,7 +13,13 @@ public sealed record DeleteSessionCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId); internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed class DeleteSessionHandler( public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -24,13 +31,25 @@ public sealed class DeleteSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Fetch session and verify GM // 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>( var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId """
FROM sessions s SELECT s.title AS Title,
JOIN game_groups g ON s.group_id = g.id s.batch_id AS BatchId,
WHERE s.id = @SessionId", s.group_id AS GroupId,
new { command.SessionId }, transaction); s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null) if (session == null)
{ {
@@ -38,24 +57,32 @@ public sealed class DeleteSessionHandler(
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return; return;
} }
// 2. Delete session // 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction); 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 remainingInTopic = session.ThreadId.HasValue
var remainingInBatch = await connection.ExecuteScalarAsync<int>( ? await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId", """
new { BatchId = session.BatchId }, transaction); 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); await transaction.CommitAsync(ct);
// 4. If no sessions left and we have a forum topic, delete the topic // 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (remainingInBatch == 0 && session.ThreadId.HasValue) if (session.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
{ {
try try
{ {
@@ -77,16 +104,23 @@ public sealed class DeleteSessionHandler(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, @"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 = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
g.gm_telegram_id as GmId EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new new
{ {
ChatId = command.ChatId, ChatId = command.ChatId,
command.TelegramUserId,
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -96,34 +130,20 @@ public sealed class DeleteSessionHandler(
if (sessionsList.Count == 0) 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; return;
} }
var text = "📅 <b>Ближайшие игры:</b>\n\n"; var renderResult = SessionListMessageRenderer.Render(sessionsList);
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 isGm = command.TelegramUserId == sessionsList.First().GmId;
var keyboard = isGm
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
command.ChatId, command.ChatId,
command.MessageId, command.MessageId,
text, renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard, replyMarkup: renderResult.Markup,
cancellationToken: ct); cancellationToken: ct);
} }
catch (Exception ex) catch (Exception ex)
@@ -3,10 +3,59 @@ using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions; namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId); 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( public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -20,16 +69,23 @@ public sealed class ListSessionsHandler(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, @"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 = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
g.gm_telegram_id as GmId EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new new
{ {
ChatId = message.Chat.Id, ChatId = message.Chat.Id,
TelegramUserId = message.From?.Id,
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -46,27 +102,13 @@ public sealed class ListSessionsHandler(
return; return;
} }
var text = "📅 <b>Ближайшие игры:</b>\n\n"; var renderResult = SessionListMessageRenderer.Render(sessionsList);
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 isGm = message.From?.Id == sessionsList.First().GmId;
var keyboard = isGm
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, chatId: message.Chat.Id,
text: text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard, replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
} }
@@ -1,11 +1,13 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -13,7 +15,7 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto( internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt, Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode); Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
internal sealed record VoteParticipantDto( internal sealed record VoteParticipantDto(
Guid PlayerId, Guid PlayerId,
@@ -25,12 +27,14 @@ internal sealed record VoteParticipantDto(
/// <summary> /// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal. /// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses the new time, creates a voting message, and tags all participants. /// Parses reschedule options with a voting deadline, creates a voting message,
/// and tags all participants.
/// If no participants are registered, reschedules immediately. /// If no participants are registered, reschedules immediately.
/// </summary> /// </summary>
public sealed class HandleRescheduleTimeInputHandler( public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger) ILogger<HandleRescheduleTimeInputHandler> logger)
{ {
@@ -55,6 +59,7 @@ public sealed class HandleRescheduleTimeInputHandler(
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, 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, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM reschedule_proposals rp FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id JOIN sessions s ON s.id = rp.session_id
@@ -62,6 +67,13 @@ public sealed class HandleRescheduleTimeInputHandler(
WHERE rp.proposed_by = @GmId WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime' AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId AND g.telegram_chat_id = @ChatId
AND EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @GmId
)
ORDER BY rp.created_at DESC ORDER BY rp.created_at DESC
LIMIT 1 LIMIT 1
""", """,
@@ -70,23 +82,13 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal is null) if (proposal is null)
return false; return false;
// 2. Parse the new time // 2. Parse voting input
if (!MoscowTime.TryParseMoscow(text, out var newTime)) if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{ {
await bot.SendMessage( await messenger.SendGroupMessageAsync(
chatId: chatId, TelegramPlatformIds.Group(chatId, proposal.ThreadId),
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>", $"⚠️ {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, ct);
cancellationToken: ct);
return true;
}
if (newTime <= DateTimeOffset.UtcNow)
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
cancellationToken: ct);
return true; return true;
} }
@@ -108,38 +110,60 @@ public sealed class HandleRescheduleTimeInputHandler(
// 4. If no participants — reschedule immediately // 4. If no participants — reschedule immediately
if (participants.Count == 0) if (participants.Count == 0)
{ {
await RescheduleImmediately(connection, proposal, newTime, chatId, ct); await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct); await TryDeleteMessage(chatId, message.MessageId, ct);
return true; return true;
} }
// 5. Create voting message // 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
// Update proposal with proposed time and Voting status
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE reschedule_proposals UPDATE reschedule_proposals
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id WHERE id = @Id
""", """,
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id }, new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
transaction); transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Build voting message text var voteText = BuildVotingMessage(
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []); proposal.Title,
proposal.CurrentScheduledAt,
var keyboard = new InlineKeyboardMarkup([ votingInput.Deadline,
[ options,
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"), participants,
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}") []);
] var keyboard = BuildVotingKeyboard(options);
]);
var voteMsg = await bot.SendMessage( var voteMsg = await bot.SendMessage(
chatId: chatId, chatId: chatId,
messageThreadId: proposal.ThreadId,
text: voteText, text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard, replyMarkup: keyboard,
@@ -148,12 +172,18 @@ public sealed class HandleRescheduleTimeInputHandler(
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
{ {
var optionsText = string.Join(
"\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $""" var directText = $"""
🔄 <b>Голосование за перенос сессии</b> 🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b> 📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК) 📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
📅 Новое время: <b>{newTime.FormatMoscow()}</b> (МСК) 🗳 Варианты:
{optionsText}
Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении. Проголосуйте кнопкой в групповом сообщении.
"""; """;
@@ -173,7 +203,12 @@ public sealed class HandleRescheduleTimeInputHandler(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id", "UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id }); new { MsgId = voteMsg.MessageId, Id = proposal.Id });
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id); logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
proposal.SessionId,
proposal.Id,
options.Count,
votingInput.Deadline);
// Delete GM's time input message // Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct); await TryDeleteMessage(chatId, message.MessageId, ct);
@@ -192,6 +227,8 @@ public sealed class HandleRescheduleTimeInputHandler(
UPDATE sessions UPDATE sessions
SET scheduled_at = @NewTime, SET scheduled_at = @NewTime,
status = @Status, status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL, one_hour_reminder_processed_at = NULL,
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
@@ -206,11 +243,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
await bot.SendMessage( await messenger.SendGroupMessageAsync(
chatId: chatId, TelegramPlatformIds.Group(chatId, proposal.ThreadId),
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>", $"✅ Сессия «{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, ct);
cancellationToken: ct);
// Re-render batch message with updated time // Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct); await TryUpdateBatchMessage(proposal, ct);
@@ -219,33 +255,105 @@ public sealed class HandleRescheduleTimeInputHandler(
} }
internal static string BuildVotingMessage( internal static string BuildVotingMessage(
string title, DateTime currentTime, DateTimeOffset newTime, string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants, IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyCollection<Guid> approvedPlayerIds) 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 pendingParticipants = participants
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
.Select(FormatParticipantName)
.ToList();
var lines = new List<string> var lines = new List<string>
{ {
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>", $"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
"", "",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)", $"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)", $"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
"", "",
"Для переноса нужно согласие всех участников:" "Выберите один из вариантов:"
}; };
foreach (var p in participants) foreach (var option in options.OrderBy(x => x.DisplayOrder))
{ {
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳"; lines.Add(
lines.Add($" {icon} {name}"); $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
if (optionVotes.Count > 0)
{
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
}
}
if (pendingParticipants.Count > 0)
{
lines.Add("");
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
} }
lines.Add(""); lines.Add("");
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count}"); lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
return string.Join("\n", lines); return string.Join("\n", lines);
} }
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
{
return new InlineKeyboardMarkup(
options
.OrderBy(option => option.DisplayOrder)
.Select(option => new[]
{
InlineKeyboardButton.WithCallbackData(
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
$"reschedule_vote:{option.OptionId}")
}));
}
internal static string FormatParticipantName(VoteParticipantDto participant)
{
return participant.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
}
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
{
return vote.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
}
private static string FormatVoteCount(int count)
{
var modulo100 = count % 100;
var modulo10 = count % 10;
var word = modulo100 is >= 11 and <= 14
? "голосов"
: modulo10 switch
{
1 => "голос",
>= 2 and <= 4 => "голоса",
_ => "голосов"
};
return $"{count} {word}";
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct) private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{ {
try try
@@ -253,7 +361,7 @@ public sealed class HandleRescheduleTimeInputHandler(
await using var conn = await dataSource.OpenConnectionAsync(ct); await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>( 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(); new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -272,16 +380,14 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
var renderResult = SessionBatchRenderer.Render( var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await messenger.UpdateScheduleAsync(
chatId: proposal.TelegramChatId, new PlatformScheduleMessage(
messageId: proposal.BatchMessageId.Value, TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
text: renderResult.Text, view,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
replyMarkup: renderResult.Markup, ct);
cancellationToken: ct);
} }
else else
{ {
@@ -1,16 +1,13 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleVoteCommand( public sealed record HandleRescheduleVoteCommand(
Guid ProposalId, Guid OptionId,
string Vote,
long TelegramUserId, long TelegramUserId,
string CallbackQueryId, string CallbackQueryId,
long ChatId, long ChatId,
@@ -19,20 +16,14 @@ public sealed record HandleRescheduleVoteCommand(
internal sealed record VoteProposalDto( internal sealed record VoteProposalDto(
Guid Id, Guid Id,
Guid SessionId, Guid SessionId,
DateTime ProposedAt, DateTimeOffset VotingDeadlineAt,
string Title, string Title,
DateTime CurrentScheduledAt, DateTime CurrentScheduledAt);
Guid BatchId,
string SessionStatus,
long TelegramChatId,
int? ConfirmationMessageId,
int? BatchMessageId,
string NotificationMode);
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
DirectSessionNotificationSender directSender, IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger) ILogger<HandleRescheduleVoteHandler> logger)
{ {
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
@@ -44,29 +35,26 @@ public sealed class HandleRescheduleVoteHandler(
""" """
SELECT rp.id AS Id, SELECT rp.id AS Id,
rp.session_id AS SessionId, rp.session_id AS SessionId,
rp.proposed_at AS ProposedAt, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.title AS Title,
s.scheduled_at AS CurrentScheduledAt, s.scheduled_at AS CurrentScheduledAt
s.batch_id AS BatchId, FROM reschedule_options ro
s.status AS SessionStatus, JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
s.confirmation_message_id AS ConfirmationMessageId,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id WHERE ro.id = @OptionId AND rp.status = 'Voting'
WHERE rp.id = @ProposalId AND rp.status = 'Voting'
""", """,
new { command.ProposalId }, new { command.OptionId },
transaction); transaction);
if (proposal is null) if (proposal is null)
{ {
await bot.AnswerCallbackQuery( await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
command.CallbackQueryId, return;
"Голосование уже завершено или не найдено.", }
cancellationToken: ct);
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
return; return;
} }
@@ -85,277 +73,97 @@ public sealed class HandleRescheduleVoteHandler(
if (playerId is null) if (playerId is null)
{ {
await bot.AnswerCallbackQuery( await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return; return;
} }
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_votes (proposal_id, player_id, vote) INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @Vote) VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE ON CONFLICT (proposal_id, player_id) DO UPDATE
SET vote = EXCLUDED.vote, SET option_id = EXCLUDED.option_id,
voted_at = now() voted_at = now()
""", """,
new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction); transaction);
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) var participants = (await connection.QueryAsync<VoteParticipantDto>(
? new List<VoteParticipantDto>()
: (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
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
? new HashSet<Guid>()
: (await connection.QueryAsync<Guid>(
"""
SELECT player_id
FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
""",
new { command.ProposalId },
transaction)).ToHashSet();
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
{
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
await transaction.CommitAsync(ct);
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId });
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after rejection");
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"❌ <b>Перенос сессии отклонён</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
"reschedule-rejected",
proposal.SessionId,
ct);
}
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return;
}
if (decision.ShouldRescheduleSession)
{
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
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 = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
if (decision.ShouldResetParticipantRsvps)
{
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 transaction.CommitAsync(ct);
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after approval");
}
await TryUpdateBatchMessage(proposal, ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"✅ <b>Сессия перенесена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)",
"reschedule-approved",
proposal.SessionId,
ct);
}
logger.LogInformation(
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
newTime,
command.ProposalId);
}
else
{
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
participants,
approvedPlayerIds);
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
]
]);
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message with progress");
}
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
}
private static async Task<List<DirectNotificationRecipient>> LoadDirectRecipients(
Npgsql.NpgsqlConnection connection,
Guid sessionId,
Npgsql.NpgsqlTransaction transaction)
{
return (await connection.QueryAsync<DirectNotificationRecipient>(
""" """
SELECT p.telegram_id AS TelegramId, SELECT p.id AS PlayerId,
p.display_name AS DisplayName p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND sp.is_gm = false AND sp.is_gm = false
AND sp.registration_status = @Active AND sp.registration_status = @Active
ORDER BY p.display_name
""", """,
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList(); transaction)).ToList();
}
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) 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();
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
try try
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); await bot.EditMessageText(
chatId: command.ChatId,
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( messageId: command.MessageId,
"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", text: voteText,
new { proposal.BatchId })).ToList(); parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( cancellationToken: ct);
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.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, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(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);
}
else
{
await bot.SendMessage(
chatId: proposal.TelegramChatId,
text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id); logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
} }
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 Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -12,48 +13,54 @@ public sealed record InitiateRescheduleCommand(
long TelegramUserId, long TelegramUserId,
string CallbackQueryId, string CallbackQueryId,
long ChatId, long ChatId,
int? MessageThreadId,
int MessageId); int MessageId);
// ── DTOs ───────────────────────────────────────────────────────────── // ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RescheduleSessionInfoDto(string Title, long GmId); internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
// ── Handler ────────────────────────────────────────────────────────── // ── Handler ──────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Handles the "⏰ Перенести" button press from the batch message. /// Handles the "⏰ Перенести" button press from the batch message.
/// Creates a reschedule proposal in AwaitingTime status and prompts /// Creates a reschedule proposal in AwaitingTime status and prompts
/// the GM to enter the new time via a regular text message. /// the GM to enter 2-3 new time options and a voting deadline.
/// </summary> /// </summary>
public sealed class InitiateRescheduleHandler( public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<InitiateRescheduleHandler> logger) ILogger<InitiateRescheduleHandler> logger)
{ {
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct) public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Verify GM ownership // 1. Verify group management access.
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>( var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
""" """
SELECT s.title AS Title, g.gm_telegram_id AS GmId SELECT s.title AS Title,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != @Cancelled WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId, Cancelled = SessionStatus.Cancelled }); new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -69,8 +76,7 @@ public sealed class InitiateRescheduleHandler(
if (hasActive) if (hasActive)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -85,13 +91,28 @@ public sealed class InitiateRescheduleHandler(
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId); logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
// 4. Prompt GM in chat // 4. Prompt GM in chat
await bot.AnswerCallbackQuery(command.CallbackQueryId, await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
await bot.SendMessage( var prompt = string.Join(
chatId: command.ChatId, "\n",
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>", new[]
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, {
cancellationToken: ct); $"⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.",
"",
"Формат:",
"<code>25.04.2026 19:30",
"26.04.2026 18:00",
"Дедлайн: 25.04.2026 12:00</code>",
"",
"Дедлайн должен быть в будущем и раньше первого предложенного времени."
});
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);
} }
@@ -9,27 +9,57 @@ internal enum RescheduleVoteOutcome
internal sealed record RescheduleVoteDecision( internal sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome, RescheduleVoteOutcome Outcome,
string CallbackText, string Reason,
bool ShouldRescheduleSession, Guid? SelectedOptionId = null,
bool ShouldResetParticipantRsvps); string CallbackText = "",
bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false);
internal static class RescheduleVoteRules internal static class RescheduleVoteRules
{ {
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{
var maxVotes = voteCounts.Count == 0 ? 0 : voteCounts.Max(x => x.VoteCount);
if (maxVotes == 0)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Никто не проголосовал до дедлайна, перенос не применяется.");
}
var winners = voteCounts.Where(x => x.VoteCount == maxVotes).ToList();
if (winners.Count > 1)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Голоса разделились поровну, перенос не применяется.");
}
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Approved,
"Победил вариант с большинством голосов.",
winners[0].OptionId,
ShouldRescheduleSession: true,
ShouldResetParticipantRsvps: true);
}
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants) public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
{ {
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase)) if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
{ {
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected, Outcome: RescheduleVoteOutcome.Rejected,
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.", Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.",
ShouldRescheduleSession: false, CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.");
ShouldResetParticipantRsvps: false);
} }
var everyoneApproved = approvedParticipants == totalParticipants; var everyoneApproved = approvedParticipants == totalParticipants;
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending, Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
Reason: everyoneApproved
? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b."
: "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.",
CallbackText: everyoneApproved CallbackText: everyoneApproved
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e." ? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!", : "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
@@ -0,0 +1,368 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
int? VoteMessageId,
long TelegramChatId,
int? ThreadId,
string NotificationMode);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ISystemClock clock,
ILogger<RescheduleVotingDeadlineService> 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
{
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
""",
new { Now = clock.UtcNow.UtcDateTime })).ToList();
foreach (var proposalId in proposalIds)
{
await FinalizeProposal(proposalId, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process due reschedule voting proposals");
}
}
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 proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
"""
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,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
g.telegram_chat_id AS TelegramChatId
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, Now = clock.UtcNow.UtcDateTime },
transaction);
if (proposal is null)
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,
confirmation_sent_at = 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))
.ToList();
await transaction.CommitAsync(ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
if (selectedOption is not null)
{
await TryUpdateBatchMessage(proposal, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
}
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.Id,
proposal.SessionId,
decision.Outcome);
}
private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct)
{
if (proposal.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);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
}
}
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, 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, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.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, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
ct);
}
else
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
}
}
private async Task SendDirectResult(
DueRescheduleProposalDto proposal,
IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
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(
recipients,
htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId,
ct);
}
}
@@ -0,0 +1,110 @@
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline)
{
private static readonly Regex DateTimePattern = new(
@"(?<date>\d{1,2}\.\d{2}\.\d{4})\s+(?<time>\d{1,2}:\d{2})",
RegexOptions.CultureInvariant);
public static bool TryParse(
string text,
DateTimeOffset nowUtc,
out RescheduleVotingInput input,
out string error)
{
input = new RescheduleVotingInput([], default);
error = string.Empty;
var options = new List<DateTimeOffset>();
DateTimeOffset? deadline = null;
foreach (var rawLine in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var line = rawLine.Trim();
var match = DateTimePattern.Match(line);
if (!match.Success)
continue;
var value = $"{match.Groups["date"].Value} {match.Groups["time"].Value}";
if (!MoscowTime.TryParseMoscow(value, out var parsed))
continue;
if (IsDeadlineLine(line))
{
deadline = parsed;
}
else
{
options.Add(parsed);
}
}
if (options.Count is < 2 or > 3)
{
error = "Укажите от 2 до 3 вариантов времени.";
return false;
}
if (options.Distinct().Count() != options.Count)
{
error = "Варианты времени не должны повторяться.";
return false;
}
if (deadline is null)
{
error = "Укажите дедлайн голосования строкой «Дедлайн: ДД.ММ.ГГГГ ЧЧ:ММ».";
return false;
}
if (options.Any(option => option <= nowUtc))
{
error = "Все варианты времени должны быть в будущем.";
return false;
}
if (deadline.Value <= nowUtc)
{
error = "Дедлайн голосования должен быть в будущем.";
return false;
}
if (deadline.Value >= options.Min())
{
error = "Дедлайн голосования должен быть раньше первого предложенного времени.";
return false;
}
input = new RescheduleVotingInput(options, deadline.Value);
return true;
}
private static bool IsDeadlineLine(string line)
{
var normalized = line.TrimStart('-', '*', ' ', '\t').ToLowerInvariant();
return normalized.StartsWith("дедлайн", StringComparison.Ordinal)
|| normalized.StartsWith("deadline", StringComparison.Ordinal)
|| normalized.StartsWith("до:", StringComparison.Ordinal);
}
}
internal sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
internal sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
internal sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
@@ -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,86 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
public interface ISessionTriggerStore
{
Task<IReadOnlyList<Guid>> GetSessionsNeedingConfirmationAsync(DateTimeOffset now, CancellationToken ct);
Task<IReadOnlyList<Guid>> GetSessionsNeedingOneHourReminderAsync(DateTimeOffset now, CancellationToken ct);
Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct);
}
public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessionTriggerStore
{
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
public async Task<IReadOnlyList<Guid>> GetSessionsNeedingConfirmationAsync(DateTimeOffset now, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var results = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Planned
AND scheduled_at - @LeadTime <= @Now
AND confirmation_sent_at IS NULL
""",
new
{
Planned = SessionStatus.Planned,
LeadTime = ConfirmationLeadTime,
Now = now.UtcDateTime
});
return results.ToList();
}
public async Task<IReadOnlyList<Guid>> GetSessionsNeedingOneHourReminderAsync(DateTimeOffset now, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var results = 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,
Now = now.UtcDateTime
});
return results.ToList();
}
public async Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var results = 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,
Now = now.UtcDateTime
});
return results.ToList();
}
}
@@ -0,0 +1,16 @@
namespace GmRelay.Bot.Infrastructure.Scheduling;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class FakeSystemClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
}
@@ -1,31 +1,27 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling; namespace GmRelay.Bot.Infrastructure.Scheduling;
/// <summary> /// <summary>
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions. /// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
/// Two triggers: /// Three triggers:
/// T-24h: send confirmation request with inline keyboard /// T-24h: send confirmation request with inline keyboard
/// T-1h: send one-hour direct reminder
/// T-5min: send join link to all confirmed players /// T-5min: send join link to all confirmed players
/// ///
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB. /// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
/// </summary> /// </summary>
public sealed class SessionSchedulerService( public sealed class SessionSchedulerService(
NpgsqlDataSource dataSource, ISessionTriggerStore triggerStore,
SendConfirmationHandler confirmationHandler, ISendConfirmationHandler confirmationHandler,
SendOneHourReminderHandler oneHourReminderHandler, ISendOneHourReminderHandler oneHourReminderHandler,
SendJoinLinkHandler joinLinkHandler, ISendJoinLinkHandler joinLinkHandler,
ISystemClock clock,
ILogger<SessionSchedulerService> logger) : BackgroundService ILogger<SessionSchedulerService> logger) : BackgroundService
{ {
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); 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) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
@@ -33,14 +29,11 @@ public sealed class SessionSchedulerService(
using var timer = new PeriodicTimer(TickInterval); using var timer = new PeriodicTimer(TickInterval);
// Run immediately on startup, then on each tick
do do
{ {
try try
{ {
await ProcessConfirmationTriggers(stoppingToken); await TickAsync(stoppingToken);
await ProcessOneHourReminderTriggers(stoppingToken);
await ProcessJoinLinkTriggers(stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@@ -57,57 +50,30 @@ public sealed class SessionSchedulerService(
} }
/// <summary> /// <summary>
/// T-1h trigger: process direct reminders according to the session notification mode. /// Runs a single scheduler tick using the current clock time.
/// Public so it can be called from integration tests with a fake clock.
/// </summary> /// </summary>
private async Task ProcessOneHourReminderTriggers(CancellationToken ct) public async Task TickAsync(CancellationToken ct)
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var now = clock.UtcNow;
var sessionIds = await connection.QueryAsync<Guid>( await ProcessConfirmationTriggers(now, ct);
""" await ProcessOneHourReminderTriggers(now, ct);
SELECT id await ProcessJoinLinkTriggers(now, ct);
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> private async Task ProcessConfirmationTriggers(DateTimeOffset now, CancellationToken ct)
/// 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); IReadOnlyList<Guid> sessionIds;
try
var sessionIds = await connection.QueryAsync<Guid>( {
""" sessionIds = await triggerStore.GetSessionsNeedingConfirmationAsync(now, ct);
SELECT id }
FROM sessions catch (Exception ex)
WHERE status = @Planned {
AND scheduled_at - @LeadTime <= now() logger.LogError(ex, "Failed to query confirmation triggers");
""", return;
new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime }); }
foreach (var sessionId in sessionIds) foreach (var sessionId in sessionIds)
{ {
@@ -123,23 +89,45 @@ public sealed class SessionSchedulerService(
} }
} }
/// <summary> private async Task ProcessOneHourReminderTriggers(DateTimeOffset now, CancellationToken ct)
/// 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); IReadOnlyList<Guid> sessionIds;
try
{
sessionIds = await triggerStore.GetSessionsNeedingOneHourReminderAsync(now, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to query one-hour reminder triggers");
return;
}
var sessionIds = await connection.QueryAsync<Guid>( foreach (var sessionId in sessionIds)
""" {
SELECT id try
FROM sessions {
WHERE status = @Confirmed await oneHourReminderHandler.HandleAsync(sessionId, ct);
AND scheduled_at - @LeadTime <= now() logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
AND link_message_id IS NULL }
""", catch (Exception ex)
new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime }); {
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
}
}
}
private async Task ProcessJoinLinkTriggers(DateTimeOffset now, CancellationToken ct)
{
IReadOnlyList<Guid> sessionIds;
try
{
sessionIds = await triggerStore.GetSessionsNeedingJoinLinkAsync(now, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to query join-link triggers");
return;
}
foreach (var sessionId in sessionIds) foreach (var sessionId in sessionIds)
{ {
@@ -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,46 @@
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramMiniAppMenuButtonService(
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<TelegramMiniAppMenuButtonService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
logger.LogInformation("Telegram Mini App URL is not configured; menu button setup skipped.");
return;
}
if (!Uri.TryCreate(miniAppUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttps && !uri.IsLoopback))
{
logger.LogWarning("Telegram Mini App URL {MiniAppUrl} is not a valid HTTPS URL.", miniAppUrl);
return;
}
try
{
await bot.SetChatMenuButton(
menuButton: new MenuButtonWebApp
{
Text = "Dashboard",
WebApp = new WebAppInfo(miniAppUrl)
},
cancellationToken: cancellationToken);
logger.LogInformation("Telegram Mini App menu button configured for {MiniAppUrl}.", miniAppUrl);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to configure Telegram Mini App menu button.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -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,196 @@
using System.Globalization;
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);
}
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 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);
}
@@ -9,6 +9,7 @@ using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Infrastructure.Telegram;
@@ -30,6 +31,7 @@ public sealed class UpdateRouter(
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler, HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler, HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot, ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{ {
public async Task RouteAsync(Update update, CancellationToken ct) public async Task RouteAsync(Update update, CancellationToken ct)
@@ -40,17 +42,26 @@ public sealed class UpdateRouter(
await HandleCallbackQueryAsync(query, ct); await HandleCallbackQueryAsync(query, ct);
break; break;
case { Message: { Text: { } text } message } when text.StartsWith('/'): case { Message: { } message }:
await HandleCommandAsync(message, text, ct); var commandText = GetCommandText(message);
break; 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; break;
} }
} }
internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct) private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{ {
if (query.Data is not { } data || query.Message is not { } message) if (query.Data is not { } data || query.Message is not { } message)
@@ -69,7 +80,7 @@ public sealed class UpdateRouter(
CallbackQueryId: query.Id, CallbackQueryId: query.Id,
ChatId: message.Chat.Id, ChatId: message.Chat.Id,
MessageId: message.MessageId); MessageId: message.MessageId);
await joinSessionHandler.HandleAsync(command, ct); await joinSessionHandler.HandleAsync(command, ct);
return; return;
} }
@@ -94,6 +105,7 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id, TelegramUserId: query.From.Id,
CallbackQueryId: query.Id, CallbackQueryId: query.Id,
ChatId: message.Chat.Id, ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId); MessageId: message.MessageId);
await cancelSessionHandler.HandleAsync(command, ct); await cancelSessionHandler.HandleAsync(command, ct);
@@ -133,21 +145,17 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id, TelegramUserId: query.From.Id,
CallbackQueryId: query.Id, CallbackQueryId: query.Id,
ChatId: message.Chat.Id, ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId); MessageId: message.MessageId);
await initiateRescheduleHandler.HandleAsync(command, ct); await initiateRescheduleHandler.HandleAsync(command, ct);
return; return;
} }
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId)) if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId))
{ {
var vote = parts[1]; // "yes" or "no"
if (vote is not ("yes" or "no"))
return;
var command = new HandleRescheduleVoteCommand( var command = new HandleRescheduleVoteCommand(
ProposalId: proposalId, OptionId: optionId,
Vote: vote,
TelegramUserId: query.From.Id, TelegramUserId: query.From.Id,
CallbackQueryId: query.Id, CallbackQueryId: query.Id,
ChatId: message.Chat.Id, ChatId: message.Chat.Id,
@@ -193,10 +201,7 @@ public sealed class UpdateRouter(
switch (command) switch (command)
{ {
case "/start": case "/start":
await bot.SendMessage( await SendStartMessageAsync(message, ct);
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
break; break;
case "/newsession": case "/newsession":
@@ -222,8 +227,14 @@ public sealed class UpdateRouter(
Время: 15.05.2026 19:30 Время: 15.05.2026 19:30
Мест: 4 Мест: 4
Ссылка: https://link Ссылка: https://link
Картинка: https://cover
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий /listsessions список предстоящих сессий
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
/help эта справка /help эта справка
""", """,
@@ -236,4 +247,24 @@ public sealed class UpdateRouter(
break; break;
} }
} }
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Откройте dashboard внутри Telegram или используйте /help для команд.",
replyMarkup: new InlineKeyboardMarkup(
InlineKeyboardButton.WithWebApp("Открыть dashboard", new WebAppInfo(miniAppUrl))),
cancellationToken: ct);
}
} }
@@ -0,0 +1,26 @@
-- Add explicit owner/co-GM management roles for each Telegram group.
INSERT INTO players (telegram_id, display_name)
SELECT DISTINCT gg.gm_telegram_id,
'GM ' || gg.gm_telegram_id::text
FROM game_groups gg
ON CONFLICT (telegram_id) DO NOTHING;
CREATE TABLE group_managers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')),
added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, player_id)
);
INSERT INTO group_managers (group_id, player_id, role)
SELECT gg.id, p.id, 'Owner'
FROM game_groups gg
JOIN players p ON p.telegram_id = gg.gm_telegram_id
ON CONFLICT (group_id, player_id) DO NOTHING;
CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role);
CREATE INDEX ix_group_managers_player ON group_managers (player_id);
@@ -0,0 +1,35 @@
-- Multi-option reschedule voting with a deadline.
ALTER TABLE reschedule_proposals
ADD COLUMN voting_deadline_at TIMESTAMPTZ,
ADD COLUMN selected_option_id UUID;
CREATE TABLE reschedule_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
proposed_at TIMESTAMPTZ NOT NULL,
display_order INTEGER NOT NULL CHECK (display_order BETWEEN 1 AND 3),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, id),
UNIQUE (proposal_id, display_order),
UNIQUE (proposal_id, proposed_at)
);
CREATE TABLE reschedule_option_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
option_id UUID NOT NULL,
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, player_id),
FOREIGN KEY (proposal_id, option_id)
REFERENCES reschedule_options(proposal_id, id)
ON DELETE CASCADE
);
CREATE INDEX ix_reschedule_voting_deadline
ON reschedule_proposals (voting_deadline_at)
WHERE status = 'Voting';
CREATE INDEX ix_reschedule_option_votes_option
ON reschedule_option_votes (option_id);
@@ -0,0 +1,17 @@
CREATE TABLE campaign_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
title VARCHAR(500) NOT NULL,
join_link TEXT NOT NULL,
session_count INTEGER NOT NULL CHECK (session_count BETWEEN 1 AND 52),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
max_players INTEGER CHECK (max_players IS NULL OR max_players > 0),
notification_mode VARCHAR(32) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, name)
);
CREATE INDEX ix_campaign_templates_group ON campaign_templates (group_id, created_at DESC);
@@ -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;
+15
View File
@@ -6,9 +6,11 @@ using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database; using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Bot.Infrastructure.Health;
using GmRelay.Bot.Infrastructure.Logging; using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -49,13 +51,17 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
return new TelegramBotClient(token); return new TelegramBotClient(token);
}); });
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>(); builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
// ── Feature handlers (explicit registration — AOT safe) ────────────── // ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>(); builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<DirectSessionNotificationSender>(); builder.Services.AddSingleton<DirectSessionNotificationSender>();
builder.Services.AddSingleton<HandleRsvpHandler>(); builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>(); builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>(); builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>(); builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<JoinSessionHandler>(); builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>(); builder.Services.AddSingleton<LeaveSessionHandler>();
@@ -71,10 +77,19 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
// ── Telegram infrastructure ────────────────────────────────────────── // ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>(); builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>()); builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>(); builder.Services.AddHostedService<TelegramBotService>();
// ── Clock and scheduling ──────────────────────────────────────────────
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ──────────────────────────────────────────────── // ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>(); builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
// ── Health check server ──────────────────────────────────────────────
builder.Services.AddHostedService<BotHealthCheckHostedService>();
var host = builder.Build(); var host = builder.Build();
+5 -1
View File
@@ -7,6 +7,10 @@
} }
}, },
"Telegram": { "Telegram": {
"BotToken": "" "BotToken": "",
"MiniAppUrl": ""
},
"Web": {
"BaseUrl": ""
} }
} }
+689
View File
@@ -0,0 +1,689 @@
{
"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"
}
},
"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=="
}
}
}
}
@@ -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"
}
}
}
}
}
@@ -0,0 +1,7 @@
namespace GmRelay.Shared.Domain;
public enum CalendarSubscriptionFilter
{
AllMyGroups = 0,
SpecificGroup = 1
}
@@ -0,0 +1,34 @@
namespace GmRelay.Shared.Domain;
public enum GroupManagerRole
{
Owner,
CoGm
}
public static class GroupManagerRoleExtensions
{
public const string OwnerValue = "Owner";
public const string CoGmValue = "CoGm";
public static string ToDatabaseValue(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => OwnerValue,
GroupManagerRole.CoGm => CoGmValue,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
public static GroupManagerRole FromDatabaseValue(string value) => value switch
{
OwnerValue => GroupManagerRole.Owner,
CoGmValue => GroupManagerRole.CoGm,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown group manager role.")
};
public static string ToDisplayName(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => "Owner",
GroupManagerRole.CoGm => "Co-GM",
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
}
+1 -1
View File
@@ -21,7 +21,7 @@ public static class MoscowTime
public static bool TryParseMoscow(string text, out DateTimeOffset utcTime) 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)) System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var localDt))
{ {
utcTime = new DateTimeOffset(localDt, MoscowOffset).ToUniversalTime(); utcTime = new DateTimeOffset(localDt, MoscowOffset).ToUniversalTime();
-4
View File
@@ -7,8 +7,4 @@
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,16 @@
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);
}
@@ -0,0 +1,8 @@
namespace GmRelay.Shared.Platform;
public sealed record PlatformGroup(
PlatformKind Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId = null,
string? ExternalThreadId = null);
@@ -0,0 +1,8 @@
namespace GmRelay.Shared.Platform;
public enum PlatformKind
{
Telegram = 0,
Discord = 1,
Max = 2
}
@@ -0,0 +1,36 @@
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);
@@ -0,0 +1,7 @@
namespace GmRelay.Shared.Platform;
public sealed record PlatformUser(
PlatformKind Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
@@ -0,0 +1,13 @@
namespace GmRelay.Shared.Rendering;
/// <summary>
/// Заглушка для Discord-рендерера.
/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26).
/// </summary>
public static class DiscordSessionBatchRenderer
{
public static object Render(SessionBatchViewModel view)
{
throw new NotImplementedException("Discord renderer will be implemented in issue #26.");
}
}
@@ -0,0 +1,4 @@
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -1,90 +0,0 @@
using GmRelay.Shared.Domain;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
public static class SessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(
string title,
IReadOnlyList<SessionBatchDto> sessions,
IReadOnlyList<ParticipantBatchDto> participants)
{
var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in activeSessions)
{
var sessionPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
.ToList();
var waitlistedPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.ToList();
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({sessionPlayers.Count}):\n";
if (sessionPlayers.Count > 0)
{
messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (waitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n";
messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"),
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}")
});
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"),
InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}")
}
.Concat(SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, sessionPlayers.Count, waitlistedPlayers.Count)
? [InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle} (ГМ)", $"promote_waitlist:{session.SessionId}")]
: [])
.ToArray());
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
{
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
{
return $"⏳ В лист ожидания {dateTitle}";
}
return $"✋ На {dateTitle}";
}
}
@@ -0,0 +1,61 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Rendering;
public static class SessionBatchViewBuilder
{
public static SessionBatchViewModel Build(
string title,
IReadOnlyList<SessionBatchDto> sessions,
IReadOnlyList<ParticipantBatchDto> participants)
{
var orderedSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
var sessionItems = new List<SessionViewItem>();
foreach (var session in orderedSessions)
{
var activePlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
.ToList();
var waitlistedPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
.ToList();
var actions = new List<AvailableAction>();
if (!SessionStatus.IsCancelled(session.Status))
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
var joinLabel = GetJoinButtonText(session, activePlayers.Count, dateTitle);
actions.Add(new AvailableAction("join_session", joinLabel, session.SessionId));
actions.Add(new AvailableAction("leave_session", $"🚪 Выйти {dateTitle}", session.SessionId));
}
sessionItems.Add(new SessionViewItem(
session.SessionId,
session.ScheduledAt,
session.Status,
session.MaxPlayers,
session.JoinLink,
activePlayers.Count,
activePlayers,
waitlistedPlayers,
actions));
}
return new SessionBatchViewModel(title, sessionItems);
}
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
{
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
{
return $"⏳ В лист ожидания {dateTitle}";
}
return $"✋ На {dateTitle}";
}
}
// trigger pr
@@ -0,0 +1,28 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchViewModel(
string Title,
IReadOnlyList<SessionViewItem> Sessions);
public sealed record SessionViewItem(
Guid SessionId,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
string JoinLink,
int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
IReadOnlyList<AvailableAction> AvailableActions);
public sealed record PlayerViewItem(
string DisplayName,
string? TelegramUsername,
string RegistrationStatus);
public sealed record AvailableAction(
string ActionKey,
string Label,
Guid SessionId);
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
}
}
}
}
+259 -2
View File
@@ -10,9 +10,10 @@
<ResourcePreloader /> <ResourcePreloader />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=Cinzel:wght@400;600;700&family=Jura:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="@Assets["app.css"]" /> <link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" /> <link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<ImportMap /> <ImportMap />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" /> <HeadOutlet @rendermode="InteractiveServer" />
@@ -23,19 +24,275 @@
<ReconnectModal /> <ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script>
<script> <script>
window.waitForTelegramMiniApp = async function (timeoutMs) {
var deadline = Date.now() + (timeoutMs || 3000);
while (Date.now() <= deadline) {
if (window.Telegram && window.Telegram.WebApp) {
return window.Telegram.WebApp;
}
await new Promise(function (resolve) {
setTimeout(resolve, 100);
});
}
return null;
};
window.waitForTelegramMiniAppInitData = async function (timeoutMs) {
var deadline = Date.now() + (timeoutMs || 3000);
while (Date.now() <= deadline) {
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) {
return window.Telegram.WebApp;
}
await new Promise(function (resolve) {
setTimeout(resolve, 100);
});
}
return null;
};
window.syncTelegramMiniAppViewport = function (webApp) {
if (!webApp) {
return;
}
var root = document.documentElement;
var safeArea = webApp.safeAreaInset || {};
var contentSafeArea = webApp.contentSafeAreaInset || {};
var setPx = function (name, value) {
root.style.setProperty(name, Math.max(0, Number(value) || 0) + 'px');
};
setPx('--gm-tg-safe-top', safeArea.top);
setPx('--gm-tg-safe-right', safeArea.right);
setPx('--gm-tg-safe-bottom', safeArea.bottom);
setPx('--gm-tg-safe-left', safeArea.left);
setPx('--gm-tg-content-safe-top', contentSafeArea.top);
setPx('--gm-tg-content-safe-right', contentSafeArea.right);
setPx('--gm-tg-content-safe-bottom', contentSafeArea.bottom);
setPx('--gm-tg-content-safe-left', contentSafeArea.left);
if (webApp.viewportHeight) {
root.style.setProperty('--gm-tg-viewport-height', webApp.viewportHeight + 'px');
}
};
window.prepareTelegramMiniApp = function (webApp) {
if (!webApp) {
return;
}
document.body.classList.add('telegram-mini-app');
window.syncTelegramMiniAppViewport(webApp);
try {
webApp.ready();
} catch (error) {
}
try {
webApp.expand();
} catch (error) {
}
if (webApp.onEvent && !window.gmRelayTelegramMiniAppViewportEventsRegistered) {
window.gmRelayTelegramMiniAppViewportEventsRegistered = true;
webApp.onEvent('safeAreaChanged', function () {
window.syncTelegramMiniAppViewport(webApp);
});
webApp.onEvent('contentSafeAreaChanged', function () {
window.syncTelegramMiniAppViewport(webApp);
});
webApp.onEvent('viewportChanged', function () {
window.syncTelegramMiniAppViewport(webApp);
});
}
};
(async function () {
var webApp = await window.waitForTelegramMiniApp(1000);
window.prepareTelegramMiniApp(webApp);
})();
window.loadTelegramWidget = function (botUsername, authUrl) { window.loadTelegramWidget = function (botUsername, authUrl) {
var container = document.getElementById('telegram-login-container'); var container = document.getElementById('telegram-login-container');
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
window.gmRelayTelegramLoginAuthUrl = authUrl || '/auth/telegram-login';
var script = document.createElement('script'); var script = document.createElement('script');
script.async = true; script.async = true;
script.src = 'https://telegram.org/js/telegram-widget.js?22'; script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.setAttribute('data-telegram-login', botUsername); script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large'); script.setAttribute('data-size', 'large');
script.setAttribute('data-auth-url', authUrl); script.setAttribute('data-onauth', 'window.handleTelegramLogin(user)');
script.setAttribute('data-request-access', 'write'); script.setAttribute('data-request-access', 'write');
container.appendChild(script); container.appendChild(script);
}; };
window.handleTelegramLogin = async function (user) {
if (!user) {
window.location.href = '/login?error=auth_failed';
return;
}
try {
var response = await fetch(window.gmRelayTelegramLoginAuthUrl || '/auth/telegram-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify(user)
});
if (!response.ok) {
window.location.href = '/login?error=auth_failed';
return;
}
var payload = await response.json();
window.location.href = payload.redirectUrl || '/';
} catch (error) {
window.location.href = '/login?error=auth_failed';
}
};
window.watchTelegramMiniAppLogin = function (statusUrl, redirectUrl, reloadOnReturn) {
window.gmRelayMiniAppLoginReloadOnReturn =
window.gmRelayMiniAppLoginReloadOnReturn || reloadOnReturn === true;
if (window.gmRelayMiniAppLoginWatcher) {
return;
}
window.gmRelayMiniAppLoginLeftPage = false;
var stopWatching = function () {
if (window.gmRelayMiniAppLoginWatcher) {
window.clearInterval(window.gmRelayMiniAppLoginWatcher);
window.gmRelayMiniAppLoginWatcher = null;
}
};
var reloadAfterExternalLogin = function () {
if (!window.gmRelayMiniAppLoginReloadOnReturn || !window.gmRelayMiniAppLoginLeftPage) {
return;
}
window.gmRelayMiniAppLoginLeftPage = false;
try {
var refreshKey = 'gmrelay-miniapp-login-refresh:' + window.location.pathname;
if (window.sessionStorage && window.sessionStorage.getItem(refreshKey) === '1') {
return;
}
if (window.sessionStorage) {
window.sessionStorage.setItem(refreshKey, '1');
}
} catch (error) {
}
window.location.reload();
};
var allowNextExternalLoginReload = function () {
window.gmRelayMiniAppLoginLeftPage = true;
try {
var refreshKey = 'gmrelay-miniapp-login-refresh:' + window.location.pathname;
if (window.sessionStorage) {
window.sessionStorage.removeItem(refreshKey);
}
} catch (error) {
}
};
var checkLogin = async function (reloadWhenUnauthenticated) {
try {
var response = await fetch(statusUrl, {
credentials: 'same-origin',
cache: 'no-store'
});
if (!response.ok) {
return;
}
var payload = await response.json();
if (payload.authenticated) {
stopWatching();
window.location.href = redirectUrl || '/';
return;
}
if (reloadWhenUnauthenticated) {
reloadAfterExternalLogin();
}
} catch (error) {
return;
}
};
window.gmRelayMiniAppLoginWatcher = window.setInterval(checkLogin, 1000);
window.addEventListener('blur', function () {
allowNextExternalLoginReload();
});
window.addEventListener('focus', function () {
void checkLogin(true);
});
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
allowNextExternalLoginReload();
return;
}
void checkLogin(true);
});
void checkLogin(false);
};
window.authenticateTelegramMiniApp = async function (authUrl, redirectUrl) {
var webApp = await window.waitForTelegramMiniApp(3000);
if (!webApp) {
return { authenticated: false, reason: 'telegram-webapp-missing' };
}
window.prepareTelegramMiniApp(webApp);
if (!webApp.initData) {
return { authenticated: false, reason: 'telegram-init-data-empty' };
}
try {
var response = await fetch(authUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ initData: webApp.initData })
});
if (!response.ok) {
return {
authenticated: false,
reason: 'telegram-auth-failed',
status: response.status
};
}
var payload = await response.json();
window.location.href = payload.redirectUrl || redirectUrl || '/';
return { authenticated: true, redirectUrl: payload.redirectUrl || redirectUrl || '/' };
} catch (error) {
return { authenticated: false, reason: 'telegram-auth-failed' };
}
};
</script> </script>
</body> </body>
@@ -30,8 +30,9 @@
/* === Error UI === */ /* === Error UI === */
#blazor-error-ui { #blazor-error-ui {
background: var(--bg-secondary); background: var(--status-danger-bg);
border-top: 1px solid var(--border-color); color: var(--status-danger);
border-top: 1px solid rgba(239, 68, 68, 0.15);
bottom: 0; bottom: 0;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3); box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3);
box-sizing: border-box; box-sizing: border-box;
@@ -41,8 +42,9 @@
position: fixed; position: fixed;
width: 100%; width: 100%;
z-index: 1000; z-index: 1000;
color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
font-family: 'Jura', sans-serif;
font-weight: 500;
} }
#blazor-error-ui .reload { #blazor-error-ui .reload {
@@ -60,12 +62,17 @@
/* === Mobile Responsive === */ /* === Mobile Responsive === */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
transform: translateX(-100%); transform: none;
width: 280px; width: 100%;
height: auto;
min-height: 0;
position: sticky;
border-right: none;
border-bottom: 1px solid var(--border-color);
} }
.sidebar.open { .page {
transform: translateX(0); display: block;
} }
.main-area { .main-area {
@@ -2,10 +2,10 @@
<div class="nav-header"> <div class="nav-header">
<a class="nav-brand" href=""> <a class="nav-brand" href="">
<span class="nav-brand-icon">🎲</span> <img src="logo.png" alt="GM-Relay" class="nav-brand-icon" />
<span class="nav-brand-text">GM-Relay</span> <span class="nav-brand-text">GM-Relay</span>
</a> </a>
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Навигационное меню"> <button class="nav-toggle" @onclick="ToggleMenu" aria-label="Переключить меню">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/> <line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/> <line x1="3" y1="12" x2="21" y2="12"/>
@@ -23,7 +23,16 @@
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/> <polyline points="9 22 9 12 15 12 15 22"/>
</svg> </svg>
Панель управления Главная страница
</NavLink>
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 8h10"/>
<path d="M7 12h6"/>
<path d="M7 16h8"/>
</svg>
Шаблоны
</NavLink> </NavLink>
</div> </div>
@@ -43,11 +52,11 @@
<polyline points="16 17 21 12 16 7"/> <polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/> <line x1="21" y1="12" x2="9" y2="12"/>
</svg> </svg>
Выйти Выход
</button> </button>
</form> </form>
<div class="nav-version">v1.3.0</div> <div class="nav-version">v2.1.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -58,7 +67,7 @@
<polyline points="10 17 15 12 10 7"/> <polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/> <line x1="15" y1="12" x2="3" y2="12"/>
</svg> </svg>
Войти Вход
</NavLink> </NavLink>
</div> </div>
</NotAuthorized> </NotAuthorized>
@@ -9,23 +9,25 @@
.nav-brand { .nav-brand {
display: flex; display: flex;
align-items: center; align-items: baseline;
gap: 0.625rem; gap: 0.75rem;
text-decoration: none; text-decoration: none;
color: var(--text-primary); color: var(--text-primary);
} }
.nav-brand-icon { .nav-brand-icon {
font-size: 1.5rem; width: 1.5rem;
height: 1.5rem;
object-fit: contain;
display: block;
} }
.nav-brand-text { .nav-brand-text {
font-family: 'Cinzel Decorative', 'Cinzel', serif;
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700; font-weight: 700;
background: var(--accent-gradient); letter-spacing: 0.04em;
-webkit-background-clip: text; color: var(--text-primary);
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.nav-toggle { .nav-toggle {
@@ -56,13 +58,17 @@
.nav-section { .nav-section {
padding: 0 0.75rem; padding: 0 0.75rem;
flex: 1; flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
} }
/* === Nav Items === */ /* === Nav Items === */
.nav-item { .nav-section ::deep .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.875rem; padding: 0.625rem 0.875rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-secondary); color: var(--text-secondary);
@@ -70,19 +76,20 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
transition: all var(--transition-normal); transition: all var(--transition-normal);
margin-bottom: 0.125rem; white-space: nowrap;
box-sizing: border-box;
} }
.nav-item:hover { .nav-section ::deep .nav-item:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: var(--text-primary); color: var(--text-primary);
} }
.nav-item.active, .nav-section ::deep .nav-item.active {
.nav-item ::deep a.active { background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(34, 211, 238, 0.08) 100%);
background: rgba(124, 58, 237, 0.15); color: var(--text-accent);
color: var(--accent-primary); border: 1px solid rgba(139, 92, 246, 0.25);
border: 1px solid rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(139, 92, 246, 0.1);
} }
.nav-icon { .nav-icon {
@@ -138,7 +145,7 @@
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-muted); color: var(--text-muted);
font-family: 'Inter', sans-serif; font-family: 'Jura', sans-serif;
font-size: 0.8125rem; font-size: 0.8125rem;
cursor: pointer; cursor: pointer;
transition: all var(--transition-normal); transition: all var(--transition-normal);
@@ -180,9 +187,18 @@
.nav-body.open { .nav-body.open {
display: flex; display: flex;
position: fixed;
inset: 0;
z-index: 200;
background: linear-gradient(180deg, #0f1629 0%, #1a0a2e 100%);
padding-top: 4.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 1rem;
} }
.nav-header { .nav-header {
padding-left: 3.75rem;
padding-right: 0.75rem; padding-right: 0.75rem;
} }
} }
@@ -0,0 +1,402 @@
@page "/templates"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Шаблоны кампаний — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Шаблоны кампаний</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📋 Шаблоны кампаний</h2>
<p>Сохраняйте типовые кампании один раз и применяйте их на странице нужной группы.</p>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
✅ @successMessage
</div>
}
@if (groups is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 70%;"></div>
</div>
}
else if (groups.Count == 0)
{
<div class="glass-card">
<div class="empty-state">
<div class="empty-state-icon">🤖</div>
<div class="empty-state-title">Нет доступных групп</div>
<p class="empty-state-text">Добавьте бота GM-Relay в группу Telegram, чтобы создать первый шаблон кампании.</p>
</div>
</div>
}
else
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Группа для шаблонов</h3>
<p>@(SelectedGroup?.Name ?? "Выберите группу")</p>
</div>
@if (SelectedGroup is not null)
{
<span class="status-badge @(SelectedGroup.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatRole(SelectedGroup.ManagerRole)
</span>
}
</div>
<div class="template-group-selector">
<select value="@selectedGroupId" @onchange="OnSelectedGroupChanged" class="gm-form-control">
@foreach (var group in groups)
{
<option value="@group.Id">@group.Name</option>
}
</select>
@if (SelectedGroup is not null)
{
<a href="/group/@SelectedGroup.Id" class="btn-gm btn-gm-outline">Открыть группу →</a>
}
</div>
</div>
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Новый шаблон</h3>
<p>Эти параметры будут использоваться при запуске batch из группы.</p>
</div>
<span class="status-badge status-info">Template</span>
</div>
<EditForm Model="@templateModel" OnValidSubmit="CreateCampaignTemplate">
<div class="campaign-template-fields">
<div class="gm-form-group">
<label class="gm-form-label">Название шаблона</label>
<InputText @bind-Value="templateModel.Name" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Название кампании</label>
<InputText @bind-Value="templateModel.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Ссылка</label>
<InputText @bind-Value="templateModel.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Игр</label>
<InputNumber @bind-Value="templateModel.SessionCount" class="gm-form-control" min="1" max="52" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Интервал, дней</label>
<InputNumber @bind-Value="templateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Мест</label>
<InputNumber @bind-Value="templateModel.MaxPlayers" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления</label>
<select @bind="templateModel.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
</button>
</EditForm>
</div>
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Сохранённые шаблоны</h3>
<p>@campaignTemplateModels.Count для выбранной группы</p>
</div>
<span class="status-badge status-info">@campaignTemplateModels.Count</span>
</div>
@if (campaignTemplates is null)
{
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 55%;"></div>
}
else if (campaignTemplateModels.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Шаблонов пока нет</div>
<p class="empty-state-text">Создайте первый шаблон для выбранной группы.</p>
</div>
}
else
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row template-management-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="template-management-actions">
<span class="status-badge status-neutral">@FormatLocalMoscow(template.UpdatedAt.ToMoscow())</span>
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingTemplateId == template.Id)" @onclick="() => DeleteCampaignTemplate(template)">
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@code {
private List<WebGameGroup>? groups;
private List<WebCampaignTemplate>? campaignTemplates;
private List<CampaignTemplateManagementModel> campaignTemplateModels = [];
private Guid selectedGroupId;
private Guid? deletingTemplateId;
private bool isCreatingTemplate;
private long telegramId;
private string? errorMessage;
private string? successMessage;
private CampaignTemplateEditModel templateModel = new();
private WebGameGroup? SelectedGroup => groups?.FirstOrDefault(group => group.Id == selectedGroupId);
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
if (selectedGroupId != Guid.Empty)
{
await LoadTemplates();
}
}
private async Task OnSelectedGroupChanged(ChangeEventArgs args)
{
if (!Guid.TryParse(args.Value?.ToString(), out var groupId))
{
return;
}
selectedGroupId = groupId;
errorMessage = null;
successMessage = null;
await LoadTemplates();
}
private async Task LoadTemplates()
{
campaignTemplates = null;
campaignTemplateModels = [];
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
if (templates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = templates;
RebuildCampaignTemplateModels();
}
private async Task CreateCampaignTemplate()
{
errorMessage = null;
successMessage = null;
if (selectedGroupId == Guid.Empty)
{
errorMessage = "Выберите группу для шаблона.";
return;
}
if (!ValidateCampaignTemplate(templateModel))
{
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
return;
}
isCreatingTemplate = true;
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
selectedGroupId,
telegramId,
new CreateCampaignTemplateRequest(
templateModel.Name,
templateModel.Title,
templateModel.JoinLink,
templateModel.SessionCount,
templateModel.IntervalDays,
templateModel.MaxPlayers,
SessionNotificationModeExtensions.FromDatabaseValue(templateModel.NotificationMode)));
templateModel = new();
successMessage = "Шаблон кампании сохранён.";
await LoadTemplates();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
}
finally
{
isCreatingTemplate = false;
}
}
private async Task DeleteCampaignTemplate(CampaignTemplateManagementModel template)
{
errorMessage = null;
successMessage = null;
deletingTemplateId = template.Id;
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
successMessage = "Шаблон кампании удалён.";
await LoadTemplates();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
}
finally
{
deletingTemplateId = null;
}
}
private void RebuildCampaignTemplateModels()
{
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateManagementModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
UpdatedAt = template.UpdatedAt
})
.ToList() ?? [];
}
private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
{
template.Name = template.Name.Trim();
template.Title = template.Title.Trim();
template.JoinLink = template.JoinLink.Trim();
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
{
return false;
}
return template.Name.Length > 0 &&
template.Title.Length > 0 &&
template.JoinLink.Length > 0 &&
template.SessionCount is >= 1 and <= 52 &&
template.IntervalDays is >= 1 and <= 365;
}
private static string FormatTemplateSummary(CampaignTemplateManagementModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
private sealed class CampaignTemplateEditModel
{
public string Name { get; set; } = "";
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public int SessionCount { get; set; } = 6;
public int IntervalDays { get; set; } = 7;
public int? MaxPlayers { get; set; }
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
}
private sealed class CampaignTemplateManagementModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime UpdatedAt { get; init; }
}
}
@@ -20,6 +20,58 @@
<h2>📅 Предстоящие игры</h2> <h2>📅 Предстоящие игры</h2>
</div> </div>
@if (groupManagement is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Управление группой</h3>
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
</div>
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
<a href="/groupstats/@GroupId" class="btn-gm btn-gm-outline">📊 Статистика</a>
@foreach (var manager in groupManagement.Managers)
{
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatManager(manager)
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
</div>
@if (groupManagement.CurrentUserIsOwner)
{
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Telegram ID co-GM</label>
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@(isAddingCoGm ? "⏳ Добавляем..." : " Добавить co-GM")
</button>
</EditForm>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage)) @if (!string.IsNullOrEmpty(errorMessage))
{ {
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;"> <div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -34,6 +86,47 @@
</div> </div>
} }
@if (campaignTemplates is not null)
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Применить шаблон</h3>
<p>@campaignTemplateModels.Count доступных для этой группы</p>
</div>
<a href="/templates" class="btn-gm btn-gm-outline">⚙️ Управлять шаблонами</a>
</div>
@if (campaignTemplateModels.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Шаблонов пока нет</div>
<p class="empty-state-text">Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.</p>
</div>
}
else
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="campaign-template-actions">
<input type="datetime-local" @bind="template.FirstScheduledAtLocal" class="gm-form-control" />
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
</button>
</div>
</div>
}
</div>
}
</div>
}
@if (sessions == null) @if (sessions == null)
{ {
<div class="glass-card" style="padding: 2rem;"> <div class="glass-card" style="padding: 2rem;">
@@ -122,7 +215,7 @@
</div> </div>
@* Desktop table *@ @* Desktop table *@
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;"> <div class="glass-card session-table-desktop session-table-desktop-card animate-slide-up">
<table class="gm-table"> <table class="gm-table">
<thead> <thead>
<tr> <tr>
@@ -137,33 +230,81 @@
<tbody> <tbody>
@foreach (var session in sessions) @foreach (var session in sessions)
{ {
var isExpanded = expandedSessions.Contains(session.Id);
<tr> <tr>
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td> <td style="color: var(--text-primary); font-weight: 500;">
<button type="button" class="btn-gm btn-gm-link" @onclick="() => ToggleParticipants(session.Id)">
@(isExpanded ? "▼" : "▶") @session.Title
</button>
</td>
<td>@session.ScheduledAt.FormatMoscow()</td> <td>@session.ScheduledAt.FormatMoscow()</td>
<td>@FormatSeats(session)</td> <td>@FormatSeats(session)</td>
<td> <td>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span> <span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td> </td>
<td> <td>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer" <a href="@session.JoinLink" target="_blank" rel="noopener noreferrer" class="session-join-link">
style="max-width: 150px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
Подключиться ↗ Подключиться ↗
</a> </a>
</td> </td>
<td> <td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> <div class="session-table-actions">
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;"> <a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
✏️ Изменить ✏️ Изменить
</a> </a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline">📜 История</a>
@if (CanPromote(session)) @if (CanPromote(session))
{ {
<button type="button" class="btn-gm btn-gm-success" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)"> <button type="button" class="btn-gm btn-gm-success" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания") @(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button> </button>
} }
</div> </div>
</td> </td>
</tr> </tr>
@if (isExpanded)
{
<tr>
<td colspan="6" style="padding: 0; border: none;">
<div class="participant-panel">
@if (loadingParticipantsSessionId == session.Id)
{
<div class="skeleton skeleton-text" style="width: 60%;"></div>
}
else if (participantsCache.TryGetValue(session.Id, out var participants))
{
@if (participants.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Нет участников</div>
</div>
}
else
{
<div class="participant-list">
@foreach (var p in participants)
{
<div class="participant-row">
<div class="participant-info">
<span class="participant-name">@p.DisplayName</span>
<span class="participant-username">@FormatParticipantUsername(p)</span>
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
</div>
@if (!p.IsGm)
{
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
</button>
}
</div>
}
</div>
}
}
</div>
</td>
</tr>
}
} }
</tbody> </tbody>
</table> </table>
@@ -173,9 +314,12 @@
<div class="session-card-mobile stagger-children"> <div class="session-card-mobile stagger-children">
@foreach (var session in sessions) @foreach (var session in sessions)
{ {
var isExpanded = expandedSessions.Contains(session.Id);
<div class="session-card"> <div class="session-card">
<div class="session-card-header"> <div class="session-card-header">
<span class="session-card-title">@session.Title</span> <button type="button" class="btn-gm btn-gm-link" style="text-align: left; padding: 0;" @onclick="() => ToggleParticipants(session.Id)">
@(isExpanded ? "▼" : "▶") @session.Title
</button>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span> <span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</div> </div>
<div class="session-card-body"> <div class="session-card-body">
@@ -196,6 +340,7 @@
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;"> <a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
✏️ Изменить ✏️ Изменить
</a> </a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">📜 История</a>
@if (CanPromote(session)) @if (CanPromote(session))
{ {
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)"> <button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -203,6 +348,45 @@
</button> </button>
} }
</div> </div>
@if (isExpanded)
{
<div class="participant-panel" style="margin-top: 0.75rem;">
@if (loadingParticipantsSessionId == session.Id)
{
<div class="skeleton skeleton-text" style="width: 60%;"></div>
}
else if (participantsCache.TryGetValue(session.Id, out var participants))
{
@if (participants.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Нет участников</div>
</div>
}
else
{
<div class="participant-list">
@foreach (var p in participants)
{
<div class="participant-row">
<div class="participant-info">
<span class="participant-name">@p.DisplayName</span>
<span class="participant-username">@FormatParticipantUsername(p)</span>
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
</div>
@if (!p.IsGm)
{
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
</button>
}
</div>
}
</div>
}
}
</div>
}
</div> </div>
} }
</div> </div>
@@ -212,12 +396,23 @@
@code { @code {
[Parameter] public Guid GroupId { get; set; } [Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions; private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = []; private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private Guid? promotingSessionId; private Guid? promotingSessionId;
private Guid? processingBatchId; private Guid? processingBatchId;
private Guid? processingTemplateId;
private long? removingCoGmId;
private bool isAddingCoGm;
private long telegramId; private long telegramId;
private string? errorMessage; private string? errorMessage;
private string? successMessage; private string? successMessage;
private CoGmEditModel coGmModel = new();
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
private Guid? loadingParticipantsSessionId;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -233,6 +428,13 @@
private async Task LoadSessions() private async Task LoadSessions()
{ {
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null) if (sessions is null)
{ {
@@ -240,7 +442,81 @@
return; return;
} }
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
RebuildBatchModels(); RebuildBatchModels();
RebuildCampaignTemplateModels();
}
private async Task AddCoGm()
{
errorMessage = null;
successMessage = null;
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
{
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
return;
}
isAddingCoGm = true;
try
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
coGmModel.DisplayName,
coGmModel.TelegramUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
}
finally
{
isAddingCoGm = false;
}
}
private async Task RemoveCoGm(long coGmTelegramId)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
}
finally
{
removingCoGmId = null;
}
} }
private async Task PromoteWaitlisted(Guid sessionId) private async Task PromoteWaitlisted(Guid sessionId)
@@ -268,6 +544,105 @@
} }
} }
private async Task ToggleParticipants(Guid sessionId)
{
if (expandedSessions.Contains(sessionId))
{
expandedSessions.Remove(sessionId);
return;
}
expandedSessions.Add(sessionId);
if (!participantsCache.ContainsKey(sessionId))
{
loadingParticipantsSessionId = sessionId;
try
{
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
participantsCache[sessionId] = participants ?? [];
}
catch (Exception ex)
{
errorMessage = "Не удалось загрузить участников: " + ex.Message;
expandedSessions.Remove(sessionId);
}
finally
{
loadingParticipantsSessionId = null;
}
}
}
private async Task KickParticipant(Guid sessionId, Guid participantId)
{
errorMessage = null;
successMessage = null;
kickingParticipantId = participantId;
try
{
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
participantsCache.Remove(sessionId);
successMessage = "Игрок исключён.";
await LoadSessions();
if (expandedSessions.Contains(sessionId))
{
await ToggleParticipants(sessionId);
}
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
kickingParticipantId = null;
}
}
private static string FormatParticipantUsername(WebParticipant p)
{
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
? p.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + p.TelegramUsername;
return $"{username} · {FormatParticipantRsvp(p.RsvpStatus)}";
}
private static string FormatParticipantRsvp(string rsvp) => rsvp switch
{
RsvpStatus.Pending => "⏳ не ответил",
RsvpStatus.Confirmed => "✅ подтвердил",
RsvpStatus.Declined => "❌ отказался",
_ => rsvp
};
private static string GetParticipantStatusClass(WebParticipant p)
{
if (p.IsGm) return "status-success";
return p.RegistrationStatus switch
{
"Active" => "status-info",
"Waitlisted" => "status-warning",
_ => "status-neutral"
};
}
private static string TranslateParticipantStatus(WebParticipant p)
{
if (p.IsGm) return "ГМ";
return p.RegistrationStatus switch
{
"Active" => "Основной состав",
"Waitlisted" => "Ожидание",
_ => p.RegistrationStatus
};
}
private async Task UpdateBatchDetails(BatchBulkEditModel batch) private async Task UpdateBatchDetails(BatchBulkEditModel batch)
{ {
errorMessage = null; errorMessage = null;
@@ -369,6 +744,39 @@
} }
} }
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
processingTemplateId = template.Id;
try
{
var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
if (utcTime <= DateTime.UtcNow)
{
errorMessage = "Первая дата batch должна быть в будущем.";
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать batch из шаблона: " + ex.Message;
}
finally
{
processingTemplateId = null;
}
}
private void RebuildBatchModels() private void RebuildBatchModels()
{ {
batchModels = sessions? batchModels = sessions?
@@ -395,6 +803,27 @@
.ToList() ?? []; .ToList() ?? [];
} }
private void RebuildCampaignTemplateModels()
{
var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow();
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateUsageModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
FirstScheduledAtLocal = defaultStart
})
.ToList() ?? [];
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch) private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{ {
batch.Title = batch.Title.Trim(); batch.Title = batch.Title.Trim();
@@ -404,6 +833,24 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId; private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + manager.TelegramUsername;
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
}
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions) private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{ {
if (orderedSessions.Count < 2) if (orderedSessions.Count < 2)
@@ -433,6 +880,22 @@
private static string FormatBatchSummary(BatchBulkEditModel batch) => private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}"; $"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
private static string FormatTemplateSummary(CampaignTemplateUsageModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatLocalMoscow(DateTime localMoscow) => private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU")); localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
@@ -466,4 +929,24 @@
public int SessionCount { get; init; } public int SessionCount { get; init; }
public string CloneInterval { get; set; } = "week"; public string CloneInterval { get; set; } = "week";
} }
private sealed class CampaignTemplateUsageModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
}
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
public string DisplayName { get; set; } = "";
public string? TelegramUsername { get; set; }
}
} }
@@ -0,0 +1,235 @@
@page "/group/{GroupId:guid}/stats"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Статистика — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li><a href="/group/@GroupId">Сессии группы</a></li>
<li class="active">Статистика</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📊 Статистика посещаемости</h2>
<p class="page-subtitle">Надёжность состава и качество расписания</p>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (stats is null)
{
<div class="loading-spinner">⏳ Загружаем статистику…</div>
}
else if (stats.Count == 0)
{
<div class="empty-state">
<div class="empty-icon">📈</div>
<h3>Пока нет данных</h3>
<p>После первых сессий здесь появится аналитика.</p>
</div>
}
else
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="stats-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="stat-card">
<div class="stat-value">@stats.Count</div>
<div class="stat-label">Игроков</div>
</div>
<div class="stat-card">
<div class="stat-value">@TotalSessions</div>
<div class="stat-label">Сессий</div>
</div>
<div class="stat-card">
<div class="stat-value">@AvgAttendanceRate%</div>
<div class="stat-label">Средняя посещаемость</div>
</div>
<div class="stat-card">
<div class="stat-value">@topPlayer?.DisplayName</div>
<div class="stat-label">Самый стабильный</div>
</div>
</div>
<div class="table-responsive">
<table class="gm-table" style="width: 100%;">
<thead>
<tr>
<th @onclick="@(() => SortBy("player"))" style="cursor:pointer;" class="sortable">Игрок @(sortColumn == "player" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("total"))" style="cursor:pointer; text-align:center;" class="sortable">Всего @(sortColumn == "total" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("confirmed"))" style="cursor:pointer; text-align:center;" class="sortable">✅ @(sortColumn == "confirmed" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("declined"))" style="cursor:pointer; text-align:center;" class="sortable">❌ @(sortColumn == "declined" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("noresponse"))" style="cursor:pointer; text-align:center;" class="sortable">💤 @(sortColumn == "noresponse" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("waitlist"))" style="cursor:pointer; text-align:center;" class="sortable">⏳ @(sortColumn == "waitlist" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("rate"))" style="cursor:pointer; text-align:center;" class="sortable">% @(sortColumn == "rate" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("cancelled"))" style="cursor:pointer; text-align:center;" class="sortable">🚫 @(sortColumn == "cancelled" ? (sortDesc ? "▼" : "▲") : "")</th>
</tr>
</thead>
<tbody>
@foreach (var s in sortedStats)
{
<tr>
<td>
<div class="player-info">
<span class="player-name">@s.DisplayName</span>
@if (!string.IsNullOrEmpty(s.TelegramUsername))
{
<span class="player-username">@@@s.TelegramUsername</span>
}
</div>
</td>
<td style="text-align:center;">@s.TotalSessions</td>
<td style="text-align:center;">@s.ConfirmedCount</td>
<td style="text-align:center;">@s.DeclinedCount</td>
<td style="text-align:center;">@s.NoResponseCount</td>
<td style="text-align:center;">@s.WaitlistedCount</td>
<td style="text-align:center;">
<span class="rate-badge @AttendanceBadgeClass(s.AttendanceRate)">
@s.AttendanceRate%
</span>
</td>
<td style="text-align:center;">@s.CancellationAffectedCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
<style>
.stat-card {
background: var(--card-bg-secondary, rgba(255,255,255,0.05));
border-radius: 0.75rem;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent-color, #7cb97a);
}
.stat-label {
font-size: 0.8rem;
color: var(--text-muted, #94a3b8);
margin-top: 0.25rem;
}
.player-info {
display: flex;
flex-direction: column;
}
.player-name {
font-weight: 500;
}
.player-username {
font-size: 0.8rem;
color: var(--text-muted, #94a3b8);
}
.rate-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
}
.rate-excellent { background: rgba(34,197,94,0.15); color: #22c55e; }
.rate-good { background: rgba(234,179,8,0.15); color: #eab308; }
.rate-poor { background: rgba(239,68,68,0.15); color: #ef4444; }
</style>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<PlayerAttendanceStats>? stats;
private List<PlayerAttendanceStats> sortedStats = new();
private string? errorMessage;
private string sortColumn = "confirmed";
private bool sortDesc = true;
private int TotalSessions => stats?.Count > 0 ? (int)(stats.Max(s => s.TotalSessions)) : 0;
private int AvgAttendanceRate => stats?.Count > 0 ? (int)(stats.Average(s => s.AttendanceRate)) : 0;
private PlayerAttendanceStats? topPlayer => stats?.OrderByDescending(s => s.AttendanceRate).ThenByDescending(s => s.ConfirmedCount).FirstOrDefault();
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (!user.Identity?.IsAuthenticated ?? true)
{
Navigation.NavigateTo("/login");
return;
}
var telegramIdClaim = user.FindFirst("telegram_id")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!long.TryParse(telegramIdClaim, out var telegramId))
{
Navigation.NavigateTo("/login");
return;
}
try
{
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new();
UpdateSortedStats();
}
catch (Exception ex)
{
errorMessage = $"Ошибка загрузки статистики: {ex.Message}";
}
}
private void SortBy(string column)
{
if (sortColumn == column)
sortDesc = !sortDesc;
else
{
sortColumn = column;
sortDesc = true;
}
UpdateSortedStats();
}
private void UpdateSortedStats()
{
if (stats is null) { sortedStats = new(); return; }
IOrderedEnumerable<PlayerAttendanceStats> ordered = sortColumn switch
{
"player" => sortDesc ? stats.OrderByDescending(s => s.DisplayName) : stats.OrderBy(s => s.DisplayName),
"total" => sortDesc ? stats.OrderByDescending(s => s.TotalSessions) : stats.OrderBy(s => s.TotalSessions),
"confirmed" => sortDesc ? stats.OrderByDescending(s => s.ConfirmedCount) : stats.OrderBy(s => s.ConfirmedCount),
"declined" => sortDesc ? stats.OrderByDescending(s => s.DeclinedCount) : stats.OrderBy(s => s.DeclinedCount),
"noresponse" => sortDesc ? stats.OrderByDescending(s => s.NoResponseCount) : stats.OrderBy(s => s.NoResponseCount),
"waitlist" => sortDesc ? stats.OrderByDescending(s => s.WaitlistedCount) : stats.OrderBy(s => s.WaitlistedCount),
"rate" => sortDesc ? stats.OrderByDescending(s => s.AttendanceRate) : stats.OrderBy(s => s.AttendanceRate),
"cancelled" => sortDesc ? stats.OrderByDescending(s => s.CancellationAffectedCount) : stats.OrderBy(s => s.CancellationAffectedCount),
_ => stats.OrderByDescending(s => s.ConfirmedCount)
};
sortedStats = ordered.ToList();
}
private string SortIndicator(string column) => sortColumn == column ? (sortDesc ? "▼" : "▲") : "";
private string AttendanceBadgeClass(decimal rate) => rate switch
{
>= 75m => "rate-excellent",
>= 50m => "rate-good",
_ => "rate-poor"
};
}
@@ -1,6 +1,7 @@
@page "/" @page "/"
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Shared.Domain
@using GmRelay.Web.Services @using GmRelay.Web.Services
@attribute [Authorize] @attribute [Authorize]
@inject AuthorizedSessionService SessionService @inject AuthorizedSessionService SessionService
@@ -43,6 +44,9 @@
<div class="group-card-icon">🎮</div> <div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3> <h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p> <p class="group-card-id">ID: @group.TelegramChatId</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole)
</span>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;"> <a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры → Посмотреть игры →
</a> </a>
@@ -97,4 +101,7 @@
groups = await SessionService.GetGroupsForGmAsync(telegramId); groups = await SessionService.GetGroupsForGmAsync(telegramId);
} }
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
} }
+3 -3
View File
@@ -8,7 +8,7 @@
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-card">
<div class="login-logo">🎲</div> <img src="logo.png" alt="GM-Relay" class="login-logo" />
<h1 class="login-title">GM-Relay</h1> <h1 class="login-title">GM-Relay</h1>
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p> <p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
@@ -25,7 +25,6 @@
@code { @code {
private string BotUsername => Configuration["Telegram:BotUsername"] ?? "GmRelayBot"; private string BotUsername => Configuration["Telegram:BotUsername"] ?? "GmRelayBot";
private string AuthUrl => Navigation.ToAbsoluteUri("/auth/telegram").ToString();
[CascadingParameter] [CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; } private Task<AuthenticationState>? AuthStateTask { get; set; }
@@ -46,7 +45,8 @@
{ {
if (firstRender) if (firstRender)
{ {
await JS.InvokeVoidAsync("loadTelegramWidget", BotUsername, AuthUrl); await JS.InvokeVoidAsync("loadTelegramWidget", BotUsername, "/auth/telegram-login");
await JS.InvokeVoidAsync("watchTelegramMiniAppLogin", "/auth/status", "/", false);
} }
} }
} }
@@ -0,0 +1,103 @@
@page "/miniapp"
@using Microsoft.AspNetCore.Components.Authorization
@using System.Text.Json.Serialization
@inject IJSRuntime JS
@inject NavigationManager Navigation
<PageTitle>Mini App Dashboard — GM-Relay</PageTitle>
<div class="mini-app-page">
<div class="mini-app-auth-card" data-auth-status="@miniAppAuthStatus">
<img src="logo.png" alt="GM-Relay" class="mini-app-logo" />
<h1>GM-Relay</h1>
<p>@statusMessage</p>
@if (showFallback)
{
<a href="/login" class="btn-gm btn-gm-primary">Войти через Telegram</a>
}
</div>
</div>
@code {
private string statusMessage = "Открываем dashboard внутри Telegram...";
private string miniAppAuthStatus = "starting";
private bool showFallback;
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
protected override async Task OnInitializedAsync()
{
if (AuthStateTask is null)
{
return;
}
var user = (await AuthStateTask).User;
if (user.Identity?.IsAuthenticated == true)
{
Navigation.NavigateTo("/");
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
try
{
var result = await JS.InvokeAsync<MiniAppAuthResult>(
"authenticateTelegramMiniApp",
"/auth/telegram-webapp",
"/");
if (!result.Authenticated)
{
miniAppAuthStatus = string.IsNullOrWhiteSpace(result.Reason)
? "telegram-auth-failed"
: result.Reason;
statusMessage = GetStatusMessage(miniAppAuthStatus);
showFallback = true;
StateHasChanged();
await TryWatchLoginAsync();
}
}
catch (JSException)
{
miniAppAuthStatus = "telegram-auth-failed";
statusMessage = "Не удалось получить данные Telegram Mini App. Попробуйте открыть dashboard из бота.";
showFallback = true;
StateHasChanged();
await TryWatchLoginAsync();
}
}
private async Task TryWatchLoginAsync()
{
try
{
await JS.InvokeVoidAsync("watchTelegramMiniAppLogin", "/auth/status", "/");
}
catch (JSException)
{
}
}
private static string GetStatusMessage(string reason) => reason switch
{
"telegram-webapp-missing" => "Mini App API не найден. Если страница открыта в браузере, войдите через Telegram.",
"telegram-init-data-empty" => "Telegram открыл страницу без Mini App initData. Попробуйте войти через Telegram на этом экране.",
"telegram-auth-failed" => "Не удалось проверить Telegram Mini App. Попробуйте войти через Telegram.",
_ => "Mini App доступен из Telegram. Для браузера используйте обычный вход."
};
private sealed record MiniAppAuthResult(
[property: JsonPropertyName("authenticated")] bool Authenticated,
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("status")] int? Status,
[property: JsonPropertyName("redirectUrl")] string? RedirectUrl);
}
@@ -0,0 +1,124 @@
@page "/session/{SessionId:guid}/history"
@using GmRelay.Web.Services
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>История изменений — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li><a href="/group/@groupId">Группа</a></li>
<li class="active">История изменений</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📜 История изменений</h2>
@if (sessionTitle is not null)
{
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p>
}
</div>
@if (entries is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
</div>
}
else if (entries.Count == 0)
{
<div class="glass-card animate-slide-up" style="padding: 2rem; text-align: center;">
<p style="color: var(--text-muted);">История изменений пуста. Значимые изменения (время, ссылка, название, участники) будут отображаться здесь.</p>
</div>
}
else
{
<div class="glass-card animate-slide-up">
<div class="table-responsive">
<table class="gm-table">
<thead>
<tr>
<th>Время</th>
<th>Актор</th>
<th>Тип изменения</th>
<th>Было</th>
<th>Стало</th>
</tr>
</thead>
<tbody>
@foreach (var entry in entries)
{
<tr>
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
<td>@entry.ActorName (@entry.ActorTelegramId)</td>
<td>
<span class="status-badge @(GetBadgeClass(entry.ChangeType))">
@GetChangeTypeLabel(entry.ChangeType)
</span>
</td>
<td style="max-width: 200px; overflow-wrap: break-word; color: var(--text-muted);">@(entry.OldValue ?? "—")</td>
<td style="max-width: 200px; overflow-wrap: break-word;">@(entry.NewValue ?? "—")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
[Parameter] public Guid SessionId { get; set; }
private List<SessionAuditLogEntry>? entries;
private string? sessionTitle;
private Guid? groupId;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessionTitle = session.Title;
groupId = session.GroupId;
entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId);
}
private string GetChangeTypeLabel(string changeType) => changeType switch
{
"Title" => "Название",
"Time" => "Время",
"Link" => "Ссылка",
"MaxPlayers" => "Лимит мест",
"Status" => "Статус",
"WaitlistPromote" => "Продвижение из листа ожидания",
"PlayerRemoved" => "Исключение игрока",
"BatchRescheduled" => "Перенос батча",
"Cancelled" => "Отмена",
_ => changeType
};
private string GetBadgeClass(string changeType) => changeType switch
{
"Cancelled" or "PlayerRemoved" => "status-danger",
"WaitlistPromote" => "status-success",
"BatchRescheduled" or "Time" => "status-warning",
_ => "status-info"
};
}
+3 -1
View File
@@ -18,8 +18,10 @@ RUN dotnet publish "GmRelay.Web.csproj" -c Release -o /app/publish /p:UseAppHost
# Stage 2: Runtime # Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 wget && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish . COPY --from=build /app/publish .
RUN mkdir -p /app/dataprotection-keys && chown -R $APP_UID:$APP_UID /app/dataprotection-keys
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080 EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "GmRelay.Web.dll"] ENTRYPOINT ["dotnet", "GmRelay.Web.dll"]
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Npgsql;
namespace GmRelay.Web.Health;
public sealed class NpgsqlHealthCheck(NpgsqlDataSource dataSource) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = "SELECT 1";
await command.ExecuteScalarAsync(cancellationToken);
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("PostgreSQL is unavailable", ex);
}
}
}
+107 -10
View File
@@ -1,9 +1,13 @@
using GmRelay.Web.Components; using GmRelay.Web.Components;
using GmRelay.Web.Health;
using GmRelay.Web.Services; using GmRelay.Web.Services;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Telegram.Bot; using Telegram.Bot;
using Npgsql; using Npgsql;
@@ -12,6 +16,10 @@ var builder = WebApplication.CreateBuilder(args);
// Add Aspire service defaults // Add Aspire service defaults
builder.AddServiceDefaults(); builder.AddServiceDefaults();
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck<NpgsqlHealthCheck>("npgsql");
// Add Data Protection // Add Data Protection
builder.Services.AddDataProtection() builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/app/dataprotection-keys")); .PersistKeysToFileSystem(new DirectoryInfo("/app/dataprotection-keys"));
@@ -23,6 +31,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
builder.Services.AddSingleton<TelegramAuthService>(); builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddSingleton<ISessionStore, SessionService>(); builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>(); builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
// Add Bot Client // Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp => builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -82,32 +91,120 @@ app.MapStaticAssets();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
// Health check endpoints
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var response = new
{
status = report.Status == HealthStatus.Healthy ? "healthy" : "unhealthy",
timestamp = DateTimeOffset.UtcNow.ToString("O")
};
await context.Response.WriteAsJsonAsync(response);
}
});
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
// Endpoint to handle Telegram Login callback // Endpoint to handle Telegram Login callback
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService) => app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService) =>
{ {
if (authService.Verify(context.Request.Query, out var telegramId, out var name)) if (authService.Verify(context.Request.Query, out var telegramId, out var name))
{ {
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, telegramId.ToString()),
new Claim(ClaimTypes.Name, name),
new Claim("TelegramId", telegramId.ToString())
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var authProperties = new AuthenticationProperties { IsPersistent = true }; var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), authProperties); CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Redirect("/"); return Results.Redirect("/");
} }
return Results.Redirect("/login?error=auth_failed"); return Results.Redirect("/login?error=auth_failed");
}); });
app.MapPost("/auth/telegram-webapp", async (
HttpContext context,
TelegramAuthService authService,
TelegramWebAppAuthRequest request) =>
{
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
{
return Results.Unauthorized();
}
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Ok(new { redirectUrl = "/" });
}).DisableAntiforgery();
app.MapPost("/auth/telegram-login", async (
HttpContext context,
TelegramAuthService authService,
TelegramLoginPayload request) =>
{
if (!authService.VerifyLoginPayload(request, out var telegramId, out var name))
{
return Results.Unauthorized();
}
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Ok(new { redirectUrl = "/" });
}).DisableAntiforgery();
app.MapGet("/auth/status", (HttpContext context) =>
Results.Ok(new { authenticated = context.User.Identity?.IsAuthenticated == true }));
app.MapPost("/auth/logout", async (HttpContext context) => app.MapPost("/auth/logout", async (HttpContext context) =>
{ {
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/"); return Results.Redirect("/");
}); });
// Public calendar subscription endpoint (no auth required)
app.MapGet("/calendar/{token}.ics", async (
string token,
CalendarSubscriptionService service,
CancellationToken ct) =>
{
try
{
var ics = await service.GetIcsAsync(token, ct);
var bytes = System.Text.Encoding.UTF8.GetBytes(ics);
return Results.File(bytes, "text/calendar", "schedule.ics");
}
catch (SubscriptionNotFoundException)
{
return Results.NotFound();
}
});
app.Run(); app.Run();
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new(ClaimTypes.Name, name),
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture))
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(claimsIdentity);
}
public sealed record TelegramWebAppAuthRequest(string InitData);
@@ -7,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) => public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId); sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
var group = await sessionStore.GetGroupAsync(groupId);
if (group is null)
{
return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
return new WebGroupManagement(group, managers, isOwner);
}
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId) public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
{ {
if (!await GroupBelongsToGmAsync(groupId, gmId)) if (!await GroupBelongsToGmAsync(groupId, gmId))
@@ -48,6 +66,15 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
} }
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers); await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
if (session.Title != title)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", session.Title, title);
if (session.ScheduledAt != scheduledAt)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
if (session.JoinLink != joinLink)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", session.JoinLink, joinLink);
if (session.MaxPlayers != maxPlayers)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
} }
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId) public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId)
@@ -59,6 +86,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
} }
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId); await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "WaitlistPromote", null, null);
} }
public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink) public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink)
@@ -97,6 +125,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
} }
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays); await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
await sessionStore.LogSessionChangeAsync(batchId, gmId, "ГМ", "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O"));
} }
public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval) public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval)
@@ -110,9 +139,177 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
} }
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
return await sessionStore.GetCampaignTemplatesAsync(groupId);
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync(
Guid groupId,
long gmId,
CreateCampaignTemplateRequest request)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
throw new SessionAccessDeniedException(groupId, gmId);
}
var normalizedRequest = NormalizeCampaignTemplateRequest(request);
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
}
public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId)
{
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
}
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync(
Guid templateId,
long gmId,
DateTime firstScheduledAt)
{
if (firstScheduledAt <= DateTime.UtcNow)
{
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
}
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
if (coGmTelegramId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
}
if (ownerTelegramId == coGmTelegramId)
{
throw new InvalidOperationException("Owner is already a group manager.");
}
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
: displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
? null
: telegramUsername.Trim().TrimStart('@');
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
}
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
{
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
}
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);
}
public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionHistoryAsync(sessionId);
}
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);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "PlayerRemoved", participantId.ToString(), null);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId) private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
{ {
var group = await sessionStore.GetGroupAsync(groupId); return await sessionStore.IsGroupManagerAsync(groupId, gmId);
return group?.GmTelegramId == gmId; }
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
{
var name = request.Name.Trim();
var title = request.Title.Trim();
var joinLink = request.JoinLink.Trim();
if (name.Length == 0)
{
throw new ArgumentException("Template name must not be empty.", nameof(request));
}
if (title.Length == 0)
{
throw new ArgumentException("Session title must not be empty.", nameof(request));
}
if (joinLink.Length == 0)
{
throw new ArgumentException("Join link must not be empty.", nameof(request));
}
if (request.SessionCount is < 1 or > 52)
{
throw new ArgumentOutOfRangeException(nameof(request), request.SessionCount, "Session count must be between 1 and 52.");
}
if (request.IntervalDays is < 1 or > 365)
{
throw new ArgumentOutOfRangeException(nameof(request), request.IntervalDays, "Interval must be between 1 and 365 days.");
}
if (request.MaxPlayers is <= 0)
{
throw new ArgumentOutOfRangeException(nameof(request), request.MaxPlayers, "Seat limit must be greater than zero.");
}
return request with
{
Name = name,
Title = title,
JoinLink = joinLink
};
} }
} }
@@ -0,0 +1,51 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Web.Services;
/// <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 (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);
}
}
}
@@ -18,8 +18,33 @@ public sealed record WebSessionBatch(
int SessionCount, int SessionCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
public sealed record WebCampaignTemplate(
Guid Id,
Guid GroupId,
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
string NotificationMode,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record CreateCampaignTemplateRequest(
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
SessionNotificationMode NotificationMode);
public static class BatchSchedulePlanner public static class BatchSchedulePlanner
{ {
private const int MaxTemplateSessionCount = 52;
private const int MaxTemplateIntervalDays = 365;
public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule( public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule(
IEnumerable<DateTime> currentSchedule, IEnumerable<DateTime> currentSchedule,
DateTime firstScheduledAt, DateTime firstScheduledAt,
@@ -36,6 +61,26 @@ public static class BatchSchedulePlanner
.ToList(); .ToList();
} }
public static IReadOnlyList<DateTime> BuildRecurringSchedule(
DateTime firstScheduledAt,
int sessionCount,
int intervalDays)
{
if (sessionCount is < 1 or > MaxTemplateSessionCount)
{
throw new ArgumentOutOfRangeException(nameof(sessionCount), sessionCount, "Session count must be between 1 and 52.");
}
if (intervalDays is < 1 or > MaxTemplateIntervalDays)
{
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be between 1 and 365 days.");
}
return Enumerable.Range(0, sessionCount)
.Select(index => firstScheduledAt.AddDays(intervalDays * index))
.ToList();
}
public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) => public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) =>
interval switch interval switch
{ {
@@ -0,0 +1,93 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Web.Services;
public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
{
private const string IcsProdId = "-//GM-Relay//TTRPG Schedule//EN";
public string GenerateToken() => Guid.NewGuid().ToString("N");
public async Task<string> CreateSubscriptionAsync(
long userTelegramId,
Guid? groupId,
CalendarSubscriptionFilter filter,
CancellationToken ct = default)
{
var token = GenerateToken();
await using var connection = await dataSource.OpenConnectionAsync(ct);
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, groupId, filterType = (int)filter });
return token;
}
public async Task<string> GetIcsAsync(string token, CancellationToken ct = default)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType
FROM calendar_subscriptions
WHERE token = @token
AND (expires_at IS NULL OR expires_at > now())",
new { token });
if (subscription is null)
throw new SubscriptionNotFoundException();
var sessions = await connection.QueryAsync<CalendarSessionDto>(
subscription.FilterType == (int)CalendarSubscriptionFilter.SpecificGroup && subscription.GroupId.HasValue
? @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
WHERE s.group_id = @GroupId
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
WHERE s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
new { subscription.GroupId, Planned = SessionStatus.Planned });
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine($"PRODID:{IcsProdId}");
sb.AppendLine("CALSCALE:GREGORIAN");
sb.AppendLine("METHOD:PUBLISH");
foreach (var s in sessions)
{
var dtStart = FormatIcsDate(s.ScheduledAt);
var dtEnd = FormatIcsDate(s.ScheduledAt.AddHours(4));
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{FormatIcsDate(DateTime.UtcNow)}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{EscapeIcsText(s.Title)}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
return sb.ToString();
}
private static string FormatIcsDate(DateTime dt) => dt.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
private static string EscapeIcsText(string text) => text
.Replace("\\", "\\\\")
.Replace(";", "\\;")
.Replace(",", "\\,")
.Replace("\n", "\\n")
.Replace("\r", "");
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType);
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
}
+39
View File
@@ -2,10 +2,37 @@ using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
public sealed record PlayerAttendanceStats(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TotalSessions,
long ConfirmedCount,
long DeclinedCount,
long NoResponseCount,
long WaitlistedCount,
long CancellationAffectedCount,
decimal AttendanceRate
);
public sealed record SessionAuditLogEntry(
Guid Id,
Guid SessionId,
long ActorTelegramId,
string ActorName,
string ChangeType,
string? OldValue,
string? NewValue,
DateTime ChangedAt
);
public interface ISessionStore public interface ISessionStore
{ {
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId); Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId); Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId); Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId); Task<WebSession?> GetSessionAsync(Guid sessionId);
Task<WebSessionBatch?> GetBatchAsync(Guid batchId); Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
@@ -15,4 +42,16 @@ public interface ISessionStore
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode); Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays); Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval); Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId);
Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId);
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
} }
+634 -22
View File
@@ -3,10 +3,28 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using GmRelay.Web.Services;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId); public sealed record WebGameGroup(
Guid Id,
long TelegramChatId,
string Name,
long GmTelegramId,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
public sealed record WebGroupManager(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string Role,
DateTime AddedAt);
public sealed record WebGroupManagement(
WebGameGroup Group,
IReadOnlyList<WebGroupManager> Managers,
bool CurrentUserIsOwner);
public sealed record WebSession( public sealed record WebSession(
Guid Id, Guid Id,
Guid GroupId, Guid GroupId,
@@ -20,7 +38,18 @@ public sealed record WebSession(
int? MaxPlayers, int? MaxPlayers,
int ActivePlayerCount, int ActivePlayerCount,
int WaitlistedPlayerCount, int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
int? ThreadId = null);
public sealed record WebParticipant(
Guid Id,
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm,
DateTime? RespondedAt);
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName); internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
@@ -45,7 +74,9 @@ internal sealed record WebBatchSessionRow(
int? BatchMessageId, int? BatchMessageId,
long TelegramChatId, long TelegramChatId,
int? ThreadId, int? ThreadId,
string NotificationMode); string NotificationMode,
bool TopicCreatedByBot = false);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
public sealed class SessionService( public sealed class SessionService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -56,7 +87,18 @@ public sealed class SessionService(
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>( return (await conn.QueryAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId", """
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
gm.role AS ManagerRole
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE p.telegram_id = @GmId
ORDER BY g.name
""",
new { GmId = gmId })).ToList(); new { GmId = gmId })).ToList();
} }
@@ -64,8 +106,207 @@ public sealed class SessionService(
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>( return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId", """
new { GroupId = groupId }); SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
@OwnerRole AS ManagerRole
FROM game_groups g
WHERE g.id = @GroupId
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
)
""",
new { GroupId = groupId, TelegramId = telegramId });
}
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
AND gm.role = @OwnerRole
)
""",
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
gm.role AS Role,
gm.created_at AS AddedAt
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
gm.created_at,
p.display_name
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
}
public async Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<PlayerAttendanceStats>(
"""
SELECT
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COUNT(DISTINCT s.id) AS TotalSessions,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Pending' THEN s.id END) AS NoResponseCount,
COUNT(DISTINCT CASE WHEN sp.registration_status = 'Waitlisted' THEN s.id END) AS WaitlistedCount,
COUNT(DISTINCT CASE WHEN s.status = 'Cancelled' AND sp.rsvp_status IN ('Confirmed','Declined') THEN s.id END) AS CancellationAffectedCount,
CASE WHEN COUNT(DISTINCT s.id) > 0
THEN ROUND(
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END)
* 100.0 / COUNT(DISTINCT s.id), 2)
ELSE 0
END AS AttendanceRate
FROM players p
JOIN session_participants sp ON sp.player_id = p.id
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = @GroupId
AND s.scheduled_at <= now()
AND sp.is_gm = false
GROUP BY p.id, p.display_name, p.telegram_username
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
""",
new { GroupId = groupId })).ToList();
}
public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value)
VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue)
""",
new { SessionId = sessionId, ActorTelegramId = actorTelegramId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
}
public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var entries = await conn.QueryAsync<SessionAuditLogEntry>(
"""
SELECT id, session_id AS SessionId, actor_telegram_id AS ActorTelegramId, actor_name AS ActorName,
change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
FROM session_audit_log
WHERE session_id = @SessionId
ORDER BY changed_at DESC
""",
new { SessionId = sessionId });
return entries.ToList();
}
public async Task AddGroupCoGmAsync(
Guid groupId,
long ownerTelegramId,
long coGmTelegramId,
string displayName,
string? telegramUsername)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername, 'Telegram', @TelegramId::TEXT, @TelegramUsername)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
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
{
TelegramId = coGmTelegramId,
DisplayName = displayName,
TelegramUsername = telegramUsername
},
transaction);
await conn.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
SELECT @GroupId,
co_gm.id,
@CoGmRole,
owner_player.id
FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
WHERE co_gm.telegram_id = @CoGmTelegramId
ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role
ELSE EXCLUDED.role
END,
added_by_player_id = EXCLUDED.added_by_player_id
""",
new
{
GroupId = groupId,
OwnerTelegramId = ownerTelegramId,
CoGmTelegramId = coGmTelegramId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
},
transaction);
await transaction.CommitAsync();
}
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
DELETE FROM group_managers gm
USING players p
WHERE gm.player_id = p.id
AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId
AND gm.role = @CoGmRole
""",
new
{
GroupId = groupId,
CoGmTelegramId = coGmTelegramId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
});
} }
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
@@ -78,7 +319,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -115,7 +357,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -173,7 +416,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
0 AS ActivePlayerCount, 0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId", WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -227,7 +471,11 @@ public sealed class SessionService(
"\n" + "\n" +
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>"; $"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); await bot.SendMessage(
chatId: oldSession.TelegramChatId,
messageThreadId: oldSession.ThreadId,
text: notification,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode); var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
@@ -254,7 +502,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
0 AS ActivePlayerCount, 0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -331,8 +580,9 @@ public sealed class SessionService(
await transaction.CommitAsync(); await transaction.CommitAsync();
await bot.SendMessage( await bot.SendMessage(
session.TelegramChatId, chatId: session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", messageThreadId: session.ThreadId,
text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (session.BatchMessageId.HasValue) if (session.BatchMessageId.HasValue)
@@ -341,6 +591,151 @@ public sealed class SessionService(
} }
} }
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();
}
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,
s.thread_id AS ThreadId
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);
}
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();
await bot.SendMessage(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: $"🚪 <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(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: $"⬆️ <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);
}
}
else if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
@@ -435,6 +830,7 @@ public sealed class SessionService(
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
@@ -487,7 +883,11 @@ public sealed class SessionService(
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" + $"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
$"↔️ Шаг: <b>{intervalDays} дн.</b>"; $"↔️ Шаг: <b>{intervalDays} дн.</b>";
await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); await bot.SendMessage(
chatId: firstSession.TelegramChatId,
messageThreadId: firstSession.ThreadId,
text: notification,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode); var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
@@ -514,6 +914,7 @@ public sealed class SessionService(
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
@@ -542,8 +943,8 @@ public sealed class SessionService(
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval); var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
var sessionId = await conn.ExecuteScalarAsync<Guid>( var sessionId = await conn.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players, notification_mode) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode) VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
RETURNING id RETURNING id
""", """,
new new
@@ -555,17 +956,19 @@ public sealed class SessionService(
ScheduledAt = scheduledAt, ScheduledAt = scheduledAt,
Status = SessionStatus.Planned, Status = SessionStatus.Planned,
ThreadId = threadId, ThreadId = threadId,
sourceSession.TopicCreatedByBot,
sourceSession.MaxPlayers, sourceSession.MaxPlayers,
sourceSession.NotificationMode sourceSession.NotificationMode
}, },
transaction); transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers)); renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
} }
await transaction.CommitAsync(); await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>()); var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage( var batchMessage = await bot.SendMessage(
chatId: chatId, chatId: chatId,
messageThreadId: threadId, messageThreadId: threadId,
@@ -588,6 +991,214 @@ public sealed class SessionService(
sourceSessions[0].NotificationMode); sourceSessions[0].NotificationMode);
} }
public async Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE group_id = @GroupId
ORDER BY created_at DESC, name
""",
new { GroupId = groupId })).ToList();
}
public async Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
""",
new { TemplateId = templateId });
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleAsync<WebCampaignTemplate>(
"""
INSERT INTO campaign_templates (
group_id,
name,
title,
join_link,
session_count,
interval_days,
max_players,
notification_mode
)
VALUES (
@GroupId,
@Name,
@Title,
@JoinLink,
@SessionCount,
@IntervalDays,
@MaxPlayers,
@NotificationMode
)
ON CONFLICT (group_id, name) DO UPDATE
SET title = EXCLUDED.title,
join_link = EXCLUDED.join_link,
session_count = EXCLUDED.session_count,
interval_days = EXCLUDED.interval_days,
max_players = EXCLUDED.max_players,
notification_mode = EXCLUDED.notification_mode,
updated_at = now()
RETURNING id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
""",
new
{
GroupId = groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
NotificationMode = request.NotificationMode.ToDatabaseValue()
});
}
public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId",
new { TemplateId = templateId, GroupId = groupId });
}
public async Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var template = await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
AND group_id = @GroupId
FOR UPDATE
""",
new { TemplateId = templateId, GroupId = groupId },
transaction);
if (template is null)
{
throw new SessionAccessDeniedException(templateId, 0);
}
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId },
transaction);
if (group is null)
{
throw new SessionAccessDeniedException(groupId, 0);
}
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
firstScheduledAt,
template.SessionCount,
template.IntervalDays);
var batchId = Guid.NewGuid();
var renderedSessions = new List<SessionBatchDto>();
foreach (var scheduledAt in schedule)
{
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
{
BatchId = batchId,
GroupId = groupId,
template.Title,
template.JoinLink,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
template.MaxPlayers,
template.NotificationMode
},
transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
}
await transaction.CommitAsync();
var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
await conn.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
new { MessageId = batchMessage.MessageId, BatchId = batchId });
return new WebSessionBatch(
batchId,
groupId,
template.Title,
template.JoinLink,
renderedSessions.Min(session => session.ScheduledAt),
renderedSessions.Max(session => session.ScheduledAt),
renderedSessions.Count,
template.NotificationMode);
}
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync( private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn, Npgsql.NpgsqlConnection conn,
Guid sessionId) Guid sessionId)
@@ -658,7 +1269,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var sessions = (await conn.QueryAsync<SessionBatchDto>( var sessions = (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 { BatchId = batchId })).ToList(); new { BatchId = batchId })).ToList();
var participants = (await conn.QueryAsync<ParticipantBatchDto>( var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -673,13 +1284,14 @@ public sealed class SessionService(
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC", ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = batchId })).ToList(); new { BatchId = batchId })).ToList();
var renderResult = SessionBatchRenderer.Render(title, sessions, participants); var view = SessionBatchViewBuilder.Build(title, sessions, participants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: chatId, chatId: chatId,
messageId: messageId, messageId: messageId,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup); replyMarkup: renderResult.Markup);
} }
catch (Exception ex) catch (Exception ex)
@@ -0,0 +1,6 @@
namespace GmRelay.Web.Services;
public sealed class SubscriptionNotFoundException : Exception
{
public SubscriptionNotFoundException() : base("Calendar subscription not found.") { }
}
@@ -1,5 +1,8 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.WebUtilities;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
@@ -55,4 +58,170 @@ public sealed class TelegramAuthService(IConfiguration configuration)
return false; return false;
} }
public bool VerifyWebAppInitData(string initData, out long telegramId, out string name)
{
telegramId = 0;
name = string.Empty;
if (string.IsNullOrWhiteSpace(initData))
return false;
var token = configuration["Telegram__BotToken"] ?? configuration["Telegram:BotToken"];
if (string.IsNullOrEmpty(token))
return false;
var values = QueryHelpers.ParseQuery(initData);
if (!values.TryGetValue("hash", out var hash) || string.IsNullOrWhiteSpace(hash))
return false;
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(token));
var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
byte[] hashBytes;
try
{
hashBytes = Convert.FromHexString(hash.ToString());
}
catch (FormatException)
{
return false;
}
if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes))
return false;
if (!values.TryGetValue("auth_date", out var authDateStr) ||
!long.TryParse(authDateStr, out var authDate))
{
return false;
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - authDate > 86400)
return false;
if (!values.TryGetValue("user", out var userJson) || string.IsNullOrWhiteSpace(userJson))
return false;
return TryReadWebAppUser(userJson.ToString(), out telegramId, out name);
}
public bool VerifyLoginPayload(TelegramLoginPayload payload, out long telegramId, out string name)
{
telegramId = 0;
name = string.Empty;
if (payload.Id <= 0 ||
string.IsNullOrWhiteSpace(payload.FirstName) ||
payload.AuthDate <= 0 ||
string.IsNullOrWhiteSpace(payload.Hash))
{
return false;
}
var token = configuration["Telegram__BotToken"] ?? configuration["Telegram:BotToken"];
if (string.IsNullOrEmpty(token))
return false;
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["auth_date"] = payload.AuthDate.ToString(System.Globalization.CultureInfo.InvariantCulture),
["first_name"] = payload.FirstName,
["id"] = payload.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(payload.LastName))
values["last_name"] = payload.LastName;
if (!string.IsNullOrWhiteSpace(payload.PhotoUrl))
values["photo_url"] = payload.PhotoUrl;
if (!string.IsNullOrWhiteSpace(payload.Username))
values["username"] = payload.Username;
var dataCheckString = string.Join("\n", values.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(token));
var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
byte[] hashBytes;
try
{
hashBytes = Convert.FromHexString(payload.Hash);
}
catch (FormatException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes))
return false;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - payload.AuthDate > 86400)
return false;
telegramId = payload.Id;
name = string.IsNullOrWhiteSpace(payload.LastName)
? payload.FirstName
: $"{payload.FirstName} {payload.LastName}";
return true;
}
private static bool TryReadWebAppUser(string userJson, out long telegramId, out string name)
{
telegramId = 0;
name = string.Empty;
try
{
using var document = JsonDocument.Parse(userJson);
var root = document.RootElement;
if (!root.TryGetProperty("id", out var idElement) || !idElement.TryGetInt64(out telegramId))
return false;
var firstName = root.TryGetProperty("first_name", out var firstNameElement)
? firstNameElement.GetString() ?? string.Empty
: string.Empty;
var lastName = root.TryGetProperty("last_name", out var lastNameElement)
? lastNameElement.GetString() ?? string.Empty
: string.Empty;
var username = root.TryGetProperty("username", out var usernameElement)
? usernameElement.GetString()
: null;
name = (firstName, lastName) switch
{
({ Length: > 0 }, { Length: > 0 }) => $"{firstName} {lastName}",
({ Length: > 0 }, _) => firstName,
_ when !string.IsNullOrWhiteSpace(username) => "@" + username,
_ => $"Telegram {telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
};
return true;
}
catch (JsonException)
{
return false;
}
}
} }
public sealed record TelegramLoginPayload(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("first_name")] string FirstName,
[property: JsonPropertyName("last_name")] string? LastName,
[property: JsonPropertyName("username")] string? Username,
[property: JsonPropertyName("photo_url")] string? PhotoUrl,
[property: JsonPropertyName("auth_date")] long AuthDate,
[property: JsonPropertyName("hash")] string Hash);

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