Compare commits

...

98 Commits

Author SHA1 Message Date
Toutsu d81564c308 Merge pull request #109: feat: добавить каталог игр и витрину ваншотов (issue #39)
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 17:56:53 +03:00
Toutsu accb3b2405 fix(web): правильный парсинг query string для ?register=1
PR Checks / test-and-build (pull_request) Successful in 12m50s
Заменен Navigation.Uri.Contains() на QueryHelpers.ParseQuery
для корректного определения параметра register без ложных
срабатываний на подстроки (например, register=10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:28:51 +03:00
Toutsu a63e3bef1e fix: финальные правки ревью для issue #39
PR Checks / test-and-build (pull_request) Successful in 13m16s
- PublicSession.razor: добавлена обработка ?register=1, AuthStateProvider,
  TryGetPlatformIdentity, кнопки записи для авторизованных/неавторизованных
  пользователей, отображение результата регистрации
- CreateSessionHandler: добавлен cover_image_url в INSERT SQL
- DiscordProjectStructureTests: версия 3.4.0 во всех проверках
- README.md: актуальная версия v3.4.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:00:33 +03:00
Toutsu 9d9aca53df chore: bump version to 3.4.0 2026-05-28 16:43:11 +03:00
Toutsu 5b6971fda5 test: consolidate capacity tests, add GameSystem edge cases, remove ShowcaseQueryTests 2026-05-28 16:40:11 +03:00
Toutsu b496a401fc test: add GameSystem fuzzy matching and showcase query tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:33:29 +03:00
Toutsu 76c6818952 fix(shared): add missing Platform parameter in players upsert 2026-05-28 16:28:25 +03:00
Toutsu 633a020212 fix(shared): convert GameSystem to string in SQL, guard rollback after commit 2026-05-28 16:26:54 +03:00
Toutsu ab38238fe8 feat(shared): extend CreateSessionCommand with showcase metadata
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:20:44 +03:00
Toutsu 4145cacc52 fix(web): add session-description styles for public session detail 2026-05-28 16:18:58 +03:00
Toutsu 6d59737d07 feat(web): update public session detail with showcase fields
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:13:35 +03:00
Toutsu 71ffcce06b fix(web): add try/finally, concurrency guard, accessible label, registration link to showcase 2026-05-28 16:07:09 +03:00
Toutsu 72f43dbef2 feat(web): add /showcase catalog page with filters
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:00:21 +03:00
Toutsu a5f4a68c6a fix(web): add public-session guards and ON CONFLICT to RegisterFromShowcaseAsync 2026-05-28 15:40:21 +03:00
Toutsu b2497ed877 feat(web): add showcase query and registration methods 2026-05-28 15:27:18 +03:00
Toutsu 9b42ea034a fix(shared): align GameSystem enum with spec, make TryParseFuzzy nullable and display-name based 2026-05-28 15:06:39 +03:00
Toutsu f94bea3e74 feat(shared): add GameSystem enum and Showcase DTOs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:58:49 +03:00
Toutsu cde1e4311f feat(db): V027 add showcase fields to sessions 2026-05-28 14:46:04 +03:00
Toutsu 847a40815f docs: add implementation plan for issue #39 2026-05-28 14:41:12 +03:00
Toutsu 6fd03ef836 docs: add design spec for issue #39 game catalog and showcase 2026-05-28 14:31:17 +03:00
Toutsu c2ccc35e50 Merge pull request #107: feat: add public club pages
Deploy Telegram Bot / build-and-push (push) Successful in 6m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 12:38:33 +03:00
Toutsu 3418d1a46c feat: add public club pages
PR Checks / test-and-build (pull_request) Successful in 12m47s
Add publication settings for clubs and sessions, read-only public club/session pages, dashboard controls, privacy-focused public queries, docs, and tests.

Bump version to 3.3.0
2026-05-28 12:23:47 +03:00
Toutsu fac5d75c7e Fix Discord co-GM management
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 34s
2026-05-27 16:32:47 +03:00
Toutsu 7a2965b43f fix(bot): add missing DI registrations for shared DeleteSessionHandler and ListSessionsHandler
Deploy Telegram Bot / build-and-push (push) Successful in 6m39s
Deploy Telegram Bot / scan-images (push) Successful in 3m26s
Deploy Telegram Bot / deploy (push) Successful in 29s
PR #106 extracted DeleteSessionHandler and ListSessionsHandler to GmRelay.Shared,
but forgot to register the shared implementations in Program.cs. This caused
an InvalidOperationException at startup on Native AOT builds because the Bot
wrappers could not resolve their shared dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:58:51 +03:00
Toutsu a0df94fc91 Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot
Deploy Telegram Bot / build-and-push (push) Successful in 7m11s
Deploy Telegram Bot / scan-images (push) Successful in 3m27s
Deploy Telegram Bot / deploy (push) Failing after 1m3s
2026-05-27 15:19:32 +03:00
Toutsu 79694f7de8 Merge pull request #106: refactor: extract remaining Telegram handlers to platform-neutral contracts 2026-05-27 15:19:23 +03:00
Toutsu 542f15f2d6 refactor: extract remaining Telegram handlers to platform-neutral contracts
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler,
  ExportCalendarHandler, HandleRescheduleTimeInputHandler,
  HandleRescheduleVoteHandler to GmRelay.Shared
- Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync,
  SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync
- Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers
- Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler
- Update UpdateRouter with explicit type aliases for ambiguous handler names
- Add contract and source-inspection tests for extracted handlers
- Bump version 3.1.1 → 3.2.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:52:09 +03:00
Toutsu 64216f5a26 Merge pull request #105: fix template batch topics
Deploy Telegram Bot / build-and-push (push) Successful in 6m3s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-27 14:05:38 +03:00
Toutsu 383e2c1d8d fix: create Telegram topics for template batches
PR Checks / test-and-build (pull_request) Successful in 12m56s
Create a Telegram forum topic when Web creates a batch from a campaign template, persist thread ownership on the generated sessions, and send the batch schedule into that topic.

Bump version -> 3.1.1
2026-05-27 13:50:18 +03:00
Toutsu bfa979a224 Merge pull request #104: refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
Deploy Telegram Bot / build-and-push (push) Successful in 6m43s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 30s
- Migrated all core domain SQL from telegram_* columns to platform + external_*
- Added V024/V025 migrations with backfill and deprecation comments
- Removed all COALESCE(external_*, telegram_*) fallbacks
- Replaced gm_telegram_id join with group_managers query in HandleRsvpHandler
- Updated Bot, Web, DiscordBot, and Shared handlers
- Bumped version to 3.1.0

CI run #265 passed (289 tests, 0 warnings, 0 vulnerabilities)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:19:47 +03:00
Toutsu c69ebf6c03 test: update DiscordProjectStructureTests version asserts to 3.1.0
PR Checks / test-and-build (pull_request) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:58:18 +03:00
Toutsu 040b0a3cdb refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
PR Checks / test-and-build (pull_request) Failing after 13m15s
- Добавлены миграции V024 (backfill + deprecation comments + calendar_subscriptions platform identity) и V025 (backfill proposed_by_external_user_id)
- Все Bot handlers переведены с telegram_id/chat_id на platform + external_*
- Shared handlers очищены от COALESCE fallback с telegram_* колонками
- DiscordBot очищен от COALESCE fallback
- Web SessionService и CalendarSubscriptionService переведены на external_*
- HandleRsvpHandler: убран legacy UNION с gm_telegram_id, теперь только group_managers
- RescheduleVotingFinalizer: переведен на external_username/external_user_id
- Tests: добавлены asserts для V024/V025
- Версия обновлена до 3.1.0

Bump version → 3.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:41:15 +03:00
Toutsu a5aed14dd2 fix(discord): add backoff to scheduler to prevent 403 spam
Deploy Telegram Bot / build-and-push (push) Successful in 6m37s
Deploy Telegram Bot / scan-images (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 33s
- SessionSchedulerService now backs off for 15 minutes after any
  handler failure (confirmation, one-hour reminder, join link),
  preventing infinite retry loops on Discord 403 Missing Access.
- Added per-session ConcurrentDictionary backoff tracking with
  automatic cleanup on success.
- Enhanced DiscordPlatformMessenger logging for SendConfirmation
  and SendJoinLink to aid permission diagnostics.
- Added 3 regression tests for backoff behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:51:25 +03:00
Toutsu 9fc434b42b fix(discord): treat /newsession and /reschedule input as Moscow time (UTC+3)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 3m20s
Deploy Telegram Bot / deploy (push) Successful in 34s
DiscordNewSessionHandler.ParseTimeInput used DateTimeStyles.AssumeUniversal,
which interpreted user input as UTC. A user entering 15:00 got a session
scheduled at 18:00 MSK after rendering. Align with Telegram behavior by
treating input as Moscow time and converting to UTC before storage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:12:00 +03:00
Toutsu c2cc7fd9a8 fix(web): show discord sessions and integration labels
Deploy Telegram Bot / build-and-push (push) Successful in 5m46s
Deploy Telegram Bot / scan-images (push) Successful in 3m29s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 14:43:33 +03:00
Toutsu 3447acd8c4 fix(discord): update sessions via interactions
Deploy Telegram Bot / build-and-push (push) Successful in 6m14s
Deploy Telegram Bot / scan-images (push) Successful in 3m12s
Deploy Telegram Bot / deploy (push) Successful in 31s
2026-05-26 14:24:06 +03:00
Toutsu 56aeca5288 fix(discord): sanitize embed join links
Deploy Telegram Bot / build-and-push (push) Successful in 5m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m6s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:57:11 +03:00
Toutsu 6ed0a120a0 fix(discord): avoid duplicate schedule send after new session
Deploy Telegram Bot / build-and-push (push) Successful in 6m0s
Deploy Telegram Bot / scan-images (push) Successful in 3m22s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:40:59 +03:00
Toutsu 682dd3fdec Merge pull request #103: fix(db): make legacy telegram_* columns nullable for Discord multi-platform
Deploy Telegram Bot / build-and-push (push) Successful in 13m30s
Deploy Telegram Bot / scan-images (push) Successful in 3m35s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-26 13:10:58 +03:00
Toutsu c955e1572f fix(db): make legacy telegram_* columns nullable for Discord multi-platform
PR Checks / test-and-build (pull_request) Successful in 18m38s
V023 migration drops NOT NULL constraints on:
- game_groups.telegram_chat_id
- game_groups.gm_telegram_id
- players.telegram_id

This allows Discord (and future platforms) to create players and
game_groups without legacy Telegram identifiers.

Bump version → 3.0.10

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:51:45 +03:00
Toutsu a9aa84af0f Merge pull request #102: fix(discord): add missing Dapper.AOT reference to DiscordBot project
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m19s
Deploy Telegram Bot / deploy (push) Successful in 35s
fix(discord): add missing Dapper.AOT reference to DiscordBot project

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:32:26 +03:00
Toutsu dcbd9bab41 fix(discord): add missing Dapper.AOT reference to DiscordBot project
PR Checks / test-and-build (pull_request) Successful in 11m4s
GmRelay.Shared references Dapper.AOT with PrivateAssets=all, which
prevents the runtime DLL from flowing to downstream projects. Telegram
bot works because it explicitly references Dapper.AOT directly, but
Discord bot did not — causing FileNotFoundException for Dapper.AOT
at runtime, breaking the scheduler and slash commands.

- Add Dapper.AOT 1.0.48 to GmRelay.DiscordBot.csproj
- Add regression test: DiscordWorkerProject_ShouldExist asserts
  Dapper.AOT is present in the DiscordBot csproj
- Bump version → 3.0.9

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:20:56 +03:00
Toutsu 92d5d9c2d3 Merge pull request #101: fix(discord): add console logging and deferred responses
Deploy Telegram Bot / build-and-push (push) Successful in 5m56s
Deploy Telegram Bot / scan-images (push) Successful in 3m3s
Deploy Telegram Bot / deploy (push) Successful in 31s
fix(discord): add console logging and deferred responses

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:46:55 +03:00
Toutsu 47d106e288 fix(tests): update DiscordNewSessionHandlerTests for deferred response pattern
PR Checks / test-and-build (pull_request) Successful in 11m55s
The Command_ShouldRenderEmbedOnSuccess test asserted the presence of
WithEmbeds in DiscordNewSessionCommand.cs. After switching to deferred
responses (InteractionCallback.DeferredMessage + ModifyResponseAsync),
embeds are now set via message.Embeds = embeds instead.

Bump version → 3.0.8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:33:03 +03:00
Toutsu a5624897e9 fix(discord): add console logging and deferred responses
PR Checks / test-and-build (pull_request) Failing after 12m3s
- Add builder.Logging.AddConsole() to DiscordBot Program.cs so logs
  are visible in docker logs.
- Add granular LogInformation/LogError calls to DiscordNewSessionCommand
  and DiscordRescheduleCommand to diagnose failures.
- Use InteractionCallback.DeferredMessage() + ModifyResponseAsync pattern
  for /newsession and /reschedule to avoid Discord 3-second interaction
  timeout.
- Bump version → 3.0.8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:18:09 +03:00
Toutsu 11e75d036a Merge pull request #100: fix(discord): use GuildInteractionUser.Permissions instead of REST guild lookup
Deploy Telegram Bot / build-and-push (push) Successful in 5m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m11s
Deploy Telegram Bot / deploy (push) Successful in 30s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:57:02 +03:00
Toutsu 2942da0c35 fix(discord): use GuildInteractionUser.Permissions instead of REST guild lookup
PR Checks / test-and-build (pull_request) Successful in 11m25s
Replace REST GetGuildAsync/GetGuildUserAsync calls with authoritative
member.Permissions from the slash-command interaction payload. Discord
already resolves channel/guild permissions in the interaction JSON, so
we no longer need to fetch the guild via REST (which returns 404 when
the bot is not a REST member of the guild, e.g. user-installed apps).

Keep a best-effort GetGuildAsync call only to obtain OwnerId for the
permission checker fallback, swallowing 404 silently.

Bump version → 3.0.7

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:44:59 +03:00
Toutsu 549c0c96ae Merge pull request #99: fix(discord): cast COUNT to int for slash command list query
Deploy Telegram Bot / build-and-push (push) Successful in 5m27s
Deploy Telegram Bot / scan-images (push) Successful in 2m49s
Deploy Telegram Bot / deploy (push) Successful in 31s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:23:05 +03:00
Toutsu dd9337dd20 fix(discord): cast COUNT to int for slash command list query
PR Checks / test-and-build (pull_request) Successful in 9m34s
PostgreSQL COUNT() returns bigint, but DiscordSessionListItemDto expects
int for PlayerCount and WaitlistCount. Dapper 2.1.72 in GmRelay.DiscordBot
(without Dapper.AOT) fails to materialize the record with bigint→int mismatch.
Added ::int casts to both COUNT expressions.

Bump version to 3.0.6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:10:13 +03:00
Toutsu 3cc3b373e5 Merge pull request #98: fix(discord): resolve slash commands from interaction payload instead of gateway cache
Deploy Telegram Bot / build-and-push (push) Successful in 4m59s
Deploy Telegram Bot / scan-images (push) Successful in 2m20s
Deploy Telegram Bot / deploy (push) Successful in 28s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:12:15 +03:00
Toutsu f6d5281af8 fix(discord): resolve slash commands from interaction payload instead of gateway cache
PR Checks / test-and-build (pull_request) Successful in 8m46s
Context.Guild in NetCord resolves the Guild object from the gateway client cache
(cache.Guilds.GetValueOrDefault(guildId)), not from the interaction JSON payload.
After a bot restart, the guild may not yet be cached when the first slash command
arrives, causing Context.Guild to be null even though the command is invoked
inside a guild channel. This produced "This command can only be used in a guild."

Changes:
- DiscordListSessionsCommand: use Context.Interaction.GuildId instead of Context.Guild.Id
- DiscordNewSessionCommand: use Context.Interaction.GuildId + REST GetGuildAsync/GetGuildUserAsync
- DiscordRescheduleCommand: same as above
- DiscordSessionInteractionModule: same fix for button interactions (CreateInput)
- Add null guard in GetResolvedPermissions for safety
- Bump version to 3.0.5

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:01:53 +03:00
Toutsu fa63886195 Merge pull request #97: fix(discord): use correct slash command context type in AddApplicationCommands
Deploy Telegram Bot / build-and-push (push) Successful in 5m1s
Deploy Telegram Bot / scan-images (push) Successful in 2m22s
Deploy Telegram Bot / deploy (push) Successful in 28s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:23:32 +03:00
Toutsu 9bd5fe75c9 test: sync version assertions to 3.0.4
PR Checks / test-and-build (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:08:01 +03:00
Toutsu d931da37ec fix(discord): use correct slash command context type in AddApplicationCommands
PR Checks / test-and-build (pull_request) Failing after 8m7s
The default AddApplicationCommands() registers ApplicationCommandService<ApplicationCommandContext>,
but our modules inherit ApplicationCommandModule<SlashCommandContext>. Because SlashCommandContext
does not inherit from ApplicationCommandContext in NetCord, AddModules(typeof(Program).Assembly)
failed to discover the modules, so /newsession, /listsessions, /reschedule were never published
to Discord. Only /ping worked because it uses the minimal API route.

Fix: specify AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>() so the
service matches the module context type, allowing module discovery to succeed.

Bump version to 3.0.4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:05:51 +03:00
Toutsu 9375fa45b2 Merge pull request #96: fix(discord): declare slash commands on module methods
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / scan-images (push) Successful in 2m9s
Deploy Telegram Bot / deploy (push) Successful in 27s
2026-05-25 16:37:15 +03:00
Toutsu 0b45aee96d fix(discord): declare slash commands on module methods
PR Checks / test-and-build (pull_request) Successful in 8m26s
2026-05-25 16:27:29 +03:00
Toutsu 80e346d6b5 Merge pull request #95: fix(discord): register slash command modules
Deploy Telegram Bot / build-and-push (push) Successful in 4m53s
Deploy Telegram Bot / scan-images (push) Successful in 2m12s
Deploy Telegram Bot / deploy (push) Successful in 27s
2026-05-25 16:04:42 +03:00
Toutsu eff0128d29 fix(discord): register slash command modules
PR Checks / test-and-build (pull_request) Successful in 8m27s
Register NetCord application command modules after the host is built so module-based commands are published alongside the minimal /ping command.

Update README Discord env guidance to avoid the unused DISCORD_BOT_CLIENT_ID variable.

Bump version to 3.0.2.
2026-05-25 15:49:36 +03:00
Toutsu 8214e052af bump: version 3.0.1
Deploy Telegram Bot / build-and-push (push) Successful in 4m55s
Deploy Telegram Bot / scan-images (push) Successful in 2m2s
Deploy Telegram Bot / deploy (push) Successful in 28s
Synchronize version across all files:
- Directory.Build.props → 3.0.1
- compose.yaml → gmrelay-bot/web/discord-bot:3.0.1
- deploy.yml → VERSION: 3.0.1
- NavMenu.razor → v3.0.1
- DiscordProjectStructureTests → 3.0.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:34:25 +03:00
Toutsu 2a233b2b1e fix: ensure Telegram is always primary in identity links
Deploy Telegram Bot / build-and-push (push) Successful in 5m6s
Deploy Telegram Bot / scan-images (push) Successful in 1m59s
Deploy Telegram Bot / deploy (push) Successful in 29s
When a Discord user linked Telegram via the Telegram Login Widget,
LinkIdentityAsync incorrectly made Discord primary and Telegram
secondary. This broke access to all Telegram groups/sessions because
ResolveEffectivePlayerIdAsync returned the (empty) Discord primary.

- In /auth/telegram callback, swap LinkIdentityAsync args so Telegram
  is always treated as the current (primary) account.
- Add V022 migration to reverse any existing incorrectly-oriented
  player_links where Discord is primary and Telegram is secondary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:19:08 +03:00
Toutsu 5e3028e470 fix: SameSite=Lax for auth cookie + bidirectional identity linking
Deploy Telegram Bot / build-and-push (push) Successful in 4m45s
Deploy Telegram Bot / scan-images (push) Successful in 2m7s
Deploy Telegram Bot / deploy (push) Successful in 28s
- Change cookie auth SameSite from Strict to Lax so Discord OAuth callback
can see existing Telegram auth session and perform linking instead of
creating a new standalone Discord session (root cause of broken linking).
- Add linking logic to /auth/telegram endpoint for Discord→Telegram linking.
- Add Telegram Login Widget in Profile.razor for Discord users.
- Add CookieAuthOptionsTests to verify Lax SameSite configuration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 14:58:25 +03:00
Toutsu 63193310f2 hotfix: fix Blazor circuit crash on Discord link + add missing avatar_url column
Deploy Telegram Bot / build-and-push (push) Successful in 4m53s
Deploy Telegram Bot / scan-images (push) Successful in 1m47s
Deploy Telegram Bot / deploy (push) Successful in 28s
- Replace @onclick button with plain <a href="/auth/discord"> to avoid
circuit disconnect from forceLoad navigation during event handlers.
- Add query param handling (?linked, ?link_error) in Profile.razor for
Discord callback feedback.
- Add V021 migration: ALTER TABLE players ADD COLUMN avatar_url.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 14:39:24 +03:00
Toutsu af37f3a8ec fix: Profile.razor use ISessionStore directly + forceLoad for Discord link
Deploy Telegram Bot / build-and-push (push) Successful in 4m38s
Deploy Telegram Bot / scan-images (push) Successful in 1m41s
Deploy Telegram Bot / deploy (push) Successful in 26s
- Replace HttpClient API calls with direct ISessionStore DI to avoid
  302 redirect from missing auth cookie in Blazor Server interactive mode
- Use NavigationManager.NavigateTo with forceLoad=true for Discord OAuth
  to bypass Blazor circuit navigation and trigger full HTTP request

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 14:20:26 +03:00
Toutsu 66228cf106 Merge pull request #93: feat: unify Telegram and Discord accounts via identity linking
Deploy Telegram Bot / build-and-push (push) Successful in 5m6s
Deploy Telegram Bot / scan-images (push) Successful in 1m48s
Deploy Telegram Bot / deploy (push) Successful in 26s
2026-05-25 14:07:33 +03:00
Toutsu 9c59240f48 fix: connection leak in UpsertDiscordUserAsync + false conflict in LinkIdentityAsync
PR Checks / test-and-build (pull_request) Successful in 7m25s
- UpsertDiscordUserAsync: restore await using on opened connection
- LinkIdentityAsync: compute effectiveCurrentPrimary before existingLink check
  to prevent false conflict when current user is a secondary identity

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:59:41 +03:00
Toutsu baa25f2e1e feat: unify Telegram and Discord accounts via identity linking
PR Checks / test-and-build (pull_request) Successful in 7m6s
- Add V020 migration: player_links + identity_audit_log tables
- Add ISessionStore methods: ResolveEffectivePlayerId, LinkIdentity, UnlinkIdentity, GetLinkedIdentities
- Update SessionService to resolve effective player id for all permission checks
- Add /auth/discord/callback linking flow when already authenticated
- Add /api/me/identities GET/DELETE endpoints
- Add Profile.razor page for managing linked accounts
- Update NavMenu with profile link and v3.0.0 badge
- Bump version to 3.0.0 across all files

Bump version → 3.0.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:51:10 +03:00
Toutsu 7a2ed808c4 fix: replace cookie-based Discord OAuth CSRF with server-side state store
Deploy Telegram Bot / build-and-push (push) Successful in 4m19s
Deploy Telegram Bot / scan-images (push) Successful in 1m24s
Deploy Telegram Bot / deploy (push) Successful in 11s
- Replace __DiscordOAuthState cookie (blocked by third-party cookie policies)
  with in-memory DiscordOAuthStateStore singleton
- State is created server-side and validated on callback, eliminating
  cross-site cookie transmission issues entirely
- Removed CryptographicOperations dependency from Program.cs

Bump version → 2.8.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:18:23 +03:00
Toutsu dd0828a63d Merge pull request #92: fix Discord OAuth CSRF cookie SameSite
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / scan-images (push) Successful in 1m28s
Deploy Telegram Bot / deploy (push) Successful in 34s
2026-05-25 13:08:31 +03:00
Toutsu 72a392e652 fix: Discord OAuth CSRF cookie SameSite=None for cross-site callback
PR Checks / test-and-build (pull_request) Successful in 6m34s
- Changed __DiscordOAuthState cookie from SameSite=Strict to SameSite=None
  because Discord redirects from discord.com (cross-site) and Strict
  prevents the cookie from being sent on the callback request.
- Added logging for CSRF validation failure to aid future diagnostics.

Bump version → 2.8.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:08:14 +03:00
Toutsu e1fac04775 Merge pull request #92: fix: add Discord OAuth token exchange logging for production diagnostics
Deploy Telegram Bot / build-and-push (push) Successful in 4m17s
Deploy Telegram Bot / scan-images (push) Successful in 1m24s
Deploy Telegram Bot / deploy (push) Successful in 23s
2026-05-25 12:47:19 +03:00
Toutsu 7e02e86cd6 fix: add Discord OAuth token exchange logging for production diagnostics
PR Checks / test-and-build (pull_request) Failing after 6m20s
- Log status code and response body when Discord /oauth2/token fails
- Helps identify why ExchangeCodeAsync returns null in production

Bump version → 2.8.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:46:56 +03:00
Toutsu eb9a159dbb Merge pull request #91: feat: Discord OAuth и платформонезависимый Web Dashboard (issue #34)
Deploy Telegram Bot / build-and-push (push) Successful in 4m36s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 26s
- Discord OAuth 2.0 login flow (identify + guilds scopes)
- Platform-agnostic auth via (platform, external_user_id)
- Cookie Authentication with ClaimsPrincipal
- Razor Pages updated to *ForCurrentUserAsync APIs
- CSRF protection for Discord OAuth
- V019 migration for audit log platform identity
- Version bumped to 2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:14:27 +03:00
Toutsu 66dc53f12f fix(web): address PR review critical issues for Discord OAuth
PR Checks / test-and-build (pull_request) Successful in 6m6s
- Add V019 migration: rename session_audit_log.actor_telegram_id → actor_external_user_id
- Add CSRF protection to Discord OAuth flow (state cookie with HttpOnly/Secure/Strict)
- Add Discord OAuth env vars to compose.yaml, deploy.yml, and .env.example
- Fix SQL COALESCE for nullable telegram_id in GetGroupManagersAsync and GetSessionParticipantsAsync

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:07:40 +03:00
Toutsu 50f5307aac feat(web): finalize Discord OAuth and platform-agnostic auth
PR Checks / test-and-build (pull_request) Successful in 5m47s
- Bump version to 2.8.0 across all versioned files
- Fix AuthorizedSessionServiceTests for platform-agnostic identity
- Update Razor Pages to use *ForCurrentUserAsync APIs
- Add backward-compatible constructors to WebGameGroup/WebGroupManager
- Make DiscordOAuthOptions properties non-required for config binding

Bump version → 2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:47:54 +03:00
Toutsu 5fa7e26f72 test(web): add Discord auth and platform identity tests
- DiscordAuthServiceTests: authorize URL, token exchange, profile fetch
- PlatformIdentityTests: Telegram fallback, Discord identity, avatar URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:13:08 +03:00
Toutsu 976e204102 feat(web): add Discord login button, platform indicator, and CSS styles
- Discord login button on /login with brand colors
- NavMenu shows user avatar (Discord) and platform label
- CSS: login-divider, login-btn-discord, nav-user-info, nav-user-platform
- NavMenu version bumped to v2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:12:16 +03:00
Toutsu 9d4256353d feat(web): refactor SessionStore and AuthorizedSessionService to platform-agnostic identity
- ISessionStore: all methods use (platform, external_user_id)
- SessionService: updated SQL queries and added UpsertDiscordUserAsync
- AuthorizedSessionService: resolves identity from HttpContext, no longer accepts telegram_id params
- SessionAccessDeniedException now accepts string externalUserId
- Added ExternalUserId/ExternalUsername to WebGroupManager and WebParticipant

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:08:10 +03:00
Toutsu 543fc42a6d feat(web): add platform-agnostic identity extraction from ClaimsPrincipal
- TryGetPlatformIdentity returns (platform, external_user_id)
- TryGetDiscordId for Discord-specific flows
- Backward-compatible fallback for legacy Telegram auth without Platform claim
- GetAvatarUrl helper for Discord avatars

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:02:29 +03:00
Toutsu bfed400b4d feat(web): add Discord OAuth service and authorization endpoints
- DiscordOAuthOptions for client_id, secret, redirect_uri
- DiscordAuthService exchanges code for token and fetches user profile
- /auth/discord and /auth/discord/callback endpoints
- CreateDiscordPrincipal for cookie auth claims
- Telegram principal now includes Platform claim for forward compatibility

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:02:13 +03:00
Toutsu d0ddf3fb58 fix(bot): add missing using for DirectSessionNotificationSender
Deploy Telegram Bot / build-and-push (push) Successful in 5m50s
Deploy Telegram Bot / scan-images (push) Successful in 2m29s
Deploy Telegram Bot / deploy (push) Successful in 36s
2026-05-24 07:50:05 +03:00
Toutsu 654db04d44 fix(discord): use dotnet/aspnet:10.0-noble runtime image
Deploy Telegram Bot / build-and-push (push) Failing after 42s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:05 +03:00
Toutsu 3a94becf05 fix(bot): register DirectSessionNotificationSender in DI
Deploy Telegram Bot / build-and-push (push) Failing after 44s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:04 +03:00
Toutsu 31d8f59f1e ci(deploy): reduce healthcheck timeout 180s -> 40s
Deploy Telegram Bot / build-and-push (push) Failing after 48s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:04 +03:00
Toutsu 31e08ba073 ci(deploy): reduce healthcheck timeout 180s → 40s
Deploy Telegram Bot / build-and-push (push) Successful in 34s
Deploy Telegram Bot / scan-images (push) Successful in 1m51s
Deploy Telegram Bot / deploy (push) Failing after 1m22s
2026-05-24 07:38:34 +03:00
Toutsu 7c8e14c44f feat(ci): wait for bot/discord/web healthcheck after deploy
Deploy Telegram Bot / build-and-push (push) Successful in 41s
Deploy Telegram Bot / scan-images (push) Successful in 1m45s
Deploy Telegram Bot / deploy (push) Failing after 3m46s
Add loop that polls docker compose ps for healthy state.
Timeout: 180s, interval: 5s.
Workflow now fails if any service doesn't become healthy.
2026-05-24 07:33:57 +03:00
Toutsu b57332bd5c chore: remove AI working directories (docs/superpowers, docs/plans) from repo
Deploy Telegram Bot / build-and-push (push) Successful in 32s
Deploy Telegram Bot / scan-images (push) Successful in 1m45s
Deploy Telegram Bot / deploy (push) Successful in 15s
Add docs/superpowers/, docs/plans/, *.diff to .gitignore.
These directories contain implementation plans and design specs
used during agentic development; they are not needed in source control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:58:57 +03:00
Toutsu 73714c9525 docs(adr): add ADR-003 Discord Integration Architecture
Deploy Telegram Bot / build-and-push (push) Successful in 35s
Deploy Telegram Bot / scan-images (push) Successful in 1m57s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-21 18:40:30 +03:00
Toutsu 8319edda38 docs(adr-002): add links to issues #30-33 in related section
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:39:29 +03:00
Toutsu 5e1f0a00ad docs(adr-001): add Discord Gateway + NetCord decision, update Aspire services 2026-05-21 18:38:36 +03:00
Toutsu 987013974c docs(c4): update container view for Discord worker and healthcheck 2026-05-21 18:37:22 +03:00
Toutsu 7249ca079d docs(readme): update for v2.7.2 — Discord features, env vars, structure
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:34:46 +03:00
Toutsu 7fac5926fc docs: add design spec for MVP2 documentation sync
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:19:08 +03:00
Toutsu 9f7b772680 Merge pull request #90: test: добавить регрессионные тесты platform rendering и Discord MVP interactions (issue #33)
Deploy Telegram Bot / build-and-push (push) Successful in 4m43s
Deploy Telegram Bot / scan-images (push) Successful in 1m55s
Deploy Telegram Bot / deploy (push) Successful in 16s
2026-05-21 17:51:51 +03:00
Toutsu 1853a7a9c7 chore(release): bump version to 2.7.2
PR Checks / test-and-build (pull_request) Successful in 8m3s
Issue: #33
2026-05-21 15:36:17 +03:00
Toutsu befb2da6a0 test: add DiscordLandingPromisesSmokeTests
Mirror TelegramLandingPromisesSmokeTests for Discord MVP:
- Join/leave/waitlist promotion via capacity rules
- Reschedule voting flow
- Direct message notifications on reschedule
- Dashboard batch update

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

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

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

Issue: #33
2026-05-21 15:11:01 +03:00
143 changed files with 9314 additions and 7803 deletions
+7
View File
@@ -14,6 +14,13 @@ TELEGRAM_MINI_APP_URL=
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
# Discord OAuth (для Web Dashboard)
# Client ID и Secret из OAuth2 раздела Discord Developer Portal
# Redirect URI должен указывать на /auth/discord/callback вашего домена
DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID_HERE
DISCORD_CLIENT_SECRET=YOUR_DISCORD_CLIENT_SECRET_HERE
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase
+36 -3
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 2.7.1
VERSION: 3.4.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -113,14 +113,47 @@ jobs:
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
echo "DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}" >> .env
echo "DISCORD_CLIENT_SECRET=${{ secrets.DISCORD_CLIENT_SECRET }}" >> .env
echo "DISCORD_REDIRECT_URI=${{ secrets.DISCORD_REDIRECT_URI }}" >> .env
- name: Deploy Containers
run: |
# Авторизуемся локальным докером в нашей Gitea
docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }}
# Pull гарантирует, что мы получили нужную версию.
docker compose pull bot discord web
# Запускаем! Флаг -d оставит их работать в фоне.
docker compose up -d
# Ждём, пока сервисы перейдут в healthy или упадут
SERVICES="bot discord web"
MAX_WAIT=40
INTERVAL=5
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
NOT_HEALTHY=0
for svc in $SERVICES; do
HEALTH=$(docker compose ps $svc --format="{{.Health}}" 2>/dev/null | head -n1)
if [ "$HEALTH" != "healthy" ]; then
STATE=$(docker compose ps $svc --format="{{.State}}" 2>/dev/null | head -n1)
echo "❌ $svc not healthy yet (state: ${STATE:-unknown})"
NOT_HEALTHY=$((NOT_HEALTHY + 1))
fi
done
if [ $NOT_HEALTHY -eq 0 ]; then
echo "✅ All services are healthy!"
exit 0
fi
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
done
echo "⏰ Timed out waiting for services to become healthy"
docker compose ps
exit 1
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>2.7.1</Version>
<Version>3.4.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+27 -12
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.7.0`.
**Текущая версия:** `v3.4.0`.
---
@@ -25,11 +25,11 @@
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
### Discord Bot
- **Slash-команды расписания**: GM создаёт сессию через `/newsession` и публикует актуальное расписание через `/listsessions`.
- **Кнопки записи и выхода**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **Подтверждения и RSVP**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает исходы RSVP через платформенный messenger.
- **Напоминания и ссылки**: one-hour reminders и join-link notifications отправляются в Discord DM при включенных личных уведомлениях; сбои DM логируются без публичного fallback.
- **Переносы**: deadline-сервис обновляет Discord vote message и schedule message через `IPlatformMessenger`.
- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале.
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
- **Reschedule voting (голосование за перенос)**: deadline-сервис обновляет Discord vote message и schedule message через `IPlatformMessenger`.
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
### 🌐 Web Dashboard (Blazor Server)
@@ -37,6 +37,7 @@
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
@@ -55,7 +56,7 @@
|---|---|
| Язык | C# 14 (.NET 10) |
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker) |
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker внутри `GmRelay.Bot`) |
| Веб | Blazor Server |
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
| БД | PostgreSQL |
@@ -85,6 +86,11 @@ TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Токен Discord application bot
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
# Discord OAuth (для Web Dashboard)
DISCORD_CLIENT_ID=ваш_discord_client_id_здесь
DISCORD_CLIENT_SECRET=ваш_discord_client_secret_здесь
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
# Имя бота без @ (для Telegram Login Widget)
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
@@ -109,15 +115,15 @@ docker compose up -d
- создание Docker-сети и volume PostgreSQL;
- подъём PostgreSQL (`db:5432`);
- запуск бота с плавной миграцией (DbUp);
- запуск отдельного Discord Gateway worker на NetCord;
- запуск Discord Gateway worker на NetCord (healthcheck на `:8082`);
- запуск веб-приложения с подключением к БД и Telegram API.
### 3. Первоначальная настройка
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`.
5. В Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 💾 Backup и восстановление
@@ -164,8 +170,7 @@ BACKUP_VOLUME_NAME=game_pgbackups
```
├── src/
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
│ ├── GmRelay.Bot/ # Telegram-бот (Native AOT)
│ ├── GmRelay.DiscordBot/ # Discord Gateway worker на NetCord
│ ├── GmRelay.Bot/ # Telegram- и Discord-бот (Native AOT + NetCord Gateway worker)
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
│ ├── GmRelay.Shared/ # Общие доменные модели
│ └── GmRelay.Web/ # Blazor Server dashboard
@@ -177,6 +182,16 @@ BACKUP_VOLUME_NAME=game_pgbackups
---
## 👨‍💻 Для разработчиков
- **Архитектура**: проект следует Vertical Slice с явным DI. Подробности — в [ADR-001](docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md) и [ADR-002](docs/adr/002-platform-neutral-batch-rendering.md).
- **Добавление обработчика**: из-за Native AOT все DI-регистрации выполняются вручную в `src/GmRelay.Bot/Program.cs` (assembly scanning не используется).
- **Миграции**: SQL-скрипты добавляются как embedded resources в `src/GmRelay.Bot/Migrations/` и применяются автоматически при старте бота через DbUp.
- **Тесты**: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
- **Сборка**: `dotnet build`
---
## 📜 Лицензия
MIT License. См. [LICENSE](./LICENSE).
+6 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.1
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.4.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.1
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.4.0
restart: always
depends_on:
db:
@@ -84,7 +84,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.1
image: git.codeanddice.ru/toutsu/gmrelay-web:3.4.0
restart: always
depends_on:
db:
@@ -94,6 +94,9 @@ services:
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes:
@@ -56,8 +56,18 @@ Aspire обеспечивает:
- Service discovery и передачу connection strings.
- OpenTelemetry (traces, metrics, logs) из коробки.
- Aspire Dashboard для мониторинга.
- **Три сервиса:** Bot (Telegram long polling + Discord Gateway), Web, PostgreSQL.
### 5. Telegram.Bot 22.x + Long Polling
### 5. Discord Gateway + NetCord
Discord-интеграция реализована через NetCord Gateway (не DSharpPlus) из-за:
- Нативной совместимости с .NET 10 и минимального размера зависимостей.
- Gateway events маршрутизируются в те же vertical slice handlers, что и Telegram updates.
- Slash-команды регистрируются через NetCord `ApplicationCommandService`.
Ephemeral-ответы (кнопки Join/Leave/RSVP) используют `InteractionMessageProperties` с `Flags = MessageFlags.Ephemeral`.
### 6. Telegram.Bot 22.x + Long Polling
- Long Polling — единственный вариант для Pi за NAT.
- Telegram.Bot поддерживает `System.Text.Json` source generators для AOT.
@@ -62,5 +62,8 @@ SessionBatchViewModel (platform-neutral)
- Issue #22 — этот рефакторинг.
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
- Issue #31scheduler notifications and reschedule deadline updates now use `IPlatformMessenger` for Telegram and Discord.
- Issue #30Discord reschedule voting использует `IPlatformMessenger`.
- Issue #31 — scheduler notifications и reschedule deadline updates через `IPlatformMessenger`.
- Issue #32 — compose wiring для Discord bot (healthcheck :8082).
- Issue #33 — регрессионные тесты platform rendering (Telegram + Discord).
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
@@ -0,0 +1,57 @@
# ADR 003: Discord Integration Architecture
## Status
**Accepted** — implemented in v2.6.0 (PR #87, issue #30).
## Context
После Telegram-бота требовалась поддержка Discord для кросс-платформенных групп. Нужно было выбрать:
1. Библиотеку для Discord API (NetCord vs DSharpPlus vs Discord.NET).
2. Модель runtime (отдельный процесс vs тот же Worker).
3. Способ обработки интеракций (Gateway events vs HTTP interactions).
## Decision
### 1. NetCord (не DSharpPlus, не Discord.NET)
- **NetCord** — лёгкий, AOT-compatible, minimal dependencies.
- **DSharpPlus** — слишком тяжёлый, много зависимостей, reflection-heavy.
- **Discord.NET** — несовместим с Native AOT (heavy reflection, dynamic IL).
### 2. Gateway Events внутри GmRelay.Bot
- Discord Gateway worker живёт **внутри** `GmRelay.Bot` (тот же Worker Service), а не как отдельный проект.
- Это упрощает DI, shared DB connection, shared `IPlatformMessenger`.
- Для масштабирования можно вынести в отдельный контейнер позже.
### 3. Slash-команды через NetCord ApplicationCommandService
- Регистрация глобальных slash-команд (`/newsession`, `/listsessions`) через `ApplicationCommandService`.
- Команды мапятся на vertical slice handlers через `DiscordSessionInteractionModule`.
### 4. Ephemeral Replies
- Все кнопки (Join/Leave/RSVP) отвечают ephemeral (`MessageFlags.Ephemeral`).
- Schedule message редактируется через `DiscordPlatformMessenger` (реализация `IPlatformMessenger`).
## Consequences
### Positive
- Один бинарник для Telegram + Discord.
- Shared DI, shared DB pool, shared domain logic.
- Native AOT совместимость.
### Negative
- Gateway connection требует persistent WebSocket — при разрыве происходит reconnect.
- Discord rate limits агрессивнее Telegram — нужен backoff.
## Related
- Issue #30 — reschedule voting (кнопки + дедлайн).
- Issue #31 — scheduler notifications через `IPlatformMessenger`.
- Issue #32 — compose wiring + healthcheck.
- ADR 001 — Vertical Slice, Native AOT, Aspire.
- ADR 002 — Platform-Neutral Rendering.
+11 -4
View File
@@ -8,8 +8,9 @@ C4Context
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
Person(visitor, "Public visitor", "Views published club schedules without private player data")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club pages, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
@@ -19,6 +20,7 @@ C4Context
Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
Rel(visitor, gmrelay, "Views public club and session pages")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
@@ -34,13 +36,14 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
Person(visitor, "Public visitor")
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
Container(discordBot, "GmRelay.DiscordBot", "Worker Service, .NET 10", "NetCord Gateway, slash commands, scheduler notifications, and button interactions")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club pages, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
}
System_Ext(telegram, "Telegram Bot API")
@@ -50,6 +53,7 @@ C4Container
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(visitor, web, "Read-only public schedule pages")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls")
@@ -77,6 +81,8 @@ C4Component
Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
}
Component(healthCheck, "DiscordHealthCheckHostedService", ":8082", "Healthcheck для Docker Compose")
Container_Boundary(discordBot, "GmRelay.DiscordBot") {
Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands")
Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages")
@@ -117,4 +123,5 @@ C4Component
Rel(scheduler, telegramMessenger, "Send Telegram scheduler notifications")
Rel(discordMessenger, discord, "REST send/edit/DM + ephemeral text")
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
Rel(healthCheck, discord, "HTTP /health")
```
@@ -1,438 +0,0 @@
# 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.
---
@@ -1,69 +0,0 @@
# 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`.
@@ -1,560 +0,0 @@
# 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.
@@ -1,731 +0,0 @@
# Discord NetCord Gateway Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a separate `src/GmRelay.DiscordBot` worker that uses NetCord Gateway for Discord slash commands and component interactions while keeping Telegram dependencies isolated in `src/GmRelay.Bot`.
**Architecture:** Create a new .NET worker project that references `GmRelay.ServiceDefaults` and `GmRelay.Shared`, validates `Discord:Token` during startup, registers NetCord gateway/application command/component services, and logs gateway lifecycle events through NetCord gateway handlers. Keep database connectivity aligned with the existing worker by registering the same `ConnectionStrings:gmrelaydb` `NpgsqlDataSource` pattern, but do not move Telegram code or dependencies.
**Tech Stack:** .NET 10 worker, Aspire service defaults, NetCord.Hosting `1.0.0-alpha.489`, Npgsql `10.0.2`, xUnit, Docker Compose, Gitea Actions.
---
## Issue
- Gitea issue: `#26`, `feat: добавить src/GmRelay.DiscordBot на NetCord Gateway`
- Labels: `type:feature`, `area:discord`, `area:infra`, `platform:discord`, `priority:p1`, `pending-approval`
- Version bump: minor, `2.1.1` -> `2.2.0`
- Branch: `feature/issue-26-discord-netcord-gateway`
## Sources Checked
- NetCord application commands guide: `https://netcord.dev/guides/services/application-commands/introduction.html`
- NetCord intents guide: `https://netcord.dev/guides/events/intents.html`
- NetCord gateway handler docs: `https://netcord.dev/docs/NetCord.Hosting.Gateway.html`
- NuGet flat container for `NetCord.Hosting`: latest observed version `1.0.0-alpha.489`
## File Structure
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` - Discord worker project and package references.
- Create: `src/GmRelay.DiscordBot/Program.cs` - host composition, token validation, database registration, NetCord service registration.
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - strongly typed Discord token/options validation.
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs` - Discord-local startup redaction without referencing the Telegram worker project.
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - NetCord gateway lifecycle handler for ready/connect/resume/disconnect/close/rate-limit events where available.
- Create: `src/GmRelay.DiscordBot/Dockerfile` - publish and runtime image for the Discord worker.
- Modify: `GM-Relay.slnx` - include the new project.
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` - reference the Discord worker for Aspire orchestration.
- Modify: `src/GmRelay.AppHost/Program.cs` - add `discord` project with PostgreSQL reference.
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - reference the Discord worker project.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` - source-level tests for solution inclusion, Docker/Compose/CI wiring, and Telegram isolation.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` - unit tests for token validation.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - source-level startup tests for NetCord registration, service defaults, and PostgreSQL connection requirements.
- Modify: `compose.yaml` - add `discord` service and versioned image tag.
- Modify: `.gitea/workflows/deploy.yml` - build/push/scan/pull Discord image and include `DISCORD_BOT_TOKEN` in `.env`.
- Modify: `.gitea/workflows/pr-checks.yml` - build the Discord project in PR checks.
- Modify: `Directory.Build.props` - version `2.2.0`.
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - visible version `v2.2.0`.
- Generated by restore: `src/GmRelay.DiscordBot/packages.lock.json`.
- Generated by restore: updates to `tests/GmRelay.Bot.Tests/packages.lock.json` and `src/GmRelay.AppHost/packages.lock.json`.
## TDD Plan
### Task 1: Project Presence And Telegram Isolation
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Modify: `GM-Relay.slnx`
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`
- Create: `src/GmRelay.DiscordBot/Program.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using System;
using System.IO;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordProjectStructureTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
[Fact]
public void Solution_ShouldIncludeDiscordWorkerProject()
{
var repoRoot = GetRepoRoot();
var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx"));
Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution);
}
[Fact]
public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency()
{
var repoRoot = GetRepoRoot();
var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj");
Assert.True(File.Exists(projectPath), "Discord worker project should exist.");
var project = File.ReadAllText(projectPath);
Assert.Contains("Microsoft.NET.Sdk.Worker", project);
Assert.Contains("NetCord.Hosting", project);
Assert.Contains("GmRelay.ServiceDefaults.csproj", project);
Assert.Contains("GmRelay.Shared.csproj", project);
Assert.DoesNotContain("Telegram.Bot", project);
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
}
[Fact]
public void TelegramWorkerProject_ShouldNotReferenceNetCord()
{
var repoRoot = GetRepoRoot();
var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj"));
Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
Expected: FAIL because `GM-Relay.slnx` does not include `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` and the project file does not exist.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
</ItemGroup>
</Project>
```
Add this project to `GM-Relay.slnx` inside `/src/`:
```xml
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
```
Create temporary minimal `src/GmRelay.DiscordBot/Program.cs`:
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
await builder.Build().RunAsync();
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
Expected: PASS.
### Task 2: Token Validation
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs`
- [ ] **Step 1: Write the failing test**
Add the project reference to `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`:
```xml
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
```
Create `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`:
```csharp
using GmRelay.DiscordBot;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordOptionsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_ShouldRejectMissingToken(string? token)
{
var options = new DiscordOptions { Token = token };
var exception = Assert.Throws<InvalidOperationException>(options.Validate);
Assert.Contains("Discord:Token is required", exception.Message);
Assert.Contains("Discord__Token", exception.Message);
}
[Fact]
public void Validate_ShouldAcceptConfiguredToken()
{
var options = new DiscordOptions { Token = "configured-token" };
options.Validate();
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
Expected: FAIL at compile time because `GmRelay.DiscordBot.DiscordOptions` is not defined.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/DiscordOptions.cs`:
```csharp
namespace GmRelay.DiscordBot;
public sealed class DiscordOptions
{
public string? Token { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Token))
{
throw new InvalidOperationException(
"Discord:Token is required. Set via environment variable Discord__Token or user secrets.");
}
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
Expected: PASS.
### Task 3: Startup Wiring For Service Defaults, PostgreSQL, NetCord, And Slash Commands
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
- Modify: `src/GmRelay.DiscordBot/Program.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`:
```csharp
using System;
using System.IO;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordStartupTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
[Fact]
public void Program_ShouldValidateDiscordTokenBeforeRunning()
{
var program = ReadProgram();
Assert.Contains("GetRequiredSection(\"Discord\")", program);
Assert.Contains("DiscordOptions", program);
Assert.Contains(".Validate()", program);
}
[Fact]
public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource()
{
var program = ReadProgram();
Assert.Contains("builder.AddServiceDefaults()", program);
Assert.Contains("ConnectionStrings:gmrelaydb is required", program);
Assert.Contains("NpgsqlDataSource", program);
Assert.Contains("SecretRedactor.RedactConnectionString", program);
}
[Fact]
public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents()
{
var program = ReadProgram();
Assert.Contains(".AddDiscordGateway", program);
Assert.Contains(".AddApplicationCommands", program);
Assert.Contains(".AddComponentInteractions", program);
Assert.Contains(".AddGatewayHandlers", program);
Assert.Contains("AddSlashCommand", program);
}
private static string ReadProgram()
{
var repoRoot = GetRepoRoot();
return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs"));
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
Expected: FAIL because `Program.cs` does not validate `Discord:Token`, register `NpgsqlDataSource`, or register NetCord services yet.
- [ ] **Step 3: Write minimal implementation**
Replace `src/GmRelay.DiscordBot/Program.cs` with host composition that:
```csharp
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Infrastructure.Logging;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
using NetCord.Hosting.Services.ApplicationCommands;
using NetCord.Hosting.Services.ComponentInteractions;
using Npgsql;
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
var discordOptions = builder.Configuration
.GetRequiredSection("Discord")
.Get<DiscordOptions>() ?? new DiscordOptions();
discordOptions.Validate();
builder.Services.AddSingleton(discordOptions);
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var connectionString = config.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException(
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup");
logger.LogInformation(
"Configured PostgreSQL data source with connection string {ConnectionString}",
SecretRedactor.RedactConnectionString(connectionString));
return NpgsqlDataSource.Create(connectionString);
});
builder.Services
.AddDiscordGateway(options =>
{
options.Token = discordOptions.Token;
options.Intents = GatewayIntents.Guilds;
})
.AddApplicationCommands()
.AddComponentInteractions()
.AddGatewayHandlers(typeof(Program).Assembly);
var host = builder.Build();
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
await host.RunAsync();
```
Use the Discord-local `SecretRedactor` namespace instead of `GmRelay.Bot.Infrastructure.Logging` so the new project does not reference the Telegram worker.
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs`:
```csharp
using System.Text.RegularExpressions;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
internal static partial class SecretRedactor
{
public static string RedactConnectionString(string connectionString)
{
return PasswordPattern().Replace(connectionString, "$1***");
}
[GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")]
private static partial Regex PasswordPattern();
}
```
If `GatewayClientOptions.Token` does not accept `string`, adjust to NetCord's required token type after compile feedback while preserving the tests' intent.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
Expected: PASS.
### Task 4: Gateway Lifecycle Logging
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs`
- [ ] **Step 1: Write the failing test**
Add to `DiscordStartupTests.cs`:
```csharp
[Fact]
public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues()
{
var repoRoot = GetRepoRoot();
var loggerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Infrastructure", "Logging", "DiscordGatewayLifecycleLogger.cs");
Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist.");
var logger = File.ReadAllText(loggerPath);
Assert.Contains("IReadyGatewayHandler", logger);
Assert.Contains("IDisconnectGatewayHandler", logger);
Assert.Contains("IResumeGatewayHandler", logger);
Assert.Contains("LogInformation", logger);
Assert.DoesNotContain("Token", logger);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
Expected: FAIL because `DiscordGatewayLifecycleLogger.cs` does not exist.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` using the concrete NetCord handler signatures from the installed `NetCord.Hosting` package. Minimum behavior:
```csharp
using Microsoft.Extensions.Logging;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
public sealed class DiscordGatewayLifecycleLogger(
ILogger<DiscordGatewayLifecycleLogger> logger)
: IReadyGatewayHandler,
IDisconnectGatewayHandler,
IResumeGatewayHandler
{
public ValueTask HandleAsync(ReadyEventArgs arg)
{
logger.LogInformation("Discord gateway ready as application {ApplicationId}", arg.Application.Id);
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync(DisconnectEventArgs arg)
{
logger.LogWarning("Discord gateway disconnected with close status {CloseStatus}", arg.CloseStatus);
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync()
{
logger.LogInformation("Discord gateway session resumed");
return ValueTask.CompletedTask;
}
}
```
If interface signatures differ in `1.0.0-alpha.489`, inspect the package XML/docs and adjust the handlers to compile while keeping ready/disconnect/resume logging and never logging token values.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
Expected: PASS.
### Task 5: Runtime Container, Compose, AppHost, And CI Wiring
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Create: `src/GmRelay.DiscordBot/Dockerfile`
- Modify: `compose.yaml`
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj`
- Modify: `src/GmRelay.AppHost/Program.cs`
- Modify: `.gitea/workflows/pr-checks.yml`
- Modify: `.gitea/workflows/deploy.yml`
- [ ] **Step 1: Write the failing test**
Add to `DiscordProjectStructureTests.cs`:
```csharp
[Fact]
public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram()
{
var repoRoot = GetRepoRoot();
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj"));
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.2.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks);
Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject);
Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
Expected: FAIL because runtime wiring is not present.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/Dockerfile` modeled after `src/GmRelay.Bot/Dockerfile`, with project copy/restore for `GmRelay.DiscordBot`, `GmRelay.ServiceDefaults`, and `GmRelay.Shared`, and entrypoint `./GmRelay.DiscordBot`.
Add `discord` service to `compose.yaml`:
```yaml
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0
restart: always
depends_on:
db:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
networks:
- gmrelay
```
Add Discord project reference to `src/GmRelay.AppHost/GmRelay.AppHost.csproj`:
```xml
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
```
Add Discord service to `src/GmRelay.AppHost/Program.cs`:
```csharp
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres);
```
Update `.gitea/workflows/pr-checks.yml` with:
```yaml
- name: Build Discord Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
```
Update `.gitea/workflows/deploy.yml` to build, push, scan, pull, and deploy `git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}` and write `DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}` to `.env`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
Expected: PASS.
### Task 6: Version Synchronization
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Write the failing test**
Add to `DiscordProjectStructureTests.cs`:
```csharp
[Fact]
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("v2.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
Expected: FAIL because current version is `2.1.1`.
- [ ] **Step 3: Write minimal implementation**
Update:
- `Directory.Build.props`: `<Version>2.2.0</Version>`
- `.gitea/workflows/deploy.yml`: `VERSION: 2.2.0`
- `compose.yaml`: `gmrelay-bot:2.2.0`, `gmrelay-web:2.2.0`, `gmrelay-discord-bot:2.2.0`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v2.2.0`
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
Expected: PASS.
### Task 7: Restore, Format, Build, And Full Test Verification
**Files:**
- Generated/updated: `src/GmRelay.DiscordBot/packages.lock.json`
- Generated/updated: `tests/GmRelay.Bot.Tests/packages.lock.json`
- Generated/updated: `src/GmRelay.AppHost/packages.lock.json`
- Any code formatting changes required by `dotnet format`
- [ ] **Step 1: Restore lock files**
Run: `dotnet restore GM-Relay.slnx`
Expected: restore succeeds and creates/updates lock files for the new project references and NetCord dependency.
- [ ] **Step 2: Run targeted tests**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~Discord`
Expected: all Discord tests pass.
- [ ] **Step 3: Run full tests**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
Expected: all tests pass.
- [ ] **Step 4: Run release build**
Run: `dotnet build GM-Relay.slnx -c Release`
Expected: solution build succeeds and includes `src/GmRelay.DiscordBot`.
- [ ] **Step 5: Run format check**
Run: `dotnet format --verify-no-changes --verbosity diagnostic`
Expected: no formatting changes required.
- [ ] **Step 6: Inspect diff for secrets**
Run: `git diff --check`
Expected: no whitespace errors and no Discord token value in tracked files.
Run: `git diff -- . ':!*.lock.json'`
Expected: diff contains configuration variable names such as `Discord__Token` and `DISCORD_BOT_TOKEN`, but not a real token value.
### Task 8: Commit, PR, CI, Deploy, Release, Issue Closure
**Files:**
- All intended implementation, test, lock, workflow, compose, and version files.
- [ ] **Step 1: Create commit**
Run:
```powershell
git status --short
git add GM-Relay.slnx Directory.Build.props compose.yaml .gitea/workflows/deploy.yml .gitea/workflows/pr-checks.yml src/GmRelay.AppHost src/GmRelay.DiscordBot src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests
git commit -m "feat: add Discord NetCord gateway worker"
```
Expected: only intended files are staged and committed. Do not stage untracked `CLAUDE.md`.
- [ ] **Step 2: Push branch and open PR**
Run: `git push -u origin feature/issue-26-discord-netcord-gateway`
Create Gitea PR to `main` with:
- Summary of Discord worker, token validation, runtime wiring, and version bump.
- Test plan showing targeted Discord tests, full tests, release build, format, and secret diff inspection.
- Link to issue `#26`.
- [ ] **Step 3: Store Discord token as a Gitea Actions secret**
Use Gitea Actions configuration to create or update repository secret `DISCORD_BOT_TOKEN` with the user-provided Discord bot token.
Expected: token is stored only as an Actions secret. The token value is not written to source files, plan files, logs, PR text, release notes, or commits.
- [ ] **Step 4: Monitor CI**
Use Gitea Actions run reads until PR checks finish. If CI fails, inspect logs, fix with TDD where the failure is code behavior, push again, and re-check.
- [ ] **Step 5: Review, merge, deploy, release**
After CI passes and review is approved:
- Merge PR.
- Monitor deploy workflow on `main`.
- Create release `v2.2.0` with Russian release notes.
- Close issue `#26` with a comment linking PR and release.
## Self-Review
- Spec coverage: Project creation, NetCord Gateway, slash/component service registration, `Discord__Token`, PostgreSQL service defaults, lifecycle logging, Telegram isolation, solution build, compose/deploy integration, and version sync are covered.
- Placeholder scan: No task uses `TBD`, `TODO`, or an unspecified "add tests" instruction.
- Type consistency: Test class names and file paths are consistent across tasks; NetCord lifecycle handler signatures are explicitly marked for compile-driven adjustment because the package is prerelease and must be verified against installed `1.0.0-alpha.489`.
@@ -1,599 +0,0 @@
# Platform-Neutral Join Leave Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Gitea issue #25 by making join/leave session interactions use platform-neutral command models while preserving Telegram callback behavior, seat limits, and waitlist semantics.
**Architecture:** Telegram callback routing remains in `UpdateRouter`, but it becomes an adapter that converts callback data into `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef` values. `JoinSessionHandler` and `LeaveSessionHandler` operate on those neutral values, persist players by `(platform, external_user_id)`, and update schedules through `IPlatformMessenger`.
**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Gitea Actions.
---
## Issue Context
- Issue: `#25 refactor: obobshchit JoinSession i LeaveSession pod platform-neutral interactions`
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`
- Version bump: patch, `2.1.0` -> `2.1.1`. The issue is labeled refactor, not breaking; do not use a major bump without explicit approval.
- Existing untracked file: `CLAUDE.md`; do not stage or modify it.
## File Map
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
- Reflection tests proving join/leave command records expose neutral properties and no Telegram-specific identity/message fields.
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
- Source-level regression tests for handler SQL and messenger boundaries.
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
- Add a migration test for nullable legacy `players.telegram_id`, required for non-Telegram player inserts.
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
- Drop `NOT NULL` from legacy Telegram-only player columns.
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- Change `JoinSessionCommand` to neutral properties and query/upsert players by platform identity.
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- Change `LeaveSessionCommand` to neutral properties and find participants by platform identity.
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- Convert Telegram callback data into neutral command values using `TelegramPlatformIds`.
- Modify: version files after implementation:
- `Directory.Build.props`
- `compose.yaml`
- `.gitea/workflows/deploy.yml`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
## Task 1: RED - Command Model Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
- [ ] **Step 1: Write failing command-shape tests**
```csharp
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionCommandTests
{
[Fact]
public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<JoinSessionCommand>("SessionId", typeof(Guid));
AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
}
[Fact]
public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(property => property.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
```
- [ ] **Step 2: Verify RED**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
```
Expected: FAIL because `JoinSessionCommand` and `LeaveSessionCommand` still expose `TelegramUserId`, `ChatId`, and `MessageId`, and do not expose `User`, `Group`, or `ScheduleMessage`.
## Task 2: RED - SQL and Boundary Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
- [ ] **Step 1: Write failing handler source tests**
```csharp
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionSqlTests
{
[Fact]
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
}
[Fact]
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
}
[Fact]
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
```
- [ ] **Step 2: Add failing migration assertion**
Append to `PlatformIdentityMigrationTests`:
```csharp
[Fact]
public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql");
Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal);
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
}
```
- [ ] **Step 3: Verify RED**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionSqlTests|MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId"
```
Expected: FAIL because handlers still use Telegram-specific properties and the V017 migration file does not exist.
## Task 3: GREEN - Add Migration
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
- [ ] **Step 1: Create the migration**
```sql
-- =============================================================
-- V017: Allow platform-neutral players
-- =============================================================
-- Legacy Telegram identity columns remain for backward compatibility,
-- but non-Telegram platform users do not have Telegram ids.
-- =============================================================
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
```
- [ ] **Step 2: Verify migration test turns green**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId
```
Expected: PASS.
## Task 4: GREEN - Refactor JoinSessionCommand and Handler
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- [ ] **Step 1: Replace command record**
Replace the existing `JoinSessionCommand` declaration with:
```csharp
public sealed record JoinSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
```
- [ ] **Step 2: Replace player upsert**
Use platform identity parameters:
```csharp
var platform = command.User.Platform.ToString();
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
: (long?)null;
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
? command.User.ExternalUsername
: null;
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username
RETURNING id;",
new
{
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
},
transaction);
```
Add `using System.Globalization;` at the top.
- [ ] **Step 3: Update participant display query**
Change the participant projection to prefer platform-neutral username:
```sql
COALESCE(p.external_username, p.telegram_username) as TelegramUsername
```
- [ ] **Step 4: Update schedule message and interaction reply usage**
Use:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
```
and:
```csharp
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
```
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
- [ ] **Step 5: Verify command and SQL tests for join**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext|JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity"
```
Expected: PASS for join-focused tests.
## Task 5: GREEN - Refactor LeaveSessionCommand and Handler
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- [ ] **Step 1: Replace command record**
Replace the existing `LeaveSessionCommand` declaration with:
```csharp
public sealed record LeaveSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
```
- [ ] **Step 2: Replace participant lookup**
Use platform identity instead of Telegram id:
```csharp
var platform = command.User.Platform.ToString();
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
FOR UPDATE OF sp
""",
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
transaction);
```
- [ ] **Step 3: Update participant display query**
Change the participant projection to:
```sql
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername
```
- [ ] **Step 4: Update schedule message and interaction reply usage**
Use:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
```
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
- [ ] **Step 5: Verify leave tests**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext|LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity|SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference"
```
Expected: PASS.
## Task 6: GREEN - Convert Telegram Router to Neutral Commands
**Files:**
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- [ ] **Step 1: Add local conversion values in `HandleCallbackQueryAsync`**
After parsing `action`, add:
```csharp
var user = TelegramPlatformIds.User(
query.From.Id,
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
query.From.Username);
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
```
- [ ] **Step 2: Update join command construction**
```csharp
var command = new JoinSessionCommand(
SessionId: joinSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
```
- [ ] **Step 3: Update leave command construction**
```csharp
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
```
- [ ] **Step 4: Verify compile**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
```
Expected: PASS.
## Task 7: REFACTOR - Clean Up and Full Test Pass
**Files:**
- Modify only files already listed if cleanup is needed.
- [ ] **Step 1: Remove now-unused Telegram handler imports**
Check `JoinSessionHandler.cs` and `LeaveSessionHandler.cs` for unused:
```csharp
using GmRelay.Bot.Infrastructure.Telegram;
```
Remove it from handlers if no longer needed.
- [ ] **Step 2: Run focused tests**
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"
```
Expected: PASS.
- [ ] **Step 3: Run full test suite**
```powershell
dotnet test .\GM-Relay.slnx
```
Expected: PASS.
- [ ] **Step 4: Build solution**
```powershell
dotnet build .\GM-Relay.slnx
```
Expected: PASS.
## Task 8: Version Bump
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Update version from `2.1.0` to `2.1.1`**
Expected exact replacements:
```xml
<Version>2.1.1</Version>
```
```yaml
VERSION: 2.1.1
```
```yaml
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
```
```razor
<div class="nav-version">v2.1.1</div>
```
- [ ] **Step 2: Verify synchronized versions**
Run:
```powershell
rg "<Version>|image: git.codeanddice.ru/toutsu/gmrelay-|VERSION:|nav-version" Directory.Build.props compose.yaml .gitea\workflows\deploy.yml src\GmRelay.Web\Components\Layout\NavMenu.razor
```
Expected: all project image/app/deploy UI versions show `2.1.1`.
## Task 9: PR, CI, Review, Merge, Deploy, Release
**Files:**
- No additional source changes expected.
- [ ] **Step 1: Create branch after approval**
```powershell
git checkout -b refactor/issue-25-platform-neutral-join-leave
```
- [ ] **Step 2: Stage only intended files**
```powershell
git add docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
```
- [ ] **Step 3: Commit**
```powershell
git commit -m "refactor: make session join leave platform-neutral"
```
- [ ] **Step 4: Push and create Gitea PR**
```powershell
git push -u origin refactor/issue-25-platform-neutral-join-leave
```
PR title:
```text
refactor: make session join leave platform-neutral
```
PR body:
```markdown
## Summary
- Closes #25.
- Converts join/leave session interaction commands from Telegram-specific fields to platform-neutral `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef`.
- Persists and looks up session participants by `(platform, external_user_id)`.
- Keeps Telegram callback data and schedule update behavior intact.
## Test plan
- `dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"`
- `dotnet test .\GM-Relay.slnx`
- `dotnet build .\GM-Relay.slnx`
## Workflow
- [ ] CI passes
- [ ] Code review approved
- [ ] Deployed
- [ ] Release published
```
- [ ] **Step 5: Watch CI, request review, merge, deploy, release**
Use Gitea MCP for PR creation, CI polling, review, merge, deploy monitoring, and release `v2.1.1`. Close issue #25 after release and add a comment linking the PR and release.
## Self-Review
- Spec coverage: issue scope is covered by neutral command records, Telegram adapter conversion, platform identity SQL, messenger-based schedule updates, and tests.
- Placeholder scan: no `TBD`, `TODO`, or "fill later" steps remain.
- Type consistency: commands consistently use `PlatformUser User`, `string InteractionId`, `PlatformGroup Group`, and `PlatformMessageRef ScheduleMessage`.
@@ -1,984 +0,0 @@
# Discord /newsession и /listsessions — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development (TDD) for every task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Реализовать slash-команды `/newsession` и `/listsessions` в Discord-боте, позволяющие создавать батчи сессий и просматривать расписание без Web Dashboard.
**Architecture:** Каждая команда — отдельный vertical slice в `GmRelay.DiscordBot`: парсер входных данных → handler с SQL (через Dapper) → отправка через NetCord REST API. Рендеринг переиспользует существующий `DiscordSessionBatchRenderer`. Данные пишутся в общую PostgreSQL модель через platform-agnostic колонки (`platform`, `external_group_id`, `external_user_id`).
**Tech Stack:** .NET 10, NetCord 1.0.0-alpha.489, NetCord.Hosting.Services, Dapper, Npgsql, xUnit.
**Version Bump:** minor (2.3.0 → 2.4.0) — новый функционал.
---
## File Structure
| File | Responsibility |
|------|--------------|
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs` | Slash-команда `/newsession` с параметрами (title, time, seats, link) |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs` | Handler создания batch + sessions в БД, проверка прав |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs` | Slash-команда `/listsessions` |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs` | Handler запроса активных сессий и публикации embed |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs` | Проверка прав пользователя в guild (owner/admin/manager) |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Реализация `IPlatformMessenger` для отправки/обновления расписания в Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs` | TDD-тесты создания сессий из Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs` | TDD-тесты вывода расписания |
| `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs` | TDD-тесты проверки прав |
| `src/GmRelay.DiscordBot/Program.cs` | Регистрация DI: handlers, permission checker, platform messenger |
---
## Task 1: DiscordPermissionChecker
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs`
**Context:** Discord использует guild-роли. Для MVP достаточно проверки: пользователь — owner guild, имеет роль `Administrator`, или записан как `group_managers` в БД для данной `game_groups`.
### Step 1.1: Write the failing test
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPermissionCheckerTests
{
[Fact]
public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 123456789ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserHasAdministratorRole_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var adminRole = 999ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 987654321ul,
userRoles: new[] { adminRole },
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var managerId = 555ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: managerId,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { managerId });
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenRegularUser_ReturnsFalse()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 111ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { 222ul });
Assert.False(result);
}
}
```
### Step 1.2: Run test to verify it fails
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: FAIL — `DiscordPermissionChecker` not found.
### Step 1.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`:
```csharp
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPermissionChecker
{
// Discord Administrator permission bitflag
private const ulong AdministratorPermission = 0x8;
public bool CanManageSchedule(
ulong guildOwnerId,
ulong userId,
IEnumerable<ulong> userRoles,
IEnumerable<ulong> dbManagerUserIds)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
// NetCord provides permission resolution via GuildUser.Permissions;
// here we accept pre-resolved flag for simplicity.
// Actual command handler will pass resolved permissions.
return false;
}
public bool CanManageSchedule(ulong guildOwnerId, ulong userId, IEnumerable<ulong> dbManagerUserIds, ulong resolvedPermissions)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
}
}
```
### Step 1.4: Run test to verify it passes
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: PASS (4/4).
### Step 1.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs
git commit -m "feat(discord): add DiscordPermissionChecker for session management rights
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 2: DiscordListSessionsHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`
**Context:** Handler должен:
1. Найти `game_groups` по `external_group_id` = `guild_id`.
2. Выбрать предстоящие сессии (`scheduled_at > NOW()`, `status != Cancelled`).
3. Собрать участников.
4. Построить view через `SessionBatchViewBuilder`.
5. Отрендерить через `DiscordSessionBatchRenderer`.
6. Отправить embed + buttons в Discord channel.
### Step 2.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordListSessionsHandlerTests
{
[Fact]
public void BuildSchedule_WithSessions_ReturnsEmbedsAndButtons()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(sessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Planned, 4, "https://example.com")
};
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Single(actionRows);
}
[Fact]
public void BuildSchedule_WithCancelledSession_SkipsActionRows()
{
var cancelledSessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Cancelled, null, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Empty(actionRows);
}
}
```
### Step 2.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordListSessionsHandler` not found.
### Step 2.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`:
```csharp
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using NetCord.Rest;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
{
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
{
GuildId = guildId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionList = sessions.ToList();
if (sessionList.Count == 0)
return null;
var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC",
new { SessionIds = sessionIds });
var firstTitle = sessionList.First().Title;
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
public override async Task ExecuteAsync()
{
var guildId = Context.Guild?.Id.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var view = await _handler.BuildScheduleAsync(guildId, channelId, Context.CancellationToken);
if (view is null)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
return;
}
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
}
```
### Step 2.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: PASS.
### Step 2.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs
git commit -m "feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 3: DiscordNewSessionHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`
**Context:** Handler должен:
1. Проверить права пользователя (owner/admin/manager).
2. Upsert игрока (GM) в `players` с `platform = 'Discord'`.
3. Upsert `game_groups` с `platform = 'Discord'`, `external_group_id = guild_id`.
4. Создать batch + sessions.
5. Отправить rendered schedule в Discord channel.
6. Сохранить `platform_messages` reference.
### Step 3.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordNewSessionHandlerTests
{
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
Assert.Equal(19, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
}
```
### Step 3.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordNewSessionHandler` not found.
### Step 3.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`:
```csharp
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
{
if (result < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
{
if (altResult < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
IEnumerable<ulong> userRoles,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
// Resolve db managers
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, userRoles, dbManagerUserIds))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
// Upsert player
await connection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username",
new { Name = userDisplayName, UserId = userId.ToString() },
transaction);
// Upsert group
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GuildId = guildId, ChannelId = channelId },
transaction);
// Ensure manager record
await connection.ExecuteAsync(
@"INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
ON CONFLICT (group_id, player_id) DO NOTHING",
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
// Create batch + session
var batchId = Guid.NewGuid();
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = joinLink ?? string.Empty,
ScheduledAt = scheduledAt.UtcDateTime,
Status = SessionStatus.Planned,
MaxPlayers = maxPlayers
},
transaction);
await transaction.CommitAsync(cancellationToken);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("newsession", "Create a new game session")]
public class DiscordNewSessionCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler)
{
_handler = handler;
}
[SlashCommandOption("title", "Game title", Required = true)]
public string Title { get; set; } = string.Empty;
[SlashCommandOption("time", "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)", Required = true)]
public string Time { get; set; } = string.Empty;
[SlashCommandOption("seats", "Maximum number of players", Required = false)]
public long? Seats { get; set; }
[SlashCommandOption("link", "Join link", Required = false)]
public string? Link { get; set; }
public override async Task ExecuteAsync()
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
var timeResult = DiscordNewSessionHandler.ParseTimeInput(Time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {timeResult.Error}"));
return;
}
try
{
var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
userRoles: Context.GuildUser!.RoleIds,
guildOwnerId: guild.OwnerId,
title: Title,
scheduledAt: timeResult.Value,
maxPlayers: Seats is null ? null : (int)Seats.Value,
joinLink: Link,
Context.CancellationToken);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("✅ Сессия создана!"));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"⛅ {ex.Message}"));
}
catch (Exception ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("💥 Произошла ошибка при создании сессии."));
}
}
}
```
### Step 3.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: PASS.
### Step 3.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs
git commit -m "feat(discord): add /newsession slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 4: DiscordPlatformMessenger
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`
**Context:** Необходима реализация `IPlatformMessenger` для отправки schedule embeds и обновления существующих сообщений в Discord. Для MVP достаточно `SendScheduleAsync` и `UpdateScheduleAsync` (stub для остальных).
### Step 4.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`:
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPlatformMessengerTests
{
[Fact]
public void Constructor_ShouldAcceptRestClient()
{
// DiscordPlatformMessenger requires a NetCord.Rest.RestClient.
// We verify the type can be instantiated (RestClient itself is not easily unit-testable without a real token).
// This test proves the contract exists and compiles.
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
Assert.NotNull(constructor);
}
[Fact]
public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger()
{
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
}
}
```
### Step 4.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: FAIL — `DiscordPlatformMessenger` not found.
### Step 4.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`:
```csharp
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var msg = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
return new PlatformMessageRef(
PlatformKind.Discord,
message.Group.ExternalGroupId,
null,
msg.Id.ToString());
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
if (message.ExistingMessage is null)
return;
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
await restClient.ModifyMessageAsync(
channelId,
messageId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
// MVP: not needed for /newsession and /listsessions
return Task.CompletedTask;
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
// MVP: not needed (commands answer inline via SlashCommandContext)
return Task.CompletedTask;
}
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
}
```
### Step 4.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: PASS.
### Step 4.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs
git commit -m "feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 5: Wire up DI and Register Commands
**Files:**
- Modify: `src/GmRelay.DiscordBot/Program.cs`
### Step 5.1: Write the failing test (structure test)
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` — add test that asserts new handlers are registered:
```csharp
[Fact]
public void Program_ShouldRegisterDiscordSessionHandlers()
{
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", program);
Assert.Contains("IPlatformMessenger", program);
}
```
### Step 5.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: FAIL — asserts not found in Program.cs.
### Step 5.3: Write minimal implementation
Modify `src/GmRelay.DiscordBot/Program.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
// ... existing usings ...
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
// After host.Build():
host.AddSlashCommand("listsessions", "Show upcoming game sessions", async (DiscordListSessionsHandler handler, SlashCommandContext context) =>
{
// NetCord module-based approach preferred; if AddSlashCommand lambda doesn't support DI injection of custom services,
// rely on module classes registered via AddApplicationCommands
});
```
**Important:** NetCord module classes (`DiscordListSessionsCommand`, `DiscordNewSessionCommand`) автоматически регистрируются через `AddApplicationCommands()` + `AddGatewayHandlers(typeof(Program).Assembly)`. Constructor injection в модулях работает через DI контейнер. Никаких дополнительных `AddSlashCommand` для модулей не требуется.
Убедиться, что в Program.cs есть:
```csharp
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
```
### Step 5.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: PASS.
### Step 5.5: Commit
```bash
git add src/GmRelay.DiscordBot/Program.cs tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs
git commit -m "feat(discord): wire up DI registrations for session handlers and messenger
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 6: Build Verification
### Step 6.1: Build DiscordBot project
Run: `dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore`
Expected: Build succeeds (0 errors, 0 warnings).
### Step 6.2: Run all tests
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
Expected: All tests pass.
### Step 6.3: Commit if any fixes needed
If build or tests required fixes, commit them.
---
## Task 7: Version Bump
**Files to modify:**
- `Directory.Build.props`: `<Version>2.4.0</Version>`
- `compose.yaml`: обновить теги `gmrelay-bot`, `gmrelay-web`, `gmrelay-discord-bot``2.4.0`
- `.gitea/workflows/deploy.yml`: `VERSION: 2.4.0`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `<div class="nav-version">v2.4.0</div>`
### Step 7.1: Bump version
Apply изменения ко всем 4 файлам.
### Step 7.2: Update version test
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` — обновить `Version_ShouldBeSynchronizedForDiscordFeatureRelease` ожидаемое значение на `2.4.0`.
### Step 7.3: Run version test
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Version_ShouldBeSynchronizedForDiscordFeatureRelease" --verbosity normal`
Expected: PASS.
### Step 7.4: Commit
```bash
git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs
git commit -m "chore: bump version to 2.4.0
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Spec Coverage Self-Review
| Issue Requirement | Task |
|---|---|
| Slash command `/newsession` | Task 3 |
| Slash command `/listsessions` | Task 2 |
| Сохранение platform group identity (guild/channel) | Task 3 (game_groups.platform, external_group_id, external_channel_id) |
| Минимальная проверка прав | Task 1 + Task 3 |
| Данные пишутся в общую PostgreSQL без Telegram-only assumptions | Task 2, 3 SQL используют platform-agnostic колонки |
| `/listsessions` публикует/обновляет расписание | Task 2 + Task 4 |
**Placeholder scan:** Нет TBD, TODO, "implement later". Каждый шаг содержит конкретный код.
**Type consistency:** `DiscordPermissionChecker.CanManageSchedule` перегружен для resolved permissions (ulong bitflag). Handler передает `Context.GuildUser.RoleIds` и `guild.OwnerId`.
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md`.**
**Two execution options:**
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review
**Which approach?**
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,44 +0,0 @@
# 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.
@@ -1,140 +0,0 @@
# Platform Messenger Scheduler Notifications Design
## Goal
Issue #31 moves scheduler-driven notifications and reschedule deadline message updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord support instead of no-op placeholders.
## Scope
- `SessionSchedulerService` remains the trigger orchestrator, but scheduler handlers stop depending on Telegram API types for outbound notification work.
- Confirmation requests, one-hour reminders, join-link notifications, RSVP follow-up messages, and reschedule deadline updates use platform-neutral contracts.
- Telegram keeps the current user-visible behavior: same message content, RSVP buttons, direct messages, topic/thread targeting, and stored legacy message ids.
- Discord receives full channel and direct notifications:
- confirmation requests are sent to the Discord channel with RSVP buttons;
- Discord RSVP button clicks update participant RSVP state, refresh the confirmation message, and send the same group/GM outcome notifications where applicable;
- one-hour reminders and join-link notifications are sent as Discord DMs when direct notifications are enabled;
- join-link notifications also post the channel message with participant mentions;
- reschedule deadline processing updates Discord vote and schedule messages through the same messenger boundary.
- Discord DM failures are non-fatal: log a warning and continue without posting a public fallback message.
## Architecture
The platform boundary should be semantic, not Telegram-shaped. `GmRelay.Shared.Platform` already owns `PlatformKind`, `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `IPlatformMessenger`; issue #31 extends that layer with notification-specific DTOs and messenger methods.
The scheduler handlers own database queries and notification eligibility. They load platform-neutral groups, users, message refs, and session data, then ask the platform messenger to send or update the platform message. Platform implementations own rendering details: Telegram renders HTML and inline keyboards; Discord renders embeds, components, channel messages, mentions, and DMs.
RSVP handling should become platform-neutral enough for both Telegram and Discord. The current `HandleRsvpHandler` logic is not duplicated. Its command changes from Telegram ids to `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `InteractionId`. Telegram update routing maps callback queries into that command; Discord component routing maps RSVP button interactions into the same command.
Reschedule finalization already has shared database logic in `RescheduleVotingFinalizer`. The remaining platform-specific deadline services should stop editing messages through `ITelegramBotClient` or Discord `RestClient` directly. They should load message refs and call `IPlatformMessenger` to update vote messages, schedule messages, and direct result notifications.
## Platform Contracts
Add semantic notification records in `GmRelay.Shared.Platform`, with names finalized during implementation planning:
- `PlatformSessionParticipant`: a `PlatformUser` plus RSVP, registration, and display metadata needed by notification renderers.
- `PlatformSessionNotification`: common session title, time, join link, notification mode, group, optional existing message, and participants.
- `PlatformConfirmationRequest`: confirmation-specific session notification with RSVP actions.
- `PlatformJoinLinkNotification`: join-link group/direct notification data.
- `PlatformOneHourReminder`: one-hour direct reminder data.
- `PlatformRsvpMessageUpdate`: refreshed confirmation message state after a participant responds.
- `PlatformRescheduleVoteUpdate`: finalized reschedule vote message state, including selected option or rejection reason.
Extend `IPlatformMessenger` with methods for these semantic operations while keeping existing schedule, group, private, interaction, and calendar methods intact for current flows:
- send and update confirmation request messages;
- send one-hour reminder direct notifications;
- send join-link channel and direct notifications;
- update finalized reschedule vote messages;
- send RSVP outcome messages to the group and GM recipients.
The exact method names should be chosen in the implementation plan after tests define the desired API, but each method should accept platform-neutral DTOs and return `PlatformMessageRef` when the caller must persist a sent message id.
## Telegram Behavior
Telegram implementation lives in `GmRelay.Bot.Infrastructure.Telegram.TelegramPlatformMessenger`.
It must preserve:
- `messageThreadId` handling for forum topics;
- HTML parse mode where the existing flow uses HTML;
- current confirmation and RSVP button callback payloads;
- `confirmation_message_id` and `link_message_id` storage in `sessions`;
- direct notification behavior controlled by `SessionNotificationMode`;
- warning-and-continue behavior for failed direct messages;
- existing schedule rendering through `TelegramSessionBatchRenderer` and `BatchMessageEditor`.
Telegram-specific inbound parsing remains at the Telegram boundary. `UpdateRouter` can still use `Telegram.Bot.Types`, but the command it passes into the RSVP handler should be platform-neutral.
## Discord Behavior
Discord implementation lives in `GmRelay.DiscordBot.Infrastructure.Discord.DiscordPlatformMessenger`.
It must support:
- channel messages through the configured channel id in `PlatformGroup.ExternalChannelId`;
- interactive RSVP buttons routed by `DiscordSessionInteractionModule`;
- ephemeral interaction replies via the existing `DiscordInteractionReplyCache` pattern;
- DMs through Discord user ids in `PlatformUser.ExternalUserId`;
- non-fatal DM failures with warning logs;
- Discord-friendly rendering, not raw Telegram HTML;
- persistence of Discord schedule and notification message refs in `platform_messages` where later updates need them.
The current Discord reschedule deadline service directly uses `RestClient` for vote and schedule message edits. This should be folded into `DiscordPlatformMessenger` so deadline services and future platform handlers do not need to know Discord API details.
## Data Flow
1. `SessionSchedulerService.TickAsync` asks `ISessionTriggerStore` for due confirmation, one-hour reminder, and join-link session ids.
2. Each handler loads the session, group platform identity, message refs, participants, RSVP state, and notification mode.
3. The handler builds a semantic platform notification DTO and calls `IPlatformMessenger`.
4. The messenger renders and sends/updates platform messages.
5. The handler persists sent message ids where required, using legacy `sessions.confirmation_message_id` and `sessions.link_message_id` for Telegram and `platform_messages` for Discord refs that need later updates.
6. Telegram callback queries and Discord component interactions both call the same platform-neutral RSVP handler.
7. Reschedule deadline services use `RescheduleVotingFinalizer`, then call `IPlatformMessenger` for vote message updates, schedule updates, and direct result notifications.
## Error Handling
- A failed trigger query still logs and lets the scheduler continue to the next trigger category.
- A failed send/update for one session logs and does not stop other sessions in the same tick.
- DM failures are warning-level and non-fatal for Telegram and Discord.
- A missing platform message ref logs a warning and skips only the update that needs the ref.
- Unsupported platform values throw at the messenger boundary, not inside scheduler orchestration.
- If Discord cannot parse a stored channel, message, or user id, it logs the bad external id and skips that platform send/update.
## Testing
Use TDD for implementation.
Focused tests should cover:
- `IPlatformMessenger` exposes semantic notification methods without referencing Telegram or Discord assemblies from `GmRelay.Shared`.
- `SendConfirmationHandler`, `SendOneHourReminderHandler`, `SendJoinLinkHandler`, `HandleRsvpHandler`, and reschedule deadline services do not call `ITelegramBotClient`, `BatchMessageEditor`, or Discord `RestClient` directly for notification output.
- Telegram source/regression tests preserve thread ids, callback payloads, message id persistence, and direct notification mode behavior.
- Discord source tests verify registration of scheduler handlers, RSVP component routes, and messenger methods.
- RSVP flow tests run through platform-neutral `PlatformUser` identity, including Discord users without Telegram ids.
- Discord messenger tests verify DMs are attempted, DM failures are swallowed after logging, channel notifications include buttons or mentions as appropriate, and message refs are returned.
- Full regression: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`, `dotnet build`, and `dotnet format --verify-no-changes --verbosity diagnostic`.
## Versioning
Current repository version is `2.6.0`. Although the Gitea issue is labeled `type:refactor`, the approved scope adds full Discord notification behavior. Proposed bump: `2.6.0` to `2.7.0`.
Synchronize:
- `Directory.Build.props`
- `compose.yaml` image tags for bot, discord, and web
- `.gitea/workflows/deploy.yml` `VERSION`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
## Out Of Scope
- Moving the entire scheduler hosted service into `GmRelay.Shared`.
- Removing legacy Telegram columns such as `telegram_chat_id`, `confirmation_message_id`, or `link_message_id`.
- Reworking Web dashboard Telegram behavior.
- Public fallback messages when a Discord DM is blocked.
## Self-Review
- Spec coverage: every issue acceptance criterion is represented by scheduler handler boundaries, messenger contracts, Telegram behavior preservation, and Discord implementation requirements.
- Placeholder scan: no TBD/TODO/fill-in-later sections remain.
- Internal consistency: the design uses semantic platform DTOs consistently and keeps SDK-specific rendering in platform implementations.
- Scope check: the work is large but still one coherent platform-notification refactor; moving the whole scheduler to shared remains explicitly out of scope.
@@ -0,0 +1,234 @@
# Game Catalog and One-Shot Showcase — Design Spec
> Issue #39: feat: добавить каталог игр и витрину ваншотов
---
## Goal
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
---
## Architecture
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
---
## Tech Stack
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
---
## Data Model
### New Fields on `sessions` (Migration V027)
| Column | Type | Constraints | Description |
|---|---|---|---|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
| `description` | `TEXT` | nullable | Short description for card |
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
### `GameSystem` Enum
```csharp
public enum GameSystem
{
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
OldSchoolEssentials, Dragonbane, BladesInTheDark,
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
Ironsworn, Other
}
```
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
### DTOs
```csharp
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration);
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
GameSystem? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
```
---
## UI Design
### `/showcase` — Catalog Page
**Layout:**
- Hero with title "Каталог игр"
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
- Pagination (page + pageSize = 12)
**Filters:**
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
- Seats: "Есть места" | "Waitlist" | "Любое"
- System: dropdown with all `GameSystem` values
- Type: "Ваншот" | "Кампания" | "Любое"
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
**Card Design:**
- Cover image (fallback: colored placeholder with initials)
- Title
- System badge
- Date + time (MSK)
- Duration (e.g. "3 часа")
- Format badge
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
- Club name (link to `/club/{slug}`)
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
### `/s/{id}` — Public Session Detail (Updated)
New fields added to existing page:
- Cover image (full-width hero)
- System badge
- Description block
- Duration + format
- GM contact (always visible: Telegram username or Discord tag)
- If `allow_direct_registration`:
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
- Direct registration into `session_participants` via `SessionCapacityRules`
---
## Backend
### ISessionStore Methods
```csharp
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
```
`GetShowcaseSessionsAsync` query:
- Cross-group (all clubs with `public_schedule_enabled = true`)
- Only `is_public = true` sessions
- `scheduled_at > now() - interval '4 hours'`
- `status <> 'Cancelled'`
- Apply filters in SQL WHERE clause
- Order by `scheduled_at ASC`
- Offset/limit pagination
`RegisterFromShowcaseAsync`:
- Check `allow_direct_registration = true`
- Load session with `FOR UPDATE`
- Count active + waitlisted participants
- Use `SessionCapacityRules.DecideJoinStatus`
- Insert participant with appropriate `registration_status`
- Return true on success, false if full and no waitlist allowed
---
## Bot Integration
### Telegram Bot
During `CreateSessionCommand` flow, after title/link/time input:
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
3. "Описание игры (краткое):" — text input, optional (skip button)
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
5. "Продолжительность (в часах):" — int input, optional
6. "Обложка (URL или пропустить):" — text input, optional
During `/publish` flow:
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
### Discord Bot
Same flow adapted for Discord interactions:
- Slash command options or button menus for system/format
- Modal input for description, duration, cover URL
- Fuzzy matching on free-text system input
---
## Migration V027
```sql
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
```
---
## Testing Strategy
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
---
## Version Bump
Issue label: `type:feature`**minor bump**
Current: `3.3.0` → Next: `3.4.0`
Files to sync:
- `Directory.Build.props`
- `compose.yaml` (bot, discord, web image tags)
- `.gitea/workflows/deploy.yml` (`VERSION` env)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria (from Issue #39)
- [ ] User can find a published game without accessing a private dashboard
- [ ] Registration does not bypass existing seat/waitlist limits
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
- [ ] Filters work: date, seats, system, type, format
- [ ] GM contact is always visible on public session detail
- [ ] Direct registration respects `SessionCapacityRules`
@@ -42,12 +42,13 @@ public sealed class CancelSessionHandler(
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
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
if (session == null)
{
@@ -89,7 +90,7 @@ public sealed class CancelSessionHandler(
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
SELECT p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -1,292 +1,178 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
IPlatformMessenger messenger,
ILogger<CreateSessionHandler> logger)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
public async Task HandleAsync(Message message, CancellationToken ct)
{
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
foreach (var timeInput in parseResult.PastTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
cancellationToken: cancellationToken);
ct);
}
foreach (var timeInput in parseResult.InvalidTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
ct);
}
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
cancellationToken: cancellationToken);
ct);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
ct);
}
if (!parseResult.IsValid)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"""
Не удалось распознать формат. Пожалуйста, используйте шаблон:
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для повтора можно указать одну дату и строки:
Игр: 4
Интервал: 7
""",
ct);
return;
}
var title = parseResult.Title!;
var link = parseResult.Link!;
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username;
var chatId = message.Chat.Id;
var chatTitle = message.Chat.Title ?? "Private Chat";
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var messageThreadId = topicDestination.MessageThreadId;
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
if (topicDestination.ShouldCreateForumTopic)
{
await connection.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
platform = COALESCE(players.platform, 'Telegram'),
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username);
""",
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
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, GmId = gmId },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
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 sessions = new List<SessionBatchDto>();
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
Message batchMessage;
if (imageReference is not null && renderResult.Text.Length <= 1024)
{
// Картинка + расписание умещаются в одном Telegram-фото с подписью
try
{
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
if (imageReference is not null)
{
try
{
await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
}
}
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
try
{
await botClient.DeleteMessage(
chatId: chatId,
messageId: message.MessageId,
cancellationToken: cancellationToken);
var topicRef = await messenger.CreateThreadAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"🎲 Игры: {parseResult.Title}",
ct);
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception ex)
when (ex.Message.Contains("not enough rights") ||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
ex.Message.Contains("not an administrator"))
{
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
TelegramTopicRouting.MissingForumTopicRightsMessage,
ct);
return;
}
}
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
var platformUser = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
gmName,
gmUsername);
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
platformUser,
platformGroup,
parseResult.Title!,
parseResult.Link!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
imageReference);
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
try
{
result = await sharedHandler.HandleAsync(command, ct);
}
catch
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"💥 Произошла ошибка базы данных при создании сессии.",
ct);
throw;
}
if (!result.Success)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
result.ErrorMessage!,
ct);
return;
}
var scheduleMessage = new PlatformScheduleMessage(
platformGroup,
result.View!,
null,
imageReference);
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
// Store batch_message_id
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessageId, BatchId = result.BatchId });
}
// Delete original message
try
{
await messenger.DeleteMessageAsync(
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при создании сессии");
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
}
}
@@ -41,13 +41,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
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
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
FOR UPDATE
""",
new { command.SessionId, command.TelegramUserId },
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
transaction);
if (session is null)
@@ -150,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -1,113 +1,25 @@
using System.Text;
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IConfiguration configuration)
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
public Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.telegram_chat_id = @ChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = message.From?.Id;
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
new { ChatId = message.Chat.Id });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
return sharedHandler.HandleAsync(command, cancellationToken);
}
}
@@ -1,8 +1,5 @@
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -13,138 +10,88 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
IPlatformMessenger messenger,
ILogger<DeleteSessionHandler> logger)
{
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
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);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (session == null)
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
command.SessionId,
platformUser,
platformGroup,
scheduleMessage);
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
ct);
return;
}
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (session.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
if (result.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
{
try
{
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
await messenger.DeleteThreadAsync(
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
}
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
// A simple way is to re-render the list:
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
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
JOIN game_groups g ON s.group_id = g.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()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
ChatId = command.ChatId,
command.TelegramUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
// 5. Update the /listsessions message
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
if (listResult.Sessions.Count == 0)
{
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
try
{
await messenger.UpdateGroupMessageAsync(
scheduleMessage,
"📭 В этой группе нет предстоящих игр.",
[],
ct);
}
catch { }
return;
}
var renderResult = SessionListMessageRenderer.Render(sessionsList);
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
try
{
await bot.EditMessageText(
command.ChatId,
command.MessageId,
renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
}
catch (Exception ex)
{
@@ -1,114 +1,37 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Platform;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
internal static class SessionListMessageRenderer
{
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessions.Count > 0 && sessions.First().CanManage;
if (!canManage)
{
return (text, null);
}
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
]);
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
]);
}
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
]);
}
return (text, new InlineKeyboardMarkup(buttons));
}
}
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
IPlatformMessenger messenger)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
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
JOIN game_groups g ON s.group_id = g.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()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
ChatId = message.Chat.Id,
TelegramUserId = message.From?.Id,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var result = await sharedHandler.HandleAsync(command, cancellationToken);
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
if (result.Sessions.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 В этой группе нет предстоящих игр.",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
return;
}
var renderResult = SessionListMessageRenderer.Render(sessionsList);
var text = SessionListMessageRenderer.RenderText(result.Sessions);
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
await botClient.SendMessage(
chatId: message.Chat.Id,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
}
}
@@ -0,0 +1,63 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal static class SessionListMessageRenderer
{
public static string RenderText(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";
}
return text;
}
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
{
return [];
}
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
actions.Add(new PlatformMessageAction(
$"delete_session:{session.Id}",
$"🗑 Удалить {dateTitle}",
$"delete_session:{session.Id}"));
}
return actions;
}
}
@@ -12,241 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses reschedule options with a voting deadline, creates a voting message,
/// and tags all participants.
/// If no participants are registered, reschedules immediately.
/// Telegram adapter for reschedule time input.
/// Delegates core logic to the shared handler, then performs Telegram-specific
/// message sending, DM notifications, vote_message_id storage, and cleanup.
/// </summary>
public sealed class HandleRescheduleTimeInputHandler(
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
/// <summary>
/// Attempts to handle a text message as reschedule time input.
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
/// </summary>
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
{
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
return false;
var gmTelegramId = message.From.Id;
var chatId = message.Chat.Id;
var text = message.Text.Trim();
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
new PlatformUser(
PlatformKind.Telegram,
message.From.Id.ToString(),
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
message.From.Username),
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
message.Text.Trim());
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Check if this GM has an AwaitingTime proposal in this chat
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime'
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
LIMIT 1
""",
new { GmId = gmTelegramId, ChatId = chatId });
if (proposal is null)
var result = await sharedHandler.HandleAsync(command, ct);
if (!result.Handled)
return false;
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
command.Group,
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
ct);
return true;
}
// 3. Load participants (non-GM) signed up for this session
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
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
// 4. If no participants — reschedule immediately
if (participants.Count == 0)
if (result.IsRescheduledImmediately)
{
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct);
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
{
await TryUpdateBatchMessage(
command.Group,
result.UpdatedView,
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
ct);
}
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
// 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id
""",
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
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);
// Voting mode
var voteText = BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
votingInput.Deadline,
options,
participants,
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt!.Value,
result.Options,
result.Participants,
[]);
var keyboard = BuildVotingKeyboard(options);
var keyboard = BuildVotingKeyboard(result.Options);
var voteMsg = await bot.SendMessage(
chatId: chatId,
messageThreadId: proposal.ThreadId,
chatId: message.Chat.Id,
messageThreadId: message.MessageThreadId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
if (mode.ShouldSendDirectMessages())
{
var optionsText = string.Join(
"\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $"""
🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(
result.Participants.Select(p => new DirectNotificationRecipient(
p.TelegramId,
p.DisplayName)),
directText,
"reschedule-vote",
proposal.SessionId,
result.ProposalId.Value,
ct);
}
// Store vote message ID
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
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
await TryDeleteMessage(chatId, message.MessageId, ct);
result.ProposalId.Value,
result.ProposalId.Value,
result.Options.Count,
result.VotingDeadlineAt.Value);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
private async Task RescheduleImmediately(
NpgsqlConnection connection, AwaitingProposalDto proposal,
DateTimeOffset newTime, long chatId, CancellationToken ct)
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
await using var connection = await dataSource.OpenConnectionAsync(ct);
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
SELECT s.notification_mode
FROM sessions s
JOIN reschedule_proposals rp ON rp.session_id = s.id
WHERE rp.id = @Id
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
new { Id = proposalId });
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
}
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
new { NewTime = newTime, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
ct);
// Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
private async Task TryUpdateBatchMessage(
PlatformGroup group,
SessionBatchViewModel view,
PlatformMessageRef scheduleMessage,
CancellationToken ct)
{
try
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(group, view, scheduleMessage),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
}
}
internal static string BuildVotingMessage(
@@ -268,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler(
var lines = new List<string>
{
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
"",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
@@ -349,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{
try
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.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
{
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
}
}
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
{
try
@@ -1,8 +1,7 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -15,130 +14,49 @@ public sealed record HandleRescheduleVoteCommand(
int MessageId);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger)
{
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (proposal is null)
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
command.OptionId,
platformUser,
platformGroup,
command.CallbackQueryId,
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
ct);
return;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
return;
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
{
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
return;
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
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();
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
try
{
@@ -152,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -45,12 +45,13 @@ public sealed class InitiateRescheduleHandler(
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
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
if (session is null)
{
@@ -83,10 +84,10 @@ public sealed class InitiateRescheduleHandler(
// 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
""",
new { command.SessionId, GmId = command.TelegramUserId });
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
@@ -79,7 +79,7 @@ public sealed class RescheduleVotingDeadlineService(
"""
SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
@@ -169,7 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger(
cancellationToken: ct);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(group.Platform);
await bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
await bot.EditMessageText(
chatId: ParseLong(messageRef.ExternalGroupId),
messageId: ParseInt(messageRef.ExternalMessageId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
EnsureTelegram(group.Platform);
var topic = await bot.CreateForumTopic(
chatId: ParseLong(group.ExternalGroupId),
name: title,
cancellationToken: ct);
return new PlatformMessageRef(
PlatformKind.Telegram,
group.ExternalGroupId,
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
string.Empty);
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
{
EnsureTelegram(group.Platform);
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
return Task.CompletedTask;
}
return bot.DeleteForumTopic(
ParseLong(group.ExternalGroupId),
ParseInt(group.ExternalThreadId),
cancellationToken: ct);
}
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
return bot.DeleteMessage(
ParseLong(messageRef.ExternalGroupId),
ParseInt(messageRef.ExternalMessageId),
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
@@ -5,6 +5,9 @@ using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot;
@@ -20,7 +23,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// </summary>
public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
BotCreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
@@ -29,8 +32,8 @@ public sealed class UpdateRouter(
ListSessionsHandler listSessionsHandler,
ExportCalendarHandler exportCalendarHandler,
InitiateRescheduleHandler initiateRescheduleHandler,
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
@@ -0,0 +1,18 @@
-- =============================================================
-- V019: Rename session_audit_log.actor_telegram_id to actor_external_user_id
-- =============================================================
-- Scope: Support platform-agnostic audit log identity.
-- =============================================================
ALTER TABLE session_audit_log
ADD COLUMN actor_external_user_id VARCHAR(255);
UPDATE session_audit_log
SET actor_external_user_id = actor_telegram_id::TEXT
WHERE actor_external_user_id IS NULL;
ALTER TABLE session_audit_log
ALTER COLUMN actor_external_user_id SET NOT NULL;
ALTER TABLE session_audit_log
DROP COLUMN actor_telegram_id;
@@ -0,0 +1,37 @@
-- =============================================================
-- V020: Player identity linking for unified multi-platform accounts
-- =============================================================
-- Scope: Allow linking multiple platform identities (Telegram, Discord)
-- to a single "primary" player account. All group/session permissions
-- resolve through the effective (primary) player id.
-- =============================================================
-- player_links: secondary player → primary player (1:1 on secondary)
CREATE TABLE player_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
primary_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
secondary_player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
linked_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
-- Prevent self-linking at the DB level
CONSTRAINT no_self_link CHECK (primary_player_id <> secondary_player_id)
);
CREATE INDEX ix_player_links_primary_player_id
ON player_links(primary_player_id);
-- identity_audit_log: security-sensitive link/unlink actions
CREATE TABLE identity_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL, -- 'link', 'unlink', 'link_attempt_conflict'
target_platform VARCHAR(50),
target_external_user_id VARCHAR(255),
performed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
performed_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL
);
CREATE INDEX ix_identity_audit_log_player_id
ON identity_audit_log(player_id);
CREATE INDEX ix_identity_audit_log_performed_at
ON identity_audit_log(performed_at DESC);
@@ -0,0 +1,8 @@
-- =============================================================
-- V021: Add avatar_url column to players table
-- =============================================================
-- Scope: Support storing avatar URLs for Discord and other platforms.
-- =============================================================
ALTER TABLE players
ADD COLUMN avatar_url VARCHAR(500);
@@ -0,0 +1,16 @@
-- =============================================================
-- V022: Fix incorrectly oriented player_links for Discord↔Telegram
-- =============================================================
-- Scope: Reverse player_links where Discord was incorrectly made primary
-- and Telegram secondary. Telegram (with historical group/session data)
-- must always be the primary account.
-- =============================================================
UPDATE player_links pl
SET primary_player_id = pl.secondary_player_id,
secondary_player_id = pl.primary_player_id
FROM players p1, players p2
WHERE pl.primary_player_id = p1.id
AND pl.secondary_player_id = p2.id
AND p1.platform = 'Discord'
AND p2.platform = 'Telegram';
@@ -0,0 +1,14 @@
-- =============================================================
-- V023: Make legacy Telegram columns nullable for multi-platform
-- =============================================================
-- Scope: Allow Discord (and future platforms) to create players
-- and game_groups without legacy telegram_* values.
-- Existing Telegram data was backfilled in V016.
-- =============================================================
ALTER TABLE game_groups
ALTER COLUMN telegram_chat_id DROP NOT NULL,
ALTER COLUMN gm_telegram_id DROP NOT NULL;
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
@@ -0,0 +1,41 @@
-- =============================================================
-- V024: Deprecate legacy Telegram-specific columns
-- =============================================================
-- Scope: Complete platform migration by backfilling any remaining
-- external_* gaps and officially deprecating telegram_* columns.
-- No columns are dropped — rollback-safe.
-- =============================================================
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- 3. Add platform identity to calendar_subscriptions
ALTER TABLE calendar_subscriptions
ADD COLUMN user_platform VARCHAR(50),
ADD COLUMN user_external_id VARCHAR(255);
UPDATE calendar_subscriptions
SET user_external_id = user_telegram_id::TEXT,
user_platform = 'Telegram'
WHERE user_platform IS NULL;
-- 4. Migrate calendar subscription index
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
-- 5. Deprecation comments on legacy columns
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
@@ -0,0 +1,11 @@
-- =============================================================
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
-- =============================================================
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
-- =============================================================
UPDATE reschedule_proposals
SET proposed_by_external_user_id = proposed_by::TEXT
WHERE proposed_by_external_user_id IS NULL
AND proposed_by IS NOT NULL;
@@ -0,0 +1,17 @@
-- Public club pages and read-only schedule publication controls.
ALTER TABLE game_groups
ADD COLUMN public_slug VARCHAR(120),
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
ALTER TABLE sessions
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX ux_game_groups_public_slug
ON game_groups (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE is_public = true AND status <> 'Cancelled';
@@ -0,0 +1,14 @@
-- Showcase fields for game catalog / public session browsing.
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
+12 -3
View File
@@ -1,3 +1,4 @@
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
@@ -65,20 +66,28 @@ builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
+1 -1
View File
@@ -13,7 +13,7 @@ WORKDIR /src/src/GmRelay.DiscordBot
RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
WORKDIR /app
# Install wget for healthcheck
@@ -0,0 +1,84 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordDeleteSessionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView,
string? EmptyMessage = null);
public sealed class DiscordDeleteSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
DiscordListSessionsHandler listSessionsHandler,
ILogger<DiscordDeleteSessionHandler> logger)
{
public async Task<DiscordDeleteSessionResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
return new DiscordDeleteSessionResult(
"Только owner, администратор или manager могут удалять сессии.",
UpdatedView: null);
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var deletedRows = await connection.ExecuteAsync(
"""
DELETE FROM sessions s
USING game_groups g
WHERE s.group_id = g.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await transaction.CommitAsync(cancellationToken);
if (deletedRows == 0)
{
return new DiscordDeleteSessionResult(
"Сессия не найдена или уже удалена.",
UpdatedView: null);
}
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
var updatedView = await listSessionsHandler.BuildScheduleAsync(
guildId,
channelId,
userId,
resolvedPermissions,
guildOwnerId,
cancellationToken);
return updatedView is null
? new DiscordDeleteSessionResult(
"Сессия удалена.",
UpdatedView: null,
EmptyMessage: "В этом сервере нет предстоящих игр.")
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
}
}
@@ -1,9 +1,9 @@
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
@@ -13,13 +13,23 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
_handler = handler;
}
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public async Task ExecuteAsync()
{
var guildId = Context.Guild?.Id.ToString()
var guildId = Context.Interaction.GuildId?.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
var guildOwnerId = 0UL;
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
var view = await _handler.BuildScheduleAsync(
guildId,
channelId,
Context.User.Id,
resolvedPermissions,
guildOwnerId,
CancellationToken.None);
if (view is null)
{
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
public sealed class DiscordListSessionsHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker)
{
public Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken) =>
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
@@ -21,15 +33,15 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active)::int as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted)::int as WaitlistCount
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
AND s.scheduled_at > now() - interval '4 hours'
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
@@ -44,11 +56,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
if (sessionList.Count == 0)
return null;
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
var canManage = permissionChecker.CanManageSchedule(
guildOwnerId,
userId,
dbManagerUserIds,
resolvedPermissions);
var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
p.external_username as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
return canManage ? AddManagerActions(view) : view;
}
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
view with
{
Sessions = view.Sessions
.Select(session =>
{
if (SessionStatus.IsCancelled(session.Status))
return session;
var actions = session.AvailableActions
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
.ToList();
return session with { AvailableActions = actions };
})
.ToList()
};
}
@@ -1,10 +1,10 @@
using GmRelay.DiscordBot.Rendering;
using GmRelay.DiscordBot.Rendering;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("newsession", "Create a new game session")]
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
@@ -16,15 +16,54 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
_logger = logger;
}
[SlashCommand("newsession", "Create a new game session")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
{
var guild = Context.Guild
_logger.LogInformation(
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
Context.User.Id,
Context.User.GetType().Name,
Context.Interaction.GuildId,
Context.Channel?.Id);
var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild.");
var member = Context.User as GuildInteractionUser;
if (member is null)
{
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
throw new InvalidOperationException("Guild member data not available in interaction.");
}
var resolvedPermissions = (ulong)member.Permissions;
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
var guildName = guildId.ToString();
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
guildName = guild.Name;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
guildId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
}
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
if (!timeResult.IsSuccess)
{
@@ -33,55 +72,57 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
// Defer the response to avoid Discord 3-second interaction timeout
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
try
{
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
guildId: guildId.ToString(),
channelId: Context.Channel!.Id.ToString(),
groupName: guildName,
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
guildOwnerId: guildOwnerId,
title: title,
scheduledAt: timeResult.Value,
maxPlayers: seats is null ? null : (int)seats.Value,
joinLink: link,
CancellationToken.None);
_logger.LogInformation("Session created successfully. Building render.");
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithContent(":white_check_mark: **Session created successfully!**")
.WithEmbeds(embeds)
.WithComponents(actionRows)));
_logger.LogInformation("Sending success response.");
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":white_check_mark: **Session created successfully!**";
message.Embeds = embeds;
message.Components = actionRows;
});
_logger.LogInformation("Success response sent.");
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":no_entry: {ex.Message}";
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guild.Id);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: An error occurred while creating the session."));
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":boom: An error occurred while creating the session.";
});
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -1,9 +1,9 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions;
@@ -12,35 +12,40 @@ public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, strin
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
var trimmed = input.Trim();
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt1))
{
if (result < DateTimeOffset.UtcNow)
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
return new TimeParseResult(true, offset, null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
if (DateTime.TryParseExact(
trimmed,
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt2))
{
if (altResult < DateTimeOffset.UtcNow)
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
return new TimeParseResult(true, offset, null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
@@ -49,6 +54,7 @@ public sealed class DiscordNewSessionHandler(
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
string groupName,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
@@ -60,13 +66,18 @@ public sealed class DiscordNewSessionHandler(
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
? title
: groupName.Trim();
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
WHERE g.platform = 'Discord'
AND p.platform = 'Discord'
AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
@@ -75,6 +86,7 @@ public sealed class DiscordNewSessionHandler(
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var transactionCommitted = false;
try
{
await connection.ExecuteAsync(
@@ -89,13 +101,13 @@ public sealed class DiscordNewSessionHandler(
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GuildId = guildId, ChannelId = channelId },
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
transaction);
await connection.ExecuteAsync(
@@ -125,23 +137,19 @@ public sealed class DiscordNewSessionHandler(
transaction);
await transaction.CommitAsync(cancellationToken);
transactionCommitted = true;
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
}
catch
{
await transaction.RollbackAsync(cancellationToken);
if (!transactionCommitted)
{
await transaction.RollbackAsync(cancellationToken);
}
throw;
}
}
@@ -1,9 +1,9 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordRescheduleHandler _handler;
@@ -15,6 +15,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
_logger = logger;
}
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
@@ -22,9 +23,44 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
{
var guild = Context.Guild
_logger.LogInformation(
"reschedule called by user {UserId} ({UserType}) in guild {GuildId}",
Context.User.Id,
Context.User.GetType().Name,
Context.Interaction.GuildId);
var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild.");
var member = Context.User as GuildInteractionUser;
if (member is null)
{
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
throw new InvalidOperationException("Guild member data not available in interaction.");
}
var resolvedPermissions = (ulong)member.Permissions;
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
guildId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
}
if (!Guid.TryParse(sessionIdText, out var sessionId))
{
await Context.Interaction.SendResponseAsync(
@@ -64,54 +100,55 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
// Defer the response to avoid Discord 3-second interaction timeout
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
try
{
_logger.LogInformation("Initiating reschedule for session {SessionId} in guild {GuildId}", sessionId, guildId);
var result = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
guildId: guildId.ToString(),
channelId: Context.Channel!.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
guildOwnerId: guildOwnerId,
sessionId: sessionId,
options: parsedOptions,
deadline: deadlineResult.Value,
CancellationToken.None);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC."));
_logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, result.ProposalId);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.";
});
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
_logger.LogWarning(ex, "Unauthorized reschedule attempt by user {UserId}", Context.User.Id);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":no_entry: {ex.Message}";
});
}
catch (InvalidOperationException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":warning: {ex.Message}"));
_logger.LogWarning(ex, "Invalid reschedule request by user {UserId}", Context.User.Id);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":warning: {ex.Message}";
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: Ошибка при запуске голосования."));
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":boom: Ошибка при запуске голосования.";
});
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -1,114 +1,46 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId,
string GuildId, string ChannelId, string MessageId);
Guid OptionId,
ulong UserId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId);
public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource,
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var command = new HandleRescheduleVoteCommand(
input.OptionId,
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
input.InteractionId,
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
// 1. Load proposal + option
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
var result = await sharedHandler.HandleAsync(command, ct);
if (proposal is null)
return "Голосование уже завершено или не найдено.";
if (!result.Success)
{
return result.ReplyText!;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId);
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
}
return "Ваш голос учтён. До дедлайна его можно изменить.";
return result.ReplyText!;
}
}
@@ -152,7 +152,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
@@ -1,8 +1,10 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using System.Collections;
using System.Globalization;
using NetCord;
using NetCord.Rest;
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
HandleRsvpHandler rsvpHandler,
DiscordDeleteSessionHandler deleteSessionHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try
{
await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input),
result = await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
}
[ComponentInteraction("leave_session")]
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try
{
await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
result = await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
}
[ComponentInteraction("delete_session")]
public async Task DeleteAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
await RespondAsync(InteractionCallback.DeferredModifyMessage);
try
{
var result = await deleteSessionHandler.HandleAsync(
guildId: input.GuildId,
channelId: input.ChannelId,
userId: input.UserId,
resolvedPermissions: resolvedPermissions,
guildOwnerId: 0,
sessionId: parsedSessionId,
CancellationToken.None);
await CompleteDeleteResponseAsync(result);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
await FollowupEphemeralAsync("Не удалось удалить сессию.");
}
}
[ComponentInteraction("rsvp")]
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
@@ -168,7 +207,7 @@ public sealed class DiscordSessionInteractionModule(
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
{
var guild = Context.Guild
var guildId = Context.Interaction.GuildId?.ToString(CultureInfo.InvariantCulture)
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
var message = Context.Interaction.Message
?? throw new InvalidOperationException("Session button interaction must include a message.");
@@ -176,7 +215,7 @@ public sealed class DiscordSessionInteractionModule(
return new DiscordSessionInteractionInput(
SessionId: sessionId,
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
GuildId: guild.Id.ToString(CultureInfo.InvariantCulture),
GuildId: guildId,
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
UserId: Context.User.Id,
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
}
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
{
var updatedView = result.UpdatedView;
if (updatedView is not null && SourceMessageHasDeleteAction())
{
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
}
if (updatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
var reply = interactionReplies.Take(interactionId);
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
}
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
{
if (result.UpdatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
else if (result.EmptyMessage is not null)
{
await ModifyResponseAsync(options =>
{
options.Content = result.EmptyMessage;
options.Embeds = [];
options.Components = [];
});
}
await FollowupEphemeralAsync(result.ReplyText);
}
private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text);
private Task FollowupEphemeralAsync(string text) =>
FollowupAsync(new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
private bool SourceMessageHasDeleteAction() =>
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
private static bool ComponentContainsDeleteAction(object? component)
{
if (component is null)
return false;
if (component is IInteractiveComponent interactive
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
return true;
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
if (nestedComponents is null)
return false;
foreach (var nestedComponent in nestedComponents)
{
if (ComponentContainsDeleteAction(nestedComponent))
return true;
}
return false;
}
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message(
new InteractionMessageProperties()
@@ -6,11 +6,14 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
<NoWarn>$(NoWarn);DAP005</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var rows = BuildActionRows(actions);
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
var rows = BuildActionRows(actions);
await restClient.ModifyMessageAsync(channelId, messageId, options =>
{
options.Content = htmlText;
options.Components = rows;
});
}
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
// Discord thread creation is not implemented in this adapter
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
}
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
@@ -98,17 +130,34 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
CancellationToken ct)
{
var channelId = GetChannelId(request.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds([BuildConfirmationEmbed(request)])
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
try
{
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds([BuildConfirmationEmbed(request)])
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
return new PlatformMessageRef(
PlatformKind.Discord,
request.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
logger?.LogInformation(
"Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
channelId,
message.Id);
return new PlatformMessageRef(
PlatformKind.Discord,
request.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
channelId,
request.SessionId);
throw;
}
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
@@ -135,15 +184,32 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
CancellationToken ct)
{
var channelId = GetChannelId(notification.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
try
{
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
return new PlatformMessageRef(
PlatformKind.Discord,
notification.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
logger?.LogInformation(
"Join link sent to Discord channel {ChannelId}, message id {MessageId}",
channelId,
message.Id);
return new PlatformMessageRef(
PlatformKind.Discord,
notification.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
channelId,
notification.SessionId);
throw;
}
}
public async Task SendDirectSessionNotificationAsync(
@@ -272,14 +338,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
? "—"
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
return new EmbedProperties()
var embed = new EmbedProperties()
.WithTitle($"Ссылка на игру: {notification.Title}")
.WithDescription(
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
$"Ссылка: {notification.JoinLink}\n\n" +
$"Участники: {mentions}")
.WithUrl(notification.JoinLink)
.WithColor(new Color(0x57F287));
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
}
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
@@ -367,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
return ParseSnowflake(channelId);
}
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return [];
}
var rows = new List<ActionRowProperties>();
foreach (var chunk in actions.Chunk(5))
{
var row = new ActionRowProperties();
foreach (var action in chunk)
{
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
{
CustomId = action.Payload
});
}
rows.Add(row);
}
return rows;
}
private static ulong ParseSnowflake(string value) =>
ulong.Parse(value, CultureInfo.InvariantCulture);
}
+8 -1
View File
@@ -18,8 +18,10 @@ using Microsoft.Extensions.DependencyInjection;
using NetCord;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
using NetCord.Hosting.Services;
using NetCord.Hosting.Services.ApplicationCommands;
using NetCord.Hosting.Services.ComponentInteractions;
using NetCord.Services.ApplicationCommands;
using NetCord.Services.ComponentInteractions;
using Npgsql;
@@ -34,6 +36,8 @@ discordOptions.Validate();
builder.Services.AddSingleton(discordOptions);
builder.Logging.AddConsole();
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
@@ -52,8 +56,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
@@ -82,12 +88,13 @@ builder.Services
options.Token = discordOptions.Token;
options.Intents = GatewayIntents.Guilds;
})
.AddApplicationCommands()
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
.AddGatewayHandlers(typeof(Program).Assembly);
var host = builder.Build();
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
host.AddModules(typeof(Program).Assembly);
await host.RunAsync();
@@ -0,0 +1,43 @@
namespace GmRelay.DiscordBot.Rendering;
public static class DiscordEmbedUrls
{
public static string? NormalizeHttpUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var candidate = value.Trim();
if (IsSupportedHttpUrl(candidate, out var normalized))
return normalized;
if (candidate.Contains("://", StringComparison.Ordinal))
return null;
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
&& HasPublicHost(normalized)
? normalized
: null;
}
private static bool IsSupportedHttpUrl(string value, out string normalized)
{
normalized = string.Empty;
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return false;
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return false;
}
normalized = uri.ToString();
return true;
}
private static bool HasPublicHost(string value) =>
Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& uri.Host.Contains('.', StringComparison.Ordinal);
}
@@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer
.WithInline()
};
if (!string.IsNullOrEmpty(session.JoinLink))
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
if (embedUrl is not null)
{
embed = embed.WithUrl(session.JoinLink);
embed = embed.WithUrl(embedUrl);
}
embed = embed.WithColor(GetColor(session));
@@ -28,6 +28,12 @@
"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=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[10.0.5, )",
+83
View File
@@ -0,0 +1,83 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain;
public enum GameSystem
{
Dnd5e,
Pathfinder2e,
CallOfCthulhu7e,
Shadowdark,
OldSchoolEssentials,
Dragonbane,
BladesInTheDark,
Daggerheart,
CyberpunkRed,
Mothership,
AlienRpg,
WarhammerFantasy,
VampireMasquerade5e,
StarWarsFfg,
Genesys,
SavageWorlds,
GURPS,
Fate,
DungeonWorld,
Ironsworn,
Other
}
public static class GameSystemExtensions
{
private static readonly FrozenDictionary<GameSystem, string> DisplayNames =
new Dictionary<GameSystem, string>
{
[GameSystem.Dnd5e] = "D&D 5e",
[GameSystem.Pathfinder2e] = "Pathfinder 2e",
[GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e",
[GameSystem.Shadowdark] = "Shadowdark",
[GameSystem.OldSchoolEssentials] = "Old School Essentials",
[GameSystem.Dragonbane] = "Dragonbane",
[GameSystem.BladesInTheDark] = "Blades in the Dark",
[GameSystem.Daggerheart] = "Daggerheart",
[GameSystem.CyberpunkRed] = "Cyberpunk RED",
[GameSystem.Mothership] = "Mothership",
[GameSystem.AlienRpg] = "Alien RPG",
[GameSystem.WarhammerFantasy] = "Warhammer Fantasy",
[GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e",
[GameSystem.StarWarsFfg] = "Star Wars (FFG)",
[GameSystem.Genesys] = "Genesys",
[GameSystem.SavageWorlds] = "Savage Worlds",
[GameSystem.GURPS] = "GURPS",
[GameSystem.Fate] = "Fate",
[GameSystem.DungeonWorld] = "Dungeon World",
[GameSystem.Ironsworn] = "Ironsworn",
[GameSystem.Other] = "Другое"
}.ToFrozenDictionary();
public static string ToDisplayName(this GameSystem system) =>
DisplayNames.TryGetValue(system, out var name) ? name : "Другое";
public static GameSystem? TryParseFuzzy(string input)
{
if (string.IsNullOrWhiteSpace(input))
return null;
var normalized = input.Trim().ToLowerInvariant();
if (Enum.TryParse<GameSystem>(normalized, true, out var exact))
return exact;
foreach (var value in Enum.GetValues<GameSystem>())
{
if (value == GameSystem.Other)
continue;
var display = value.ToDisplayName().ToLowerInvariant();
if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
return value;
}
return GameSystem.Other;
}
}
@@ -56,8 +56,8 @@ public sealed class HandleRsvpHandler(
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND COALESCE(p.platform, 'Telegram') = @Platform
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
)
@@ -90,8 +90,8 @@ public sealed class HandleRsvpHandler(
AND player_id = (
SELECT id
FROM players
WHERE COALESCE(platform, 'Telegram') = @Platform
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
WHERE platform = @Platform
AND external_user_id = @ExternalUserId
LIMIT 1
)
AND registration_status = @Active
@@ -265,10 +265,10 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
@@ -312,23 +312,13 @@ public sealed class HandleRsvpHandler(
var rows = await connection.QueryAsync<RsvpRecipientRow>(
"""
SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
p.external_username AS ExternalUsername
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
UNION
SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
FROM game_groups g
JOIN players p ON p.telegram_id = g.gm_telegram_id
WHERE g.id = @GroupId
AND g.gm_telegram_id IS NOT NULL
""",
new { GroupId = groupId },
transaction);
@@ -45,10 +45,10 @@ public sealed class SendConfirmationHandler(
s.title,
s.scheduled_at AS ScheduledAt,
s.group_id AS GroupId,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.platform AS Platform,
g.external_group_id AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
g.external_channel_id AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
@@ -65,10 +65,10 @@ public sealed class SendConfirmationHandler(
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
@@ -47,10 +47,10 @@ public sealed class SendJoinLinkHandler(
s.title,
s.join_link AS JoinLink,
s.scheduled_at AS ScheduledAt,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.platform AS Platform,
g.external_group_id AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
g.external_channel_id AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
@@ -58,14 +58,14 @@ public sealed class SendJoinLinkHandler(
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND (
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
OR (
COALESCE(g.platform, 'Telegram') <> 'Telegram'
g.platform <> 'Telegram'
AND NOT EXISTS (
SELECT 1
FROM platform_messages pm
WHERE pm.session_id = s.id
AND pm.platform = COALESCE(g.platform, 'Telegram')
AND pm.platform = g.platform
AND pm.purpose = 'join_link'
)
)
@@ -81,10 +81,10 @@ public sealed class SendJoinLinkHandler(
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
@@ -56,10 +56,10 @@ public sealed class SendOneHourReminderHandler(
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
p.external_username AS ExternalUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
@@ -0,0 +1,18 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionCommand(
PlatformUser User,
PlatformGroup Group,
string Title,
string Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
int? MaxPlayers,
string? ImageReference,
GameSystem? System = null,
string? Description = null,
string? Format = null,
int? DurationMinutes = null,
bool IsOneShot = false);
@@ -0,0 +1,168 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try
{
var platform = command.User.Platform.ToString();
var externalUserId = command.User.ExternalUserId;
var displayName = command.User.DisplayName;
var externalUsername = command.User.ExternalUsername;
await connection.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, @Platform, @ExternalId, @Username)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username;
""",
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.platform = @Platform
AND p.external_user_id = @ExternalGmId
) AS CanManage
FROM game_groups g
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
""",
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
RETURNING id;
""",
new
{
Platform = platform,
ExternalGroupId = command.Group.ExternalGroupId,
ExternalChannelId = command.Group.ExternalChannelId,
ChatName = command.Group.DisplayName
},
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(ct);
return new CreateSessionResult(
false,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
null,
null,
null,
Array.Empty<string>());
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"""
UPDATE game_groups
SET name = @ChatName
WHERE id = @GroupId
""",
new { ChatName = command.Group.DisplayName, GroupId = groupId },
transaction);
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
foreach (var scheduledAt in orderedTimes)
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
command.Title,
Link = command.Link,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
MaxPlayers = command.MaxPlayers,
System = command.System?.ToString(),
command.Description,
command.Format,
DurationMinutes = command.DurationMinutes,
IsOneShot = command.IsOneShot,
CoverImageUrl = command.ImageReference
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
}
await transaction.CommitAsync(ct);
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
return new CreateSessionResult(
true,
null,
view,
batchId,
groupId,
Array.Empty<string>());
}
catch
{
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
}
throw;
}
}
}
@@ -0,0 +1,11 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionResult(
bool Success,
string? ErrorMessage,
SessionBatchViewModel? View,
Guid? BatchId,
Guid? GroupId,
IReadOnlyList<string> Warnings);
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
public sealed record SessionInteractionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView = null);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger)
{
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
@@ -35,30 +40,19 @@ public sealed class JoinSessionHandler(
{
// 1. Убеждаемся, что игрок есть в базе
var platform = command.User.Platform.ToString();
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
: (long?)null;
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
? command.User.ExternalUsername
: null;
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username
RETURNING id;",
new
{
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
@@ -77,15 +71,13 @@ public sealed class JoinSessionHandler(
if (batchInfo is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
}
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
@@ -105,8 +97,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!"
: "Вы уже записаны!";
await AnswerAsync(command.InteractionId, alreadyText, ct);
return;
return await AnswerAsync(command.InteractionId, alreadyText, ct);
}
var activeParticipants = await connection.ExecuteScalarAsync<int>(
@@ -139,8 +130,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
return;
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
}
// Загружаем весь батч для перерисовки
@@ -154,7 +144,7 @@ public sealed class JoinSessionHandler(
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
p.external_username as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -168,17 +158,20 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
if (!command.DeferScheduleUpdate)
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
}
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!";
await AnswerAsync(command.InteractionId, callbackText, ct);
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
}
catch (Exception ex)
{
@@ -191,10 +184,17 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации.";
await AnswerAsync(command.InteractionId, errorText, ct);
return await AnswerAsync(command.InteractionId, errorText, ct);
}
}
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
private async Task<SessionInteractionResult> AnswerAsync(
string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
}
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger)
{
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
if (session is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
}
if (SessionStatus.IsCancelled(session.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
}
var platform = command.User.Platform.ToString();
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
if (participant is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
return;
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
}
await connection.ExecuteAsync(
@@ -175,7 +173,7 @@ public sealed class LeaveSessionHandler(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
if (!command.DeferScheduleUpdate)
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
}
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания."
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await AnswerAsync(command.InteractionId, callbackText, ct);
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
}
catch (Exception ex)
{
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи.";
await AnswerAsync(command.InteractionId, errorText, ct);
return await AnswerAsync(command.InteractionId, errorText, ct);
}
}
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
private async Task<SessionInteractionResult> AnswerAsync(
string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
}
@@ -0,0 +1,7 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
public sealed record ExportCalendarCommand(
PlatformGroup Group,
PlatformUser User);
@@ -0,0 +1,111 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IConfiguration configuration)
{
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.platform = @Platform"
+ " AND g.external_group_id = @ExternalGroupId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
command.Group,
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = command.User.ExternalUserId;
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, 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(
command.Group,
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
}
}
@@ -0,0 +1,91 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed record DeleteSessionResult(
bool Success,
string? ReplyText,
string? Title,
Guid? GroupId,
int? ThreadId,
bool TopicCreatedByBot,
int RemainingInTopic);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
if (session == null)
{
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
}
if (!session.CanManage)
{
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
return new DeleteSessionResult(
true,
"Сессия удалена!",
session.Title,
session.GroupId,
session.ThreadId,
session.TopicCreatedByBot,
remainingInTopic);
}
}
@@ -0,0 +1,13 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record ListSessionsCommand(
PlatformGroup Group,
PlatformUser User);
public sealed record DeleteSessionCommand(
Guid SessionId,
PlatformUser User,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,57 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed record SessionListResult(
IReadOnlyList<SessionListItemDto> Sessions,
bool CanManage);
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource)
{
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
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.platform = @Platform
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
Platform = command.Group.Platform.ToString(),
ExternalGroupId = command.Group.ExternalGroupId,
ExternalUserId = command.User.ExternalUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
return new SessionListResult(sessionsList, canManage);
}
}
@@ -0,0 +1,12 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto(
Guid Id,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
string ExternalGroupId,
int? ThreadId,
string NotificationMode);
@@ -0,0 +1,15 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputCommand(
PlatformUser User,
PlatformGroup Group,
string Text);
public sealed record HandleRescheduleVoteCommand(
Guid OptionId,
PlatformUser User,
PlatformGroup Group,
string InteractionId,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,181 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
HandleRescheduleTimeInputCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var platform = command.User.Platform.ToString();
var externalGmId = command.User.ExternalUserId;
var externalGroupId = command.Group.ExternalGroupId;
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.external_group_id AS ExternalGroupId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
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.proposed_by_external_user_id = @ExternalGmId
AND rp.status = 'AwaitingTime'
AND g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
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.platform = @Platform
AND manager_player.external_user_id = @ExternalGmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
if (proposal is null)
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
return new HandleRescheduleTimeInputResult(
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null);
}
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_id::BIGINT 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 })).ToList();
if (participants.Count == 0)
{
var newTime = votingInput.Options[0];
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
var replyText =
$"""✅ Сессия «{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>""";
return new HandleRescheduleTimeInputResult(
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
}
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId
WHERE id = @Id
""",
new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id },
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);
return new HandleRescheduleTimeInputResult(
true,
false,
null,
null,
proposal.Id,
votingInput.Deadline,
options,
participants,
[],
proposal.Title,
proposal.CurrentScheduledAt,
null);
}
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
NpgsqlConnection connection,
AwaitingProposalDto proposal,
DateTimeOffset newTime,
CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
new { NewTime = newTime, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_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();
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
}
}
@@ -0,0 +1,17 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputResult(
bool Handled,
bool IsRescheduledImmediately,
string? ReplyText,
SessionBatchViewModel? UpdatedView,
Guid? ProposalId,
DateTimeOffset? VotingDeadlineAt,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
string? Title,
DateTime CurrentScheduledAt,
int? BatchMessageId);
@@ -0,0 +1,156 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleVoteResult> HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
if (proposal is null)
{
return new HandleRescheduleVoteResult(
false,
"Голосование уже завершено или не найдено.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
return new HandleRescheduleVoteResult(
false,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new
{
proposal.SessionId,
Platform = command.User.Platform.ToString(),
ExternalUserId = command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (playerId is null)
{
return new HandleRescheduleVoteResult(
false,
"Вы не являетесь участником этой сессии.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_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.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
return new HandleRescheduleVoteResult(
true,
"Ваш голос учтён. До дедлайна его можно изменить.",
proposal.Id,
proposal.SessionId,
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
participants,
options,
votes);
}
}
@@ -0,0 +1,15 @@
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleVoteResult(
bool Success,
string? ReplyText,
Guid? ProposalId,
Guid? SessionId,
string? Title,
DateTime CurrentScheduledAt,
DateTimeOffset VotingDeadlineAt,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes);
@@ -78,8 +78,8 @@ public sealed class RescheduleVotingFinalizer(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
p.external_username AS TelegramUsername,
p.external_user_id::BIGINT AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
@@ -107,7 +107,7 @@ public sealed class RescheduleVotingFinalizer(
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
@@ -0,0 +1,23 @@
namespace GmRelay.Shared.Features.Showcase;
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
string? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter
{
Today,
Tomorrow,
ThisWeek,
All
}
public enum SeatFilter
{
Available,
Waitlist,
Any
}
@@ -0,0 +1,20 @@
namespace GmRelay.Shared.Features.Showcase;
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description);
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
@@ -20,6 +21,11 @@ public sealed class SessionSchedulerService(
ILogger<SessionSchedulerService> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan BackoffDuration = TimeSpan.FromMinutes(15);
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _confirmationBackoff = new();
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _oneHourBackoff = new();
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _joinLinkBackoff = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -71,14 +77,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds)
{
if (_confirmationBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping confirmation for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try
{
await confirmationHandler.HandleAsync(sessionId, ct);
_confirmationBackoff.TryRemove(sessionId, out _);
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
var nextAttempt = now.Add(BackoffDuration);
_confirmationBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to send confirmation for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
}
}
}
@@ -98,14 +120,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds)
{
if (_oneHourBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping one-hour reminder for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try
{
await oneHourReminderHandler.HandleAsync(sessionId, ct);
_oneHourBackoff.TryRemove(sessionId, out _);
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);
var nextAttempt = now.Add(BackoffDuration);
_oneHourBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to process one-hour reminder for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
}
}
}
@@ -125,14 +163,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds)
{
if (_joinLinkBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping join link for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try
{
await joinLinkHandler.HandleAsync(sessionId, ct);
_joinLinkBackoff.TryRemove(sessionId, out _);
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
var nextAttempt = now.Add(BackoffDuration);
_joinLinkBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to send join link for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
}
}
}
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
public interface IPlatformMessenger
{
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support messages with actions.");
Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message updates with actions.");
Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread creation.");
Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread deletion.");
Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message deletion.");
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support schedule messages.");
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
@@ -34,14 +34,31 @@
</svg>
Шаблоны
</NavLink>
<NavLink class="nav-item" href="profile" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</NavLink>
</div>
<div class="nav-footer">
<div class="nav-user">
<div class="nav-user-avatar">
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
@if (!string.IsNullOrWhiteSpace(context.User.GetAvatarUrl()))
{
<img src="@context.User.GetAvatarUrl()" alt="" />
}
else
{
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
}
</div>
<div class="nav-user-info">
<span class="nav-user-name">@context.User.Identity?.Name</span>
<span class="nav-user-platform">@GetPlatformLabel(context.User)</span>
</div>
<span class="nav-user-name">@context.User.Identity?.Name</span>
</div>
<form action="/auth/logout" method="post">
@@ -56,7 +73,7 @@
</button>
</form>
<div class="nav-version">v2.7.1</div>
<div class="nav-version">v3.4.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -79,4 +96,7 @@
private void ToggleMenu() => isOpen = !isOpen;
private void CloseMenu() => isOpen = false;
private static string GetPlatformLabel(System.Security.Claims.ClaimsPrincipal user) =>
user.TryGetDiscordId(out _) ? "Discord" : "Telegram";
}
@@ -0,0 +1,21 @@
@inherits LayoutComponentBase
<div class="public-shell">
<header class="public-topbar">
<a class="public-brand" href="/">
<img src="/logo.png" alt="GM-Relay" />
<span>GM-Relay</span>
</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
</header>
<main class="public-content">
@Body
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
Произошла непредвиденная ошибка.
<a href="." class="reload">Перезагрузить</a>
<span class="dismiss">×</span>
</div>
@@ -185,7 +185,7 @@
private Guid selectedGroupId;
private Guid? deletingTemplateId;
private bool isCreatingTemplate;
private long telegramId;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CampaignTemplateEditModel templateModel = new();
@@ -195,13 +195,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
groups = await SessionService.GetGroupsForCurrentUserAsync();
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
if (selectedGroupId != Guid.Empty)
@@ -228,7 +228,7 @@
campaignTemplates = null;
campaignTemplateModels = [];
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
var templates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(selectedGroupId);
if (templates is null)
{
Navigation.NavigateTo("/access-denied");
@@ -260,9 +260,8 @@
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
await SessionService.CreateCampaignTemplateForCurrentUserAsync(
selectedGroupId,
telegramId,
new CreateCampaignTemplateRequest(
templateModel.Name,
templateModel.Title,
@@ -298,7 +297,7 @@
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
await SessionService.DeleteCampaignTemplateForCurrentUserAsync(template.Id);
successMessage = "Шаблон кампании удалён.";
await LoadTemplates();
}
@@ -87,13 +87,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
@@ -114,7 +114,7 @@
try
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
@@ -122,7 +122,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
@@ -40,8 +40,8 @@
</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 type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == ManagerKey(manager))" @onclick="() => RemoveCoGm(manager)">
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
@@ -52,8 +52,8 @@
<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" />
<label class="gm-form-label">@CoGmIdLabel</label>
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
@@ -61,7 +61,7 @@
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
<InputText @bind-Value="coGmModel.ExternalUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@@ -72,6 +72,58 @@
</div>
}
@if (publicSettings is not null)
{
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Публичная страница клуба</h3>
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
</div>
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
</span>
</div>
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
<div class="batch-bulk-fields">
<div class="gm-form-group public-toggle-field">
<label class="gm-checkbox-label">
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
<span>Включить публичное расписание</span>
</label>
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
</div>
</div>
<div class="public-settings-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
</button>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
Открыть публичную страницу
</a>
}
</div>
</EditForm>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<div class="public-link-row">
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -201,6 +253,17 @@
</button>
</EditForm>
<div class="batch-publish-row">
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch)
</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
@(IsBatchPublishBusy(batch)
? "Обновляем..."
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
</button>
</div>
<div class="batch-clone-row">
<select @bind="batch.CloneInterval" class="gm-form-control">
<option value="week">Следующая неделя</option>
@@ -249,6 +312,16 @@
</td>
<td>
<div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
}
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
✏️ Изменить
</a>
@@ -337,6 +410,15 @@
</div>
</div>
<div class="session-card-actions">
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
}
<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>
@@ -398,17 +480,23 @@
private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private WebPublicGroupSettings? publicSettings;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private long? removingCoGmId;
private Guid? publishingBatchId;
private Guid? publishingSessionId;
private string? removingCoGmId;
private bool isAddingCoGm;
private long telegramId;
private bool savingPublicSettings;
private string? currentPlatform;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
private PublicSettingsEditModel publicSettingsModel = new();
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
@@ -417,32 +505,40 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
currentPlatform = platform;
await LoadSessions();
}
private async Task LoadSessions()
{
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
if (publicSettings is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
@@ -451,6 +547,92 @@
RebuildBatchModels();
RebuildCampaignTemplateModels();
RebuildPublicSettingsModel();
}
private async Task SavePublicSettings()
{
errorMessage = null;
successMessage = null;
savingPublicSettings = true;
try
{
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
GroupId,
publicSettingsModel.PublicSlug,
publicSettingsModel.PublicScheduleEnabled);
successMessage = "Настройки публичной страницы обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
}
finally
{
savingPublicSettings = false;
}
}
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
{
errorMessage = null;
successMessage = null;
publishingBatchId = batch.BatchId;
try
{
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
successMessage = isPublic
? "Batch опубликован в публичном расписании."
: "Batch скрыт из публичного расписания.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
}
finally
{
publishingBatchId = null;
}
}
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
{
errorMessage = null;
successMessage = null;
publishingSessionId = sessionId;
try
{
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
successMessage = isPublic
? "Сессия опубликована в публичном расписании."
: "Сессия скрыта из публичного расписания.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
}
finally
{
publishingSessionId = null;
}
}
private async Task AddCoGm()
@@ -458,9 +640,16 @@
errorMessage = null;
successMessage = null;
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
var coGmExternalUserId = coGmModel.ExternalUserId.Trim();
if (coGmExternalUserId.Length == 0)
{
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
errorMessage = $"{CoGmIdLabel} должен быть заполнен.";
return;
}
if (!IsValidPlatformUserId(CoGmPlatform, coGmExternalUserId))
{
errorMessage = $"{CoGmIdLabel} должен быть положительным числом.";
return;
}
@@ -470,10 +659,10 @@
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
CoGmPlatform,
coGmExternalUserId,
coGmModel.DisplayName,
coGmModel.TelegramUsername);
coGmModel.ExternalUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
@@ -493,15 +682,17 @@
}
}
private async Task RemoveCoGm(long coGmTelegramId)
private async Task RemoveCoGm(WebGroupManager manager)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
removingCoGmId = ManagerKey(manager);
var platform = ManagerPlatform(manager);
var coGmExternalUserId = ManagerExternalUserId(manager);
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
@@ -527,7 +718,7 @@
try
{
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -559,7 +750,7 @@
loadingParticipantsSessionId = sessionId;
try
{
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId);
participantsCache[sessionId] = participants ?? [];
}
catch (Exception ex)
@@ -582,7 +773,7 @@
try
{
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId);
participantsCache.Remove(sessionId);
successMessage = "Игрок исключён.";
await LoadSessions();
@@ -658,10 +849,9 @@
try
{
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForGmAsync(
await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForCurrentUserAsync(
batch.BatchId,
telegramId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены.";
await LoadSessions();
@@ -696,7 +886,7 @@
try
{
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays);
await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays);
successMessage = "Расписание пачки обновлено.";
await LoadSessions();
}
@@ -726,7 +916,7 @@
? BatchCloneInterval.NextMonth
: BatchCloneInterval.NextWeek;
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval);
var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval);
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
await LoadSessions();
}
@@ -759,7 +949,7 @@
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
@@ -796,7 +986,9 @@
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count
SessionCount = orderedSessions.Count,
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
};
})
.OrderBy(batch => batch.FirstScheduledAtLocal)
@@ -824,6 +1016,20 @@
.ToList() ?? [];
}
private void RebuildPublicSettingsModel()
{
if (publicSettings is null)
{
return;
}
publicSettingsModel = new PublicSettingsEditModel
{
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
PublicSlug = publicSettings.PublicSlug ?? ""
};
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
@@ -833,24 +1039,76 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string? PublicClubUrl =>
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
? null
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
private string PublicSessionUrl(Guid sessionId) =>
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
private static string FormatPublicationStatus(WebSession session) =>
session.IsPublic ? "Опубликована" : "Скрыта";
private static string GetPublicationStatusClass(WebSession session) =>
session.IsPublic ? "status-success" : "status-neutral";
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
batch.PublicSessionCount == 0
? "Все игры скрыты"
: batch.PublicSessionCount == batch.SessionCount
? "Все игры опубликованы"
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
private string CoGmPlatform =>
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
? "Telegram"
: groupManagement.Group.Platform;
private string CoGmIdLabel => $"{CoGmPlatform} ID co-GM";
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
groupManagement?.Managers.FirstOrDefault(manager =>
string.Equals(ManagerPlatform(manager), currentPlatform, StringComparison.OrdinalIgnoreCase) &&
ManagerExternalUserId(manager) == externalUserId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatManager(WebGroupManager manager)
private string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + manager.TelegramUsername;
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
? manager.TelegramUsername
: manager.ExternalUsername;
var identity = string.IsNullOrWhiteSpace(username)
? $"{ManagerPlatform(manager)} {ManagerExternalUserId(manager)}"
: "@" + username.TrimStart('@');
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {identity}";
}
private string ManagerPlatform(WebGroupManager manager) =>
string.IsNullOrWhiteSpace(manager.Platform) ? CoGmPlatform : manager.Platform;
private static string ManagerExternalUserId(WebGroupManager manager) =>
string.IsNullOrWhiteSpace(manager.ExternalUserId)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: manager.ExternalUserId;
private string ManagerKey(WebGroupManager manager) =>
$"{ManagerPlatform(manager)}:{ManagerExternalUserId(manager)}";
private static bool IsValidPlatformUserId(string platform, string externalUserId) =>
string.Equals(platform, "Telegram", StringComparison.OrdinalIgnoreCase)
? long.TryParse(externalUserId, out var telegramId) && telegramId > 0
: !string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ||
(ulong.TryParse(externalUserId, out var platformId) && platformId > 0);
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{
if (orderedSessions.Count < 2)
@@ -927,6 +1185,8 @@
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
public int IntervalDays { get; set; } = 7;
public int SessionCount { get; init; }
public int PublicSessionCount { get; init; }
public bool AllSessionsPublic { get; init; }
public string CloneInterval { get; set; } = "week";
}
@@ -945,8 +1205,14 @@
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
public string ExternalUserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public string? TelegramUsername { get; set; }
public string? ExternalUsername { get; set; }
}
private sealed class PublicSettingsEditModel
{
public bool PublicScheduleEnabled { get; set; }
public string? PublicSlug { get; set; }
}
}
@@ -5,7 +5,7 @@
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -85,9 +85,9 @@
<td>
<div class="player-info">
<span class="player-name">@s.DisplayName</span>
@if (!string.IsNullOrEmpty(s.TelegramUsername))
@if (!string.IsNullOrEmpty(s.ExternalUsername))
{
<span class="player-username">@@@s.TelegramUsername</span>
<span class="player-username">@@@s.ExternalUsername</span>
}
</div>
</td>
@@ -171,21 +171,20 @@
Navigation.NavigateTo("/login");
return;
}
var telegramIdClaim = user.FindFirst("telegram_id")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!long.TryParse(telegramIdClaim, out var telegramId))
if (!user.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/login");
return;
}
try
{
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId))
var groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new();
stats = await SessionService.GetGroupAttendanceStatsForCurrentUserAsync(GroupId) ?? new();
UpdateSortedStats();
}
catch (Exception ex)
+28 -6
View File
@@ -43,10 +43,15 @@
<div class="glass-card group-card">
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<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>
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
<div class="group-card-meta">
<span class="status-badge platform-badge">
@FormatPlatform(group.Platform)
</span>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatRole(group.ManagerRole)
</span>
</div>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры →
</a>
@@ -81,6 +86,20 @@
font-family: 'Courier New', monospace;
margin-bottom: 1rem;
}
.group-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.platform-badge {
background: rgba(88, 101, 242, 0.15);
color: #9ea8ff;
border-color: rgba(88, 101, 242, 0.35);
}
</style>
@code {
@@ -93,15 +112,18 @@
var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры";
if (!user.TryGetTelegramId(out var telegramId))
if (!user.TryGetPlatformIdentity(out _, out _))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
groups = await SessionService.GetGroupsForCurrentUserAsync();
}
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatPlatform(string? platform) =>
string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ? "Discord" : "Telegram";
}
+12 -1
View File
@@ -10,7 +10,7 @@
<div class="login-card">
<img src="logo.png" alt="GM-Relay" class="login-logo" />
<h1 class="login-title">GM-Relay</h1>
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
<p class="login-subtitle">Войдите для управления игровыми сессиями</p>
@if (Navigation.Uri.Contains("error=auth_failed"))
{
@@ -20,6 +20,17 @@
}
<div id="telegram-login-container"></div>
<div class="login-divider">
<span>или</span>
</div>
<a href="/auth/discord" class="login-btn login-btn-discord">
<svg class="login-btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.086 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.086 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Войти через Discord
</a>
</div>
</div>
@@ -0,0 +1,191 @@
@page "/profile"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.Extensions.Configuration
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject IConfiguration Configuration
@inject NavigationManager Navigation
<PageTitle>Профиль — GM-Relay</PageTitle>
<div class="profile-container">
<h1 class="page-title">Профиль</h1>
@if (identities is null)
{
<p class="loading-text">Загрузка...</p>
}
else if (identities.Count == 0)
{
<div class="profile-card">
<p>Связанные аккаунты не найдены.</p>
</div>
}
else
{
<div class="profile-card">
<h2 class="section-title">Связанные аккаунты</h2>
<ul class="identity-list">
@foreach (var id in identities)
{
<li class="identity-item">
<div class="identity-info">
<span class="identity-platform">@id.Platform</span>
<span class="identity-name">@id.DisplayName</span>
</div>
@if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId)
{
<button class="btn btn-secondary btn-small"
@onclick="() => Unlink(id.Platform, id.ExternalUserId)"
disabled="@isUnlinking">
Отвязать
</button>
}
else
{
<span class="identity-badge">Текущий</span>
}
</li>
}
</ul>
</div>
}
<div class="profile-card">
<h2 class="section-title">Добавить аккаунт</h2>
@if (!HasLinkedPlatform("Discord"))
{
<a href="/auth/discord" class="btn btn-primary">
Привязать Discord
</a>
}
else
{
<p class="muted-text">Discord уже привязан.</p>
}
@if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram"))
{
var botUsername = Configuration["Telegram__BotUsername"] ?? Configuration["Telegram:BotUsername"];
if (!string.IsNullOrWhiteSpace(botUsername))
{
var authUrl = new Uri(new Uri(Navigation.BaseUri), "auth/telegram").ToString();
var widgetHtml = $"<script async src=\"https://telegram.org/js/telegram-widget.js?22\" data-telegram-login=\"{botUsername}\" data-size=\"large\" data-auth-url=\"{authUrl}\" data-request-access=\"write\"></script>";
<div class="telegram-widget-wrapper">
@((MarkupString)widgetHtml)
</div>
}
}
</div>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="alert alert-error">@errorMessage</div>
}
@if (!string.IsNullOrWhiteSpace(successMessage))
{
<div class="alert alert-success">@successMessage</div>
}
</div>
@code {
private List<LinkedIdentity>? identities;
private string? currentPlatform;
private string? currentExternalUserId;
private bool isUnlinking;
private string? errorMessage;
private string? successMessage;
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
[SupplyParameterFromQuery]
public string? Linked { get; set; }
[SupplyParameterFromQuery(Name = "link_error")]
public string? LinkError { get; set; }
protected override async Task OnInitializedAsync()
{
if (AuthenticationStateTask is not null)
{
var authState = await AuthenticationStateTask;
var user = authState.User;
if (user.TryGetPlatformIdentity(out var plat, out var extId))
{
currentPlatform = plat;
currentExternalUserId = extId;
}
}
if (!string.IsNullOrWhiteSpace(Linked))
{
successMessage = $"{Linked} аккаунт успешно привязан!";
}
if (!string.IsNullOrWhiteSpace(LinkError))
{
errorMessage = $"Ошибка привязки: {Uri.UnescapeDataString(LinkError)}";
}
await LoadIdentities();
}
private async Task LoadIdentities()
{
try
{
if (currentPlatform is not null && currentExternalUserId is not null)
{
identities = await SessionStore.GetLinkedIdentitiesAsync(currentPlatform, currentExternalUserId);
}
else
{
identities = [];
}
}
catch (Exception ex)
{
errorMessage = $"Не удалось загрузить аккаунты: {ex.Message}";
}
}
private bool HasLinkedPlatform(string platform)
{
return identities?.Any(i => i.Platform == platform) ?? false;
}
private async Task Unlink(string platform, string externalUserId)
{
isUnlinking = true;
errorMessage = null;
successMessage = null;
try
{
if (currentPlatform is null || currentExternalUserId is null)
{
errorMessage = "Не удалось определить текущего пользователя.";
return;
}
await SessionStore.UnlinkIdentityAsync(currentPlatform, currentExternalUserId, platform, externalUserId);
successMessage = $"{platform} аккаунт отвязан.";
await LoadIdentities();
}
catch (InvalidOperationException ex)
{
errorMessage = $"Ошибка отвязки: {ex.Message}";
}
catch (Exception ex)
{
errorMessage = $"Ошибка отвязки: {ex.Message}";
}
finally
{
isUnlinking = false;
}
}
}
@@ -0,0 +1,121 @@
@page "/club/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && club is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Публичная страница не найдена</h1>
<p>Расписание клуба выключено или адрес больше не используется.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (club is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
</HeadContent>
<section class="public-hero">
<span class="status-badge status-success">Публичное расписание</span>
<h1>@club.Name</h1>
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
<div class="public-share-row">
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
</section>
@if (club.Sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Опубликованных игр пока нет</h2>
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
</div>
}
else
{
<div class="public-session-list">
@foreach (var session in club.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
}
@code {
[Parameter] public string? Slug { get; set; }
private WebPublicClub? club;
private bool loaded;
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
private string PublicClubUrl =>
club is null
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
protected override async Task OnParametersSetAsync()
{
loaded = false;
club = string.IsNullOrWhiteSpace(Slug)
? null
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
loaded = true;
}
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}
@@ -0,0 +1,235 @@
@page "/s/{SessionId:guid}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
@using GmRelay.Shared.Features.Showcase
@using GmRelay.Web.Services
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && session is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Сессия не опубликована</h1>
<p>Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (session is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
</HeadContent>
@if (!string.IsNullOrWhiteSpace(session.CoverImageUrl))
{
<div class="session-cover-hero" style="background-image: url('@session.CoverImageUrl')"></div>
}
<section class="public-hero public-hero-compact">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h1>@session.Title</h1>
<p>@session.GroupName</p>
<div class="session-badges">
@if (!string.IsNullOrWhiteSpace(session.System))
{
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
}
@if (session.IsOneShot)
{
<span class="status-badge status-warning">Ваншот</span>
}
@if (!string.IsNullOrWhiteSpace(session.Format))
{
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
}
</div>
</section>
<article class="glass-card public-session-detail">
<div class="public-detail-grid">
<div>
<span>Время</span>
<strong>@session.ScheduledAt.FormatMoscow()</strong>
</div>
<div>
<span>Места</span>
<strong>@FormatSeats(session)</strong>
</div>
<div>
<span>Статус</span>
<strong>@TranslateStatus(session.Status)</strong>
</div>
@if (session.DurationMinutes.HasValue)
{
<div>
<span>Длительность</span>
<strong>@FormatDuration(session.DurationMinutes.Value)</strong>
</div>
}
</div>
@if (!string.IsNullOrWhiteSpace(session.Description))
{
<div class="session-description">
<h3>Описание</h3>
<p>@session.Description</p>
</div>
}
@if (registrationResult is not null)
{
<div class="glass-card @GetRegistrationResultClass()">
<p>@registrationResult</p>
</div>
}
<div class="public-settings-actions">
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
{
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
}
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
@if (session.AllowDirectRegistration)
{
@if (isAuthenticated)
{
<button class="btn-gm btn-gm-primary" @onclick="RegisterAsync">Записаться</button>
}
else
{
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти, чтобы записаться</a>
}
}
</div>
</article>
}
@code {
[Parameter] public Guid SessionId { get; set; }
private ShowcaseSessionDto? session;
private bool loaded;
private bool isAuthenticated;
private string? registrationResult;
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
protected override async Task OnParametersSetAsync()
{
loaded = false;
registrationResult = null;
session = await SessionStore.GetShowcaseSessionAsync(SessionId);
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
var shouldRegister = query.TryGetValue("register", out var val) && val == "1";
if (session is not null && shouldRegister && session.AllowDirectRegistration)
{
if (isAuthenticated && authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
registrationResult = success
? "Вы успешно записались на игру!"
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
}
else if (!isAuthenticated)
{
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}")}");
return;
}
}
loaded = true;
}
private async Task RegisterAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
registrationResult = success
? "Вы успешно записались на игру!"
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
}
}
private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}?register=1")}";
private string GetRegistrationResultClass() => registrationResult?.StartsWith("Вы успешно") == true ? "status-success-bg" : "status-warning-bg";
private static string FormatSeats(ShowcaseSessionDto session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string FormatDuration(int minutes)
{
if (minutes < 60)
return $"{minutes} мин";
var hours = minutes / 60;
var mins = minutes % 60;
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
}
private static string GetSystemDisplayName(string? system)
{
if (string.IsNullOrWhiteSpace(system))
return system ?? string.Empty;
if (Enum.TryParse<GameSystem>(system, out var gs))
return gs.ToDisplayName();
return system;
}
private static string TranslateFormat(string format) => format switch
{
"Online" => "Онлайн",
"Offline" => "Офлайн",
"Hybrid" => "Гибрид",
_ => format
};
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}
@@ -56,7 +56,7 @@
{
<tr>
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
<td>@entry.ActorName (@entry.ActorTelegramId)</td>
<td>@entry.ActorName (@entry.ActorExternalUserId)</td>
<td>
<span class="status-badge @(GetBadgeClass(entry.ChangeType))">
@GetChangeTypeLabel(entry.ChangeType)
@@ -82,13 +82,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
@@ -97,7 +97,7 @@
sessionTitle = session.Title;
groupId = session.GroupId;
entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId);
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
}
private string GetChangeTypeLabel(string changeType) => changeType switch
@@ -0,0 +1,438 @@
@page "/showcase"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
@using GmRelay.Shared.Features.Showcase
<PageTitle>Каталог игр — GM-Relay</PageTitle>
<HeadContent>
<meta name="description" content="Каталог настольных ролевых игр GM-Relay. Найдите игру по душе — ваншоты, кампании, онлайн и офлайн." />
</HeadContent>
<section class="public-hero">
<h1>Каталог игр</h1>
<p>Найдите настольную ролевую игру по душе — ваншоты, кампании, онлайн и офлайн.</p>
</section>
<section class="glass-card showcase-filters">
<div class="showcase-filter-group">
<span class="showcase-filter-label">Когда</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Date == DateFilter.Today ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Today)">Сегодня</button>
<button class="btn-gm @(filter.Date == DateFilter.Tomorrow ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Tomorrow)">Завтра</button>
<button class="btn-gm @(filter.Date == DateFilter.ThisWeek ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.ThisWeek)">На этой неделе</button>
<button class="btn-gm @(filter.Date == DateFilter.All ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.All)">Все</button>
</div>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Места</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Seats == SeatFilter.Available ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Available)">Есть места</button>
<button class="btn-gm @(filter.Seats == SeatFilter.Waitlist ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Waitlist)">Лист ожидания</button>
<button class="btn-gm @(filter.Seats == SeatFilter.Any ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Any)">Любые</button>
</div>
</div>
<div class="showcase-filter-group">
<label class="showcase-filter-label" for="system-filter">Система</label>
<select id="system-filter" class="gm-form-control showcase-filter-select" aria-label="Система" @onchange="OnSystemChanged">
<option value="" selected="@(filter.System is null)">Любая</option>
@foreach (var system in Enum.GetValues<GameSystem>())
{
var name = system.ToString();
<option value="@name" selected="@(filter.System == name)">@system.ToDisplayName()</option>
}
</select>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Тип</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.IsOneShot == true ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(true)">Ваншот</button>
<button class="btn-gm @(filter.IsOneShot == false ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(false)">Кампания</button>
<button class="btn-gm @(filter.IsOneShot is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(null)">Любое</button>
</div>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Формат</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Format == "Online" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Online"))">Онлайн</button>
<button class="btn-gm @(filter.Format == "Offline" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Offline"))">Офлайн</button>
<button class="btn-gm @(filter.Format == "Hybrid" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Hybrid"))">Гибрид</button>
<button class="btn-gm @(filter.Format is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat((string?)null))">Любой</button>
</div>
</div>
</section>
@if (loading && sessions.Count == 0)
{
<div class="showcase-grid">
@for (var i = 0; i < 6; i++)
{
<div class="glass-card showcase-card showcase-skeleton">
<div class="skeleton showcase-skeleton-image"></div>
<div class="showcase-card-body">
<div class="skeleton skeleton-text" style="width: 70%;"></div>
<div class="skeleton skeleton-text" style="width: 45%;"></div>
<div class="skeleton skeleton-text" style="width: 55%;"></div>
</div>
</div>
}
</div>
}
else if (!loading && sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Игры не найдены</h2>
<p>Попробуйте изменить фильтры или загляните позже — новые сессии появляются каждый день.</p>
</div>
}
else
{
<div class="showcase-grid">
@foreach (var session in sessions)
{
<article class="glass-card showcase-card animate-fade-in">
<div class="showcase-card-image"
style="@(string.IsNullOrWhiteSpace(session.CoverImageUrl)
? $"background: {GetGradientStyle(session.Id)}; background-size: cover; background-position: center;"
: $"background-image: url({session.CoverImageUrl}); background-size: cover; background-position: center;")">
</div>
<div class="showcase-card-body">
<div class="showcase-card-badges">
@if (!string.IsNullOrWhiteSpace(session.System))
{
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
}
@if (session.IsOneShot)
{
<span class="status-badge status-warning">Ваншот</span>
}
@if (!string.IsNullOrWhiteSpace(session.Format))
{
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
}
</div>
<h2 class="showcase-card-title">@session.Title</h2>
<div class="showcase-card-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
@if (session.DurationMinutes.HasValue)
{
<span>@FormatDuration(session.DurationMinutes.Value)</span>
}
</div>
<div class="showcase-card-seats">
<span>@FormatSeats(session)</span>
</div>
<div class="showcase-card-club">
<span>@session.GroupName</span>
</div>
<div class="showcase-card-actions">
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
@if (session.AllowDirectRegistration)
{
<a class="btn-gm btn-gm-primary" href="@($"/s/{session.Id}?register=1")">Записаться</a>
}
</div>
</div>
</article>
}
</div>
@if (hasMore)
{
<div class="showcase-load-more">
<button class="btn-gm btn-gm-primary" @onclick="LoadMoreAsync" disabled="@loading">
@if (loading)
{
<span>Загрузка...</span>
}
else
{
<span>Загрузить ещё</span>
}
</button>
</div>
}
}
<style>
.showcase-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem 1.5rem;
margin-bottom: 1.5rem;
padding: 1.25rem;
}
.showcase-filter-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.showcase-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: 'Jura', sans-serif;
}
.showcase-filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.showcase-filter-buttons .btn-gm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.showcase-filter-select {
min-width: 180px;
width: auto;
}
.showcase-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
@@media (min-width: 640px) {
.showcase-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@media (min-width: 1024px) {
.showcase-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.showcase-card {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.showcase-card-image {
height: 160px;
background-size: cover;
background-position: center;
}
.showcase-card-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.showcase-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.showcase-card-title {
font-size: 1.0625rem;
margin: 0;
font-family: 'Cinzel', serif;
overflow-wrap: anywhere;
}
.showcase-card-meta,
.showcase-card-seats,
.showcase-card-club {
font-size: 0.8125rem;
color: var(--text-secondary);
font-family: 'Jura', sans-serif;
}
.showcase-card-club {
color: var(--text-muted);
}
.showcase-card-actions {
margin-top: auto;
padding-top: 0.75rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.showcase-card-actions .btn-gm {
flex: 1;
justify-content: center;
}
.showcase-load-more {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.showcase-skeleton {
padding: 0;
overflow: hidden;
}
.showcase-skeleton-image {
height: 160px;
border-radius: 0;
}
.showcase-skeleton .showcase-card-body {
padding: 1rem;
}
.showcase-skeleton .skeleton-text {
margin-bottom: 0.5rem;
}
</style>
@code {
private ShowcaseFilter filter = new();
private List<ShowcaseSessionDto> sessions = new();
private bool loading;
private bool hasMore;
private int page = 1;
private const int PageSize = 12;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
page = 1;
sessions.Clear();
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
sessions.AddRange(results);
hasMore = results.Count == PageSize;
}
finally
{
loading = false;
}
}
private async Task LoadMoreAsync()
{
if (loading)
return;
loading = true;
try
{
page++;
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
sessions.AddRange(results);
hasMore = results.Count == PageSize;
}
finally
{
loading = false;
}
}
private async Task OnFilterChanged()
{
await LoadAsync();
}
private async Task SetDate(DateFilter value)
{
filter = filter with { Date = value };
await OnFilterChanged();
}
private async Task SetSeats(SeatFilter value)
{
filter = filter with { Seats = value };
await OnFilterChanged();
}
private async Task OnSystemChanged(ChangeEventArgs e)
{
var value = e.Value?.ToString();
filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value };
await OnFilterChanged();
}
private async Task SetOneShot(bool? value)
{
filter = filter with { IsOneShot = value };
await OnFilterChanged();
}
private async Task SetFormat(string? value)
{
filter = filter with { Format = value };
await OnFilterChanged();
}
private static string GetGradientStyle(Guid id)
{
var bytes = id.ToByteArray();
var hue1 = bytes[0] % 360;
var hue2 = (bytes[1] + 120) % 360;
return $"linear-gradient(135deg, hsl({hue1}, 55%, 28%) 0%, hsl({hue2}, 55%, 20%) 100%)";
}
private static string GetSystemDisplayName(string? system)
{
if (string.IsNullOrWhiteSpace(system))
return system ?? string.Empty;
if (Enum.TryParse<GameSystem>(system, out var gs))
return gs.ToDisplayName();
return system;
}
private static string FormatSeats(ShowcaseSessionDto session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
if (session.WaitlistedPlayerCount > 0)
seats += $", ожидание {session.WaitlistedPlayerCount}";
return seats;
}
private static string FormatDuration(int minutes)
{
if (minutes < 60)
return $"{minutes} мин";
var hours = minutes / 60;
var mins = minutes % 60;
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
}
private static string TranslateFormat(string format) => format switch
{
"Online" => "Онлайн",
"Offline" => "Офлайн",
"Hybrid" => "Гибрид",
_ => format
};
}

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