Compare commits

...

42 Commits

Author SHA1 Message Date
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
65 changed files with 2069 additions and 462 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 2.8.0
VERSION: 3.1.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>2.8.0</Version>
<Version>3.1.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+5 -3
View File
@@ -85,8 +85,10 @@ TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Токен Discord application bot
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
# Client ID Discord application (используется для slash-команд)
DISCORD_BOT_CLIENT_ID=ваш_discord_client_id_здесь
# 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=ваше_имя_бота_здесь
@@ -119,7 +121,7 @@ docker compose up -d
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` и `DISCORD_BOT_CLIENT_ID` в `.env`.
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 и восстановление
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.1.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.1.0
restart: always
depends_on:
db:
@@ -84,7 +84,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.1.0
restart: always
depends_on:
db:
@@ -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
@@ -77,16 +77,15 @@ public sealed class CreateSessionHandler(
{
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
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Telegram', @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,
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);
external_username = EXCLUDED.external_username;
""",
new { TgId = gmId, Name = gmName, Username = gmUsername },
new { ExternalId = gmId.ToString(), Name = gmName, Username = gmUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
@@ -97,12 +96,14 @@ public sealed class CreateSessionHandler(
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
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalGmId
) AS CanManage
FROM game_groups g
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
""",
new { ChatId = chatId, GmId = gmId },
new { ExternalChatId = chatId.ToString(), ExternalGmId = gmId.ToString() },
transaction);
Guid groupId;
@@ -110,11 +111,11 @@ public sealed class CreateSessionHandler(
{
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)
INSERT INTO game_groups (name, platform, external_group_id)
VALUES (@ChatName, 'Telegram', @ExternalChatId)
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
new { ExternalChatId = chatId.ToString(), ChatName = chatTitle },
transaction);
await connection.ExecuteAsync(
@@ -122,10 +123,11 @@ public sealed class CreateSessionHandler(
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
WHERE p.platform = 'Telegram'
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
new { GroupId = groupId, ExternalGmId = gmId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
@@ -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
@@ -24,11 +24,12 @@ public sealed class ExportCalendarHandler(
@"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"
+ " WHERE g.platform = 'Telegram'"
+ " AND g.external_group_id = @ExternalChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
new { ExternalChatId = message.Chat.Id.ToString(), Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
@@ -75,13 +76,13 @@ public sealed class ExportCalendarHandler(
{
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 });
@"SELECT id FROM game_groups WHERE platform = 'Telegram' AND external_group_id = @ExternalChatId",
new { ExternalChatId = message.Chat.Id.ToString() });
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 });
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, 'Telegram', @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userExternalId = senderId.Value.ToString(), groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
@@ -44,12 +44,13 @@ public sealed class DeleteSessionHandler(
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)
{
@@ -109,18 +110,22 @@ public sealed class DeleteSessionHandler(
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
AND manager_player.platform = 'Telegram'
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.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
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,
ExternalChatId = command.ChatId.ToString(),
ExternalUserId = command.TelegramUserId.ToString(),
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -74,18 +74,22 @@ public sealed class ListSessionsHandler(
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
AND manager_player.platform = 'Telegram'
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.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
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,
ExternalChatId = message.Chat.Id.ToString(),
ExternalUserId = message.From?.Id.ToString(),
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -53,26 +53,28 @@ public sealed class HandleRescheduleTimeInputHandler(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT 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
WHERE rp.proposed_by_external_user_id = @ExternalGmId
AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId
AND g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
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
AND manager_player.platform = 'Telegram'
AND manager_player.external_user_id = @ExternalGmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { GmId = gmTelegramId, ChatId = chatId });
new { ExternalGmId = gmTelegramId.ToString(), ExternalChatId = chatId.ToString() });
if (proposal is null)
return false;
@@ -92,8 +94,8 @@ public sealed class HandleRescheduleTimeInputHandler(
"""
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
@@ -363,7 +365,7 @@ public sealed class HandleRescheduleTimeInputHandler(
"""
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
@@ -58,11 +58,12 @@ public sealed class HandleRescheduleVoteHandler(
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
new { proposal.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
@@ -91,8 +92,8 @@ public sealed class HandleRescheduleVoteHandler(
"""
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
@@ -120,7 +121,7 @@ public sealed class HandleRescheduleVoteHandler(
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
@@ -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
@@ -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,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,6 +66,9 @@ 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)
@@ -75,6 +84,7 @@ public sealed class DiscordNewSessionHandler(
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var transactionCommitted = false;
try
{
await connection.ExecuteAsync(
@@ -89,13 +99,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 +135,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;
}
}
@@ -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" />
@@ -98,17 +98,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 +152,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 +306,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)
+7 -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,6 +56,7 @@ 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<DiscordRescheduleVoteHandler>();
@@ -82,12 +87,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, )",
@@ -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
@@ -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);
}
}
@@ -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
@@ -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);
}
}
}
@@ -34,6 +34,13 @@
</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">
@@ -66,7 +73,7 @@
</button>
</form>
<div class="nav-version">v2.8.0</div>
<div class="nav-version">v3.1.0</div>
</div>
</Authorized>
<NotAuthorized>
+25 -3
View File
@@ -44,9 +44,14 @@
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</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>
<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 {
@@ -104,4 +123,7 @@
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatPlatform(string? platform) =>
string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ? "Discord" : "Telegram";
}
@@ -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;
}
}
}
+86 -25
View File
@@ -39,6 +39,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<DiscordOAuthStateStore>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
@@ -60,7 +61,7 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
options.AccessDeniedPath = "/access-denied";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
});
@@ -122,19 +123,39 @@ app.MapHealthChecks("/alive", new HealthCheckOptions
});
// Endpoint to handle Telegram Login callback
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService) =>
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService, ISessionStore sessionStore) =>
{
if (authService.Verify(context.Request.Query, out var telegramId, out var name))
if (!authService.Verify(context.Request.Query, out var telegramId, out var name))
return Results.Redirect("/login?error=auth_failed");
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
// If already authenticated via another platform, link instead of replacing session
if (context.User.Identity?.IsAuthenticated == true
&& context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId)
&& currentPlatform != "Telegram")
{
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Redirect("/");
try
{
// Always make Telegram the primary (it has the historical data/groups)
await sessionStore.LinkIdentityAsync(
"Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
currentPlatform, currentExternalUserId,
name);
return Results.Redirect("/profile?linked=telegram");
}
catch (InvalidOperationException ex)
{
return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}");
}
}
return Results.Redirect("/login?error=auth_failed");
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Redirect("/");
});
app.MapPost("/auth/telegram-webapp", async (
@@ -185,16 +206,9 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
});
// Discord OAuth endpoints
app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) =>
app.MapGet("/auth/discord", (DiscordAuthService discordAuth, DiscordOAuthStateStore stateStore) =>
{
var state = Guid.NewGuid().ToString("N");
context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromMinutes(5)
});
var state = stateStore.CreateState();
var url = discordAuth.BuildAuthorizeUrl(state);
return Results.Redirect(url);
});
@@ -202,19 +216,15 @@ app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth
app.MapGet("/auth/discord/callback", async (
HttpContext context,
DiscordAuthService discordAuth,
DiscordOAuthStateStore stateStore,
ISessionStore sessionStore) =>
{
var code = context.Request.Query["code"].ToString();
var state = context.Request.Query["state"].ToString();
var storedState = context.Request.Cookies["__DiscordOAuthState"];
context.Response.Cookies.Delete("__DiscordOAuthState");
if (string.IsNullOrWhiteSpace(code) ||
string.IsNullOrWhiteSpace(state) ||
!CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(state),
System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty)))
!stateStore.ValidateAndRemove(state))
{
return Results.Redirect("/login?error=auth_failed");
}
@@ -225,6 +235,25 @@ app.MapGet("/auth/discord/callback", async (
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
// If already authenticated via another platform, link instead of replacing session
if (context.User.Identity?.IsAuthenticated == true
&& context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId)
&& currentPlatform != "Discord")
{
try
{
await sessionStore.LinkIdentityAsync(
currentPlatform, currentExternalUserId,
"Discord", user.Id,
user.DisplayName);
return Results.Redirect("/profile?linked=discord");
}
catch (InvalidOperationException ex)
{
return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}");
}
}
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
@@ -234,6 +263,38 @@ app.MapGet("/auth/discord/callback", async (
return Results.Redirect("/");
});
// Identity linking API endpoints
app.MapGet("/api/me/identities", async (
HttpContext context,
ISessionStore sessionStore) =>
{
if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
return Results.Unauthorized();
var identities = await sessionStore.GetLinkedIdentitiesAsync(platform, externalUserId);
return Results.Ok(identities);
}).RequireAuthorization();
app.MapDelete("/api/me/identities/{targetPlatform}/{targetExternalUserId}", async (
HttpContext context,
ISessionStore sessionStore,
string targetPlatform,
string targetExternalUserId) =>
{
if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
return Results.Unauthorized();
try
{
await sessionStore.UnlinkIdentityAsync(platform, externalUserId, targetPlatform, targetExternalUserId);
return Results.NoContent();
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).RequireAuthorization();
// Public calendar subscription endpoint (no auth required)
app.MapGet("/calendar/{token}.ics", async (
string token,
@@ -12,7 +12,8 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
public string GenerateToken() => Guid.NewGuid().ToString("N");
public async Task<string> CreateSubscriptionAsync(
long userTelegramId,
string userPlatform,
string userExternalId,
Guid? groupId,
CalendarSubscriptionFilter filter,
CancellationToken ct = default)
@@ -20,9 +21,9 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
var token = GenerateToken();
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId, groupId, filterType = (int)filter });
@"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, userExternalId, groupId, filterType = (int)filter });
return token;
}
@@ -31,7 +32,7 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
await using var connection = await dataSource.OpenConnectionAsync(ct);
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType
@"SELECT id, group_id as GroupId, filter_type as FilterType
FROM calendar_subscriptions
WHERE token = @token
AND (expires_at IS NULL OR expires_at > now())",
@@ -88,6 +89,6 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
.Replace("\n", "\\n")
.Replace("\r", "");
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType);
private sealed record SubscriptionRecord(Guid Id, Guid? GroupId, int FilterType);
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
}
@@ -5,7 +5,7 @@ using System.Text.Json.Serialization;
namespace GmRelay.Web.Services;
public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration)
public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<DiscordAuthService> logger)
{
private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get<DiscordOAuthOptions>() ?? new DiscordOAuthOptions();
@@ -40,10 +40,14 @@ public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, ICo
});
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
logger.LogError("Discord token exchange failed: {StatusCode} {Body}. client_id={ClientId}, redirect_uri={RedirectUri}",
(int)response.StatusCode, json, _options.ClientId, _options.RedirectUri);
return null;
}
return JsonSerializer.Deserialize<DiscordTokenResponse>(json);
}
@@ -0,0 +1,32 @@
namespace GmRelay.Web.Services;
public sealed class DiscordOAuthStateStore(ILogger<DiscordOAuthStateStore> logger)
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _states = new();
public string CreateState()
{
var state = Guid.NewGuid().ToString("N");
_states[state] = DateTime.UtcNow.AddMinutes(5);
logger.LogDebug("Discord OAuth state created: {State}", state);
return state;
}
public bool ValidateAndRemove(string state)
{
if (!_states.TryRemove(state, out var expiresAt))
{
logger.LogWarning("Discord OAuth state not found or already used: {State}", state);
return false;
}
if (DateTime.UtcNow > expiresAt)
{
logger.LogWarning("Discord OAuth state expired: {State}", state);
return false;
}
logger.LogDebug("Discord OAuth state validated: {State}", state);
return true;
}
}
+15
View File
@@ -53,4 +53,19 @@ public interface ISessionStore
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
// --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId);
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl);
}
public sealed record LinkedIdentity(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername,
string? AvatarUrl,
DateTime LinkedAt);
+371 -103
View File
@@ -104,22 +104,45 @@ public sealed class SessionService(
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (playerIds.Length == 0)
return [];
return (await conn.QueryAsync<WebGameGroup>(
"""
WITH visible_groups AS (
SELECT gm.group_id,
CASE
WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole
ELSE @CoGmRole
END AS ManagerRole
FROM group_managers gm
WHERE gm.player_id = ANY(@PlayerIds)
GROUP BY gm.group_id
)
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.platform AS Platform,
gm.role AS ManagerRole
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalUserId
vg.ManagerRole
FROM visible_groups vg
JOIN game_groups g ON g.id = vg.group_id
LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
ORDER BY g.name
""",
new { Platform = platform, ExternalUserId = externalUserId })).ToList();
new
{
PlayerIds = playerIds,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
})).ToList();
}
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
@@ -128,12 +151,19 @@ public sealed class SessionService(
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.platform AS Platform,
@OwnerRole AS ManagerRole
FROM game_groups g
LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
WHERE g.id = @GroupId
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
@@ -142,36 +172,40 @@ public sealed class SessionService(
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (playerIds.Length == 0)
return false;
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
FROM group_managers
WHERE group_id = @GroupId
AND player_id = ANY(@PlayerIds)
)
""",
new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId });
new { GroupId = groupId, PlayerIds = playerIds });
}
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (playerIds.Length == 0)
return false;
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @OwnerRole
FROM group_managers
WHERE group_id = @GroupId
AND player_id = ANY(@PlayerIds)
AND role = @OwnerRole
)
""",
new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
@@ -179,11 +213,11 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>(
"""
SELECT COALESCE(p.telegram_id, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS TelegramUsername,
p.external_username AS ExternalUsername,
gm.role AS Role,
gm.created_at AS AddedAt
FROM group_managers gm
@@ -204,7 +238,7 @@ public sealed class SessionService(
SELECT
p.id AS PlayerId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS ExternalUsername,
COUNT(DISTINCT s.id) AS TotalSessions,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
@@ -223,7 +257,7 @@ public sealed class SessionService(
WHERE s.group_id = @GroupId
AND s.scheduled_at <= now()
AND sp.is_gm = false
GROUP BY p.id, p.display_name, p.external_username, p.telegram_username
GROUP BY p.id, p.display_name, p.external_username
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
""",
new { GroupId = groupId })).ToList();
@@ -255,22 +289,6 @@ public sealed class SessionService(
return entries.ToList();
}
public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@DisplayName, 'Discord', @DiscordId, @DisplayName)
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 { DisplayName = displayName, DiscordId = discordId });
}
public async Task AddGroupCoGmAsync(
Guid groupId,
string ownerPlatform, string ownerExternalUserId,
@@ -280,35 +298,16 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@DisplayName, @ExternalUsername, @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,
external_username = EXCLUDED.external_username
""",
new
{
DisplayName = displayName,
ExternalUsername = externalUsername,
Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId
},
transaction);
var ownerPlayerId = await _ResolveEffectivePlayerIdAsync(conn, ownerPlatform, ownerExternalUserId);
if (ownerPlayerId is null)
throw new InvalidOperationException("Owner player not found.");
var coGmPlayerId = await _UpsertPlayerAndGetIdAsync(conn, coGmPlatform, coGmExternalUserId, displayName, externalUsername, transaction);
await conn.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
SELECT @GroupId,
co_gm.id,
@CoGmRole,
owner_player.id
FROM players co_gm
LEFT JOIN players owner_player ON owner_player.platform = @OwnerPlatform AND owner_player.external_user_id = @OwnerExternalUserId
WHERE co_gm.platform = @CoGmPlatform AND co_gm.external_user_id = @CoGmExternalUserId
VALUES (@GroupId, @CoGmPlayerId, @CoGmRole, @OwnerPlayerId)
ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role
@@ -319,10 +318,8 @@ public sealed class SessionService(
new
{
GroupId = groupId,
OwnerPlatform = ownerPlatform,
OwnerExternalUserId = ownerExternalUserId,
CoGmPlatform = coGmPlatform,
CoGmExternalUserId = coGmExternalUserId,
OwnerPlayerId = ownerPlayerId.Value,
CoGmPlayerId = coGmPlayerId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
},
@@ -334,21 +331,21 @@ public sealed class SessionService(
public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var coGmPlayerId = await _ResolveEffectivePlayerIdAsync(conn, coGmPlatform, coGmExternalUserId);
if (coGmPlayerId is null)
return;
await conn.ExecuteAsync(
"""
DELETE FROM group_managers gm
USING players p
WHERE gm.player_id = p.id
AND gm.group_id = @GroupId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @CoGmRole
DELETE FROM group_managers
WHERE group_id = @GroupId
AND player_id = @PlayerId
AND role = @CoGmRole
""",
new
{
GroupId = groupId,
Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId,
PlayerId = coGmPlayerId.Value,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
});
}
@@ -359,7 +356,7 @@ public sealed class SessionService(
return (await conn.QueryAsync<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,
g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
@@ -397,7 +394,7 @@ public sealed class SessionService(
return 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,
g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
@@ -456,7 +453,7 @@ public sealed class SessionService(
var oldSession = 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,
g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
@@ -542,7 +539,7 @@ public sealed class SessionService(
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,
g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
@@ -641,11 +638,11 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
COALESCE(p.telegram_id, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS TelegramUsername,
p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
@@ -668,7 +665,7 @@ public sealed class SessionService(
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,
g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
@@ -689,9 +686,9 @@ public sealed class SessionService(
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
@@ -874,7 +871,7 @@ public sealed class SessionService(
s.status AS Status,
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode
@@ -958,7 +955,7 @@ public sealed class SessionService(
s.status AS Status,
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode
@@ -1180,7 +1177,7 @@ public sealed class SessionService(
}
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
"SELECT external_group_id::BIGINT AS TelegramChatId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId },
transaction);
@@ -1251,7 +1248,7 @@ public sealed class SessionService(
{
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
"""
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 p.id = sp.player_id
@@ -1268,7 +1265,7 @@ public sealed class SessionService(
{
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
"""
SELECT DISTINCT p.telegram_id AS TelegramId,
SELECT DISTINCT p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
@@ -1321,7 +1318,7 @@ public sealed class SessionService(
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@"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
@@ -1358,7 +1355,7 @@ public sealed class SessionService(
s.group_id AS GroupId,
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT AS TelegramChatId,
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
@@ -1366,9 +1363,280 @@ public sealed class SessionService(
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
AND s.group_id = @GroupId
GROUP BY s.batch_id, s.group_id, g.telegram_chat_id
GROUP BY s.batch_id, s.group_id, g.external_group_id
""",
new { BatchId = batchId, GroupId = groupId },
transaction);
}
// --- Identity linking (issue #35) ---
public async Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
}
public async Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
return [];
return (await conn.QueryAsync<LinkedIdentity>(
"""
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
p.external_username AS ExternalUsername,
p.avatar_url AS AvatarUrl,
COALESCE(pl.linked_at, p.created_at) AS LinkedAt
FROM players p
LEFT JOIN player_links pl ON pl.secondary_player_id = p.id
WHERE pl.primary_player_id = @EffectiveId
OR p.id = @EffectiveId
ORDER BY CASE WHEN p.id = @EffectiveId THEN 0 ELSE 1 END,
p.platform
""",
new { EffectiveId = effectiveId.Value })).ToList();
}
public async Task LinkIdentityAsync(
string currentPlatform, string currentExternalUserId,
string targetPlatform, string targetExternalUserId,
string? currentName)
{
if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId)
throw new InvalidOperationException("Cannot link an identity to itself.");
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
// Resolve current player (must exist — they are logged in)
var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
if (currentPlayerId is null)
throw new InvalidOperationException("Current player not found.");
// Upsert target player so it exists
var targetDisplayName = currentName ?? $"{targetPlatform} {targetExternalUserId}";
var targetPlayerId = await _UpsertPlayerAndGetIdAsync(conn, targetPlatform, targetExternalUserId, targetDisplayName, null, transaction);
// Check if target is already a primary of another link chain (conflict)
var targetIsPrimary = await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1 FROM player_links WHERE primary_player_id = @TargetPlayerId
)
""",
new { TargetPlayerId = targetPlayerId }, transaction);
if (targetIsPrimary)
{
await _LogIdentityAuditAsync(conn, currentPlayerId.Value, "link_attempt_conflict",
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
await transaction.CommitAsync();
throw new InvalidOperationException("Target identity is already the primary account of another linked set.");
}
// Check if current is already a secondary (then their primary becomes the effective primary)
var currentPrimaryId = await conn.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT primary_player_id
FROM player_links
WHERE secondary_player_id = @CurrentPlayerId
""",
new { CurrentPlayerId = currentPlayerId.Value }, transaction);
var effectiveCurrentPrimary = currentPrimaryId ?? currentPlayerId.Value;
// Check if target is already linked to someone else as secondary
var existingLink = await conn.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT primary_player_id
FROM player_links
WHERE secondary_player_id = @TargetPlayerId
""",
new { TargetPlayerId = targetPlayerId }, transaction);
if (existingLink is not null && existingLink.Value != effectiveCurrentPrimary)
{
await _LogIdentityAuditAsync(conn, effectiveCurrentPrimary, "link_attempt_conflict",
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
await transaction.CommitAsync();
throw new InvalidOperationException("Target identity is already linked to another account.");
}
var effectivePrimary = currentPrimaryId ?? currentPlayerId.Value;
// Check if already linked
var alreadyLinked = await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1 FROM player_links
WHERE primary_player_id = @EffectivePrimary AND secondary_player_id = @TargetPlayerId
)
""",
new { EffectivePrimary = effectivePrimary, TargetPlayerId = targetPlayerId }, transaction);
if (alreadyLinked)
{
await transaction.CommitAsync();
return; // Already linked, idempotent
}
await conn.ExecuteAsync(
"""
INSERT INTO player_links (primary_player_id, secondary_player_id, linked_by_player_id)
VALUES (@PrimaryPlayerId, @SecondaryPlayerId, @LinkedByPlayerId)
""",
new
{
PrimaryPlayerId = effectivePrimary,
SecondaryPlayerId = targetPlayerId,
LinkedByPlayerId = currentPlayerId.Value
},
transaction);
await _LogIdentityAuditAsync(conn, effectivePrimary, "link",
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
await transaction.CommitAsync();
}
public async Task UnlinkIdentityAsync(
string currentPlatform, string currentExternalUserId,
string targetPlatform, string targetExternalUserId)
{
if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId)
throw new InvalidOperationException("Cannot unlink your own primary identity from itself.");
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
if (currentPlayerId is null)
throw new InvalidOperationException("Current player not found.");
var targetPlayerId = await _ResolvePlayerIdAsync(conn, targetPlatform, targetExternalUserId);
if (targetPlayerId is null)
throw new InvalidOperationException("Target identity not found.");
var effectivePrimary = await _ResolveEffectivePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
if (effectivePrimary is null)
throw new InvalidOperationException("Effective primary not found.");
// Only the primary account owner (or the linked identity itself) can unlink
var rows = await conn.ExecuteAsync(
"""
DELETE FROM player_links
WHERE primary_player_id = @EffectivePrimary
AND secondary_player_id = @TargetPlayerId
""",
new { EffectivePrimary = effectivePrimary.Value, TargetPlayerId = targetPlayerId.Value },
transaction);
if (rows == 0)
{
await transaction.RollbackAsync();
throw new InvalidOperationException("Identity is not linked to your account.");
}
await _LogIdentityAuditAsync(conn, effectivePrimary.Value, "unlink",
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
await transaction.CommitAsync();
}
public async Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl)
{
await using var conn = await dataSource.OpenConnectionAsync();
await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, avatarUrl, null);
}
public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl)
{
await using var conn = await dataSource.OpenConnectionAsync();
await _UpsertPlayerAndGetIdAsync(conn, "Discord", discordId, displayName, avatarUrl, null);
}
// --- Private helpers ---
private static async Task<Guid?> _ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId)
{
return await conn.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT id FROM players
WHERE platform = @Platform AND external_user_id = @ExternalUserId
""",
new { Platform = platform, ExternalUserId = externalUserId });
}
private static async Task<Guid?> _ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId)
{
var playerId = await _ResolvePlayerIdAsync(conn, platform, externalUserId);
if (playerId is null)
return null;
var primaryId = await conn.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT primary_player_id FROM player_links
WHERE secondary_player_id = @PlayerId
""",
new { PlayerId = playerId.Value });
return primaryId ?? playerId;
}
private static async Task<Guid[]> _ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
{
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
return [];
return (await conn.QueryAsync<Guid>(
"""
SELECT @EffectiveId
UNION
SELECT secondary_player_id
FROM player_links
WHERE primary_player_id = @EffectiveId
""",
new { EffectiveId = effectiveId.Value })).ToArray();
}
private static async Task<Guid> _UpsertPlayerAndGetIdAsync(
NpgsqlConnection conn, string platform, string externalUserId,
string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
{
return await conn.QuerySingleAsync<Guid>(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username, avatar_url)
VALUES (@DisplayName, @Platform, @ExternalUserId, @DisplayName, @AvatarUrl)
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,
avatar_url = COALESCE(EXCLUDED.avatar_url, players.avatar_url)
RETURNING id
""",
new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId, AvatarUrl = avatarUrl },
transaction);
}
private static async Task _LogIdentityAuditAsync(
NpgsqlConnection conn, Guid playerId, string action,
string? targetPlatform, string? targetExternalUserId,
Guid? performedByPlayerId, NpgsqlTransaction? transaction)
{
await conn.ExecuteAsync(
"""
INSERT INTO identity_audit_log (player_id, action, target_platform, target_external_user_id, performed_by_player_id)
VALUES (@PlayerId, @Action, @TargetPlatform, @TargetExternalUserId, @PerformedByPlayerId)
""",
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
transaction);
}
}
@@ -33,7 +33,17 @@ public sealed class DiscordListSessionsHandlerTests
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
Assert.Contains("scheduled_at > NOW()", handler, StringComparison.Ordinal);
Assert.Contains("scheduled_at > now() - interval '4 hours'", handler, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldIncludeRecentlyStartedSessionsForCleanup()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("now() - interval '4 hours'", handler, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -47,6 +57,17 @@ public sealed class DiscordListSessionsHandlerTests
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldExposeDeleteActionForManagers()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("delete_session", handler, StringComparison.Ordinal);
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
}
[Fact]
public void Command_ShouldExist()
{
@@ -66,4 +87,18 @@ public sealed class DiscordListSessionsHandlerTests
Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
Assert.Contains("listsessions", command, StringComparison.Ordinal);
}
[Fact]
public void DeleteHandler_ShouldDeleteOnlySessionsFromTheInteractionGuild()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordDeleteSessionHandler.cs");
Assert.True(File.Exists(handlerPath), "DiscordDeleteSessionHandler should exist.");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("DELETE FROM sessions", handler, StringComparison.Ordinal);
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
}
}
@@ -17,6 +17,17 @@ public sealed class DiscordNewSessionHandlerTests
// --- Runtime tests for ParseTimeInput (static, no DB) ---
[Fact]
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00");
Assert.True(result.IsSuccess);
// 15:00 MSK = 12:00 UTC
Assert.Equal(12, result.Value.Hour);
Assert.Equal(0, result.Value.Minute);
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
}
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
@@ -28,7 +39,8 @@ public sealed class DiscordNewSessionHandlerTests
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
Assert.Equal(19, result.Value.Hour);
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
Assert.Equal(16, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
@@ -127,6 +139,18 @@ public sealed class DiscordNewSessionHandlerTests
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldRespectCancellationToken()
{
@@ -145,7 +169,30 @@ public sealed class DiscordNewSessionHandlerTests
var source = File.ReadAllText(commandPath);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("WithEmbeds", source, StringComparison.Ordinal);
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("groupName", source, StringComparison.Ordinal);
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
}
private static DateTimeOffset FutureDateAt1930()
@@ -40,6 +40,7 @@ public sealed class DiscordProjectStructureTests
Assert.Contains("GmRelay.Shared.csproj", project);
Assert.DoesNotContain("Telegram.Bot", project);
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
Assert.Contains("Dapper.AOT", project);
}
[Fact]
@@ -61,7 +62,7 @@ public sealed class DiscordProjectStructureTests
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.8.0", compose);
Assert.Contains("gmrelay-discord-bot:3.1.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);
@@ -75,13 +76,13 @@ public sealed class DiscordProjectStructureTests
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.8.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("<Version>3.1.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 3.1.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains(
"v2.8.0",
"v3.1.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
@@ -94,6 +95,16 @@ public sealed class DiscordProjectStructureTests
Assert.Contains("DISCORD_BOT_TOKEN", envExample);
}
[Fact]
public void Readme_ShouldNotAskForUnusedDiscordBotClientId()
{
var repoRoot = GetRepoRoot();
var readme = File.ReadAllText(Path.Combine(repoRoot, "README.md"));
Assert.DoesNotContain("DISCORD_BOT_CLIENT_ID", readme);
Assert.Contains("DISCORD_CLIENT_ID", readme);
}
[Fact]
public void Compose_ShouldIncludeDiscordHealthcheck()
{
@@ -21,6 +21,26 @@ public sealed class DiscordSessionInteractionModuleSourceTests
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
}
[Fact]
public async Task Module_ShouldUpdateSourceScheduleMessageThroughComponentInteraction()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("InteractionCallback.DeferredModifyMessage", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("FollowupAsync", source, StringComparison.Ordinal);
Assert.Contains("CompleteScheduleUpdateResponseAsync", source, StringComparison.Ordinal);
}
[Fact]
public async Task Module_ShouldRouteDeleteSessionButtons()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("[ComponentInteraction(\"delete_session\")]", source, StringComparison.Ordinal);
Assert.Contains("DiscordDeleteSessionHandler", source, StringComparison.Ordinal);
}
[Fact]
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
{
@@ -1,5 +1,8 @@
using System;
using System.IO;
using System.Reflection;
using GmRelay.DiscordBot.Features.Sessions;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.Bot.Tests.Discord;
@@ -47,6 +50,41 @@ public sealed class DiscordStartupTests
Assert.Contains(".AddComponentInteractions", program);
Assert.Contains(".AddGatewayHandlers", program);
Assert.Contains("AddSlashCommand", program);
Assert.Contains("AddModules(typeof(Program).Assembly)", program);
}
[Theory]
[InlineData(typeof(DiscordNewSessionCommand), "newsession")]
[InlineData(typeof(DiscordListSessionsCommand), "listsessions")]
[InlineData(typeof(DiscordRescheduleCommand), "reschedule")]
public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName)
{
var executeMethod = moduleType.GetMethod("ExecuteAsync", BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(executeMethod);
var methodAttribute = Assert.Single(executeMethod.GetCustomAttributes<SlashCommandAttribute>(inherit: false));
var nameProperty = typeof(SlashCommandAttribute).GetProperty("Name")
?? throw new InvalidOperationException("SlashCommandAttribute should expose command name.");
Assert.Equal(commandName, nameProperty.GetValue(methodAttribute));
Assert.Empty(moduleType.GetCustomAttributes<SlashCommandAttribute>(inherit: false));
}
[Fact]
public void DiscordSessionSlashCommands_ShouldBeDiscoverableByNetCordService()
{
var service = new ApplicationCommandService<SlashCommandContext>();
service.AddModules(typeof(DiscordNewSessionCommand).Assembly);
var commandNames = service.GetCommands()
.Select(command => command.Name)
.ToArray();
Assert.Contains("newsession", commandNames);
Assert.Contains("listsessions", commandNames);
Assert.Contains("reschedule", commandNames);
}
[Fact]
@@ -13,6 +13,7 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertProperty<JoinSessionCommand>("DeferScheduleUpdate", typeof(bool));
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
}
@@ -24,12 +25,29 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertProperty<LeaveSessionCommand>("DeferScheduleUpdate", typeof(bool));
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
}
[Fact]
public void SessionInteractionResult_ShouldExposeReplyTextAndUpdatedView()
{
var resultType = typeof(JoinSessionCommand).Assembly.GetType(
"GmRelay.Shared.Features.Sessions.CreateSession.SessionInteractionResult");
Assert.NotNull(resultType);
AssertProperty(resultType, "ReplyText", typeof(string));
AssertProperty(resultType, "UpdatedView", typeof(GmRelay.Shared.Rendering.SessionBatchViewModel));
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
AssertProperty(typeof(T), name, expectedType);
}
private static void AssertProperty(Type type, string name, Type expectedType)
{
var property = Assert.Single(type.GetProperties(), property => property.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
@@ -91,6 +91,15 @@ public sealed class PlatformIdentityMigrationTests
Assert.Contains("platform", service, StringComparison.Ordinal);
}
[Fact]
public async Task WebSessionService_ShouldAuthorizeGroupsAcrossLinkedIdentities()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("_ResolveLinkedPlayerIdsAsync", service, StringComparison.Ordinal);
Assert.Contains("player_id = ANY(@PlayerIds)", service, StringComparison.Ordinal);
}
[Fact]
public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername()
{
@@ -108,6 +117,28 @@ public sealed class PlatformIdentityMigrationTests
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV024_ShouldDeprecateTelegramColumns()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V024__deprecate_telegram_columns.sql");
Assert.Contains("UPDATE players", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE game_groups", migration, StringComparison.Ordinal);
Assert.Contains("DEPRECATED", migration, StringComparison.Ordinal);
Assert.Contains("calendar_subscriptions", migration, StringComparison.Ordinal);
Assert.Contains("user_platform", migration, StringComparison.Ordinal);
Assert.Contains("user_external_id", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV025_ShouldBackfillRescheduleProposals()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V025__reschedule_proposals_telegram_external.sql");
Assert.Contains("reschedule_proposals", migration, StringComparison.Ordinal);
Assert.Contains("proposed_by_external_user_id", migration, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
@@ -149,6 +149,68 @@ public sealed class SessionSchedulerServiceTests
Assert.Null(ex);
}
[Fact]
public async Task TickAsync_WhenHandlerThrows_BackoffsForDuration()
{
var sessionId = Guid.NewGuid();
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
_clock.UtcNow = now;
_store.SessionsNeedingConfirmation = [sessionId];
_confirmationHandler.ThrowFor.Add(sessionId);
var sut = CreateSut();
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
// Second tick immediately — should be backed off
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
}
[Fact]
public async Task TickAsync_WhenHandlerThrows_AfterBackoffRetriesAgain()
{
var sessionId = Guid.NewGuid();
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
_clock.UtcNow = now;
_store.SessionsNeedingConfirmation = [sessionId];
_confirmationHandler.ThrowFor.Add(sessionId);
var sut = CreateSut();
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
// Advance clock past backoff duration (15 min)
_clock.UtcNow = now.AddMinutes(16);
await sut.TickAsync(CancellationToken.None);
Assert.Equal(2, _confirmationHandler.Calls.Count);
}
[Fact]
public async Task TickAsync_WhenHandlerSucceedsAfterBackoff_ClearsBackoff()
{
var sessionId = Guid.NewGuid();
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
_clock.UtcNow = now;
_store.SessionsNeedingConfirmation = [sessionId];
_confirmationHandler.ThrowFor.Add(sessionId);
var sut = CreateSut();
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
// Remove throw condition, advance past backoff
_confirmationHandler.ThrowFor.Remove(sessionId);
_clock.UtcNow = now.AddMinutes(16);
await sut.TickAsync(CancellationToken.None);
Assert.Equal(2, _confirmationHandler.Calls.Count);
// Next tick should still call because backoff was cleared on success
_clock.UtcNow = now.AddMinutes(17);
await sut.TickAsync(CancellationToken.None);
Assert.Equal(3, _confirmationHandler.Calls.Count);
}
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
{
public List<Guid> Calls { get; } = [];
@@ -176,6 +176,32 @@ public sealed class DiscordSessionBatchRendererTests
Assert.Equal("https://example.com/game", embeds[0].Url);
}
[Fact]
public void Render_ShouldNormalizeBareDomainJoinLinkForEmbedUrl()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "mobaxterm.mobatek.net/game") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
Assert.Equal("https://mobaxterm.mobatek.net/game", embeds[0].Url);
}
[Fact]
public void Render_ShouldNotSetEmbedUrlWhenJoinLinkIsNotHttpUrl()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "test") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
Assert.Null(embeds[0].Url);
}
[Fact]
public void Render_ShouldEmbedCorrectFieldValues()
{
@@ -1121,6 +1121,21 @@ public sealed class AuthorizedSessionServiceTests
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
Task.FromResult<Guid?>(Guid.NewGuid());
public Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId) =>
Task.FromResult(new List<LinkedIdentity>());
public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) =>
Task.CompletedTask;
public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) =>
Task.CompletedTask;
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace GmRelay.Bot.Tests.Web;
public sealed class CookieAuthOptionsTests
{
[Fact]
public void CookieAuthOptions_ShouldUseLaxSameSite_ToAllowOAuthCallback()
{
// Arrange
var services = new ServiceCollection();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
});
var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
var options = optionsMonitor.Get(CookieAuthenticationDefaults.AuthenticationScheme);
// Assert
Assert.Equal(SameSiteMode.Lax, options.Cookie.SameSite);
Assert.True(options.Cookie.HttpOnly);
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
}
}
@@ -3,6 +3,7 @@ using System.Text.Json;
using GmRelay.Web;
using GmRelay.Web.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace GmRelay.Bot.Tests.Web;
@@ -20,7 +21,7 @@ public class DiscordAuthServiceTests
})
.Build();
var service = new DiscordAuthService(new TestHttpClientFactory(), config);
var service = new DiscordAuthService(new TestHttpClientFactory(), config, NullLogger<DiscordAuthService>.Instance);
var url = service.BuildAuthorizeUrl("state123");
Assert.Contains("client_id=12345", url);
@@ -33,7 +34,7 @@ public class DiscordAuthServiceTests
public void BuildAuthorizeUrl_WithMissingConfig_ThrowsInvalidOperationException()
{
var config = new ConfigurationBuilder().Build();
var service = new DiscordAuthService(new TestHttpClientFactory(), config);
var service = new DiscordAuthService(new TestHttpClientFactory(), config, NullLogger<DiscordAuthService>.Instance);
Assert.Throws<InvalidOperationException>(() => service.BuildAuthorizeUrl("state"));
}
@@ -74,7 +75,7 @@ public class DiscordAuthServiceTests
.Build();
var factory = new TestHttpClientFactory(handler);
var service = new DiscordAuthService(factory, config);
var service = new DiscordAuthService(factory, config, NullLogger<DiscordAuthService>.Instance);
var result = await service.ExchangeCodeAsync("valid_code");
@@ -102,7 +103,7 @@ public class DiscordAuthServiceTests
.Build();
var factory = new TestHttpClientFactory(handler);
var service = new DiscordAuthService(factory, config);
var service = new DiscordAuthService(factory, config, NullLogger<DiscordAuthService>.Instance);
var result = await service.ExchangeCodeAsync("invalid_code");
@@ -0,0 +1,35 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class HomePageSourceTests
{
[Fact]
public async Task HomePage_ShouldShowPlatformBadgeForGroups()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Home.razor");
Assert.Contains("platform-badge", source, StringComparison.Ordinal);
Assert.Contains("FormatPlatform", source, StringComparison.Ordinal);
Assert.Contains("group.Platform", source, StringComparison.Ordinal);
}
[Fact]
public async Task SessionService_ShouldUseSessionTitleWhenDiscordGroupNameIsOnlyId()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("latest_session.title", source, StringComparison.Ordinal);
Assert.Contains("NULLIF(g.name, g.external_group_id)", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
var repoRoot = dir ?? throw new InvalidOperationException("Could not find repo root");
return await File.ReadAllTextAsync(Path.Combine(repoRoot, relativePath));
}
}
+7 -6
View File
@@ -392,8 +392,8 @@
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"GmRelay.ServiceDefaults": "[3.0.9, )",
"GmRelay.Shared": "[3.0.9, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.5.3, )",
"dbup-postgresql": "[7.0.1, )"
@@ -404,8 +404,9 @@
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[3.0.9, )",
"GmRelay.Shared": "[3.0.9, )",
"NetCord.Hosting": "[1.0.0-alpha.489, )",
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
"NetCord.Services": "[1.0.0-alpha.489, )",
@@ -436,8 +437,8 @@
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"GmRelay.ServiceDefaults": "[3.0.9, )",
"GmRelay.Shared": "[3.0.9, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )"
}