From e791fc2f4a5e3b36572d11c8f8834245352f3fdd Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 18 May 2026 13:30:48 +0300 Subject: [PATCH] refactor: make session join leave platform-neutral Convert join/leave interaction commands to PlatformUser, PlatformGroup, and PlatformMessageRef. Persist and look up participants by platform identity while keeping Telegram callbacks intact. Add V017 migration and TDD coverage. Bump version to 2.1.1. --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 4 +- .../2026-05-18-platform-neutral-join-leave.md | 599 ++++++++++++++++++ .../CreateSession/JoinSessionHandler.cs | 64 +- .../CreateSession/LeaveSessionHandler.cs | 36 +- .../Infrastructure/Telegram/UpdateRouter.cs | 24 +- .../V017__allow_platform_neutral_players.sql | 9 + .../Components/Layout/NavMenu.razor | 2 +- ...rmNeutralSessionInteractionCommandTests.cs | 47 ++ ...atformNeutralSessionInteractionSqlTests.cs | 61 ++ .../PlatformIdentityMigrationTests.cs | 9 + 12 files changed, 803 insertions(+), 56 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md create mode 100644 src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ca3b1c9..dcc6a56 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.1.0 + VERSION: 2.1.1 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 60e9423..9a32501 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.1.0 + 2.1.1 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index 2fe9c78..988d092 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1 restart: always depends_on: db: diff --git a/docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md b/docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md new file mode 100644 index 0000000..00367b0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md @@ -0,0 +1,599 @@ +# Platform-Neutral Join Leave Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Gitea issue #25 by making join/leave session interactions use platform-neutral command models while preserving Telegram callback behavior, seat limits, and waitlist semantics. + +**Architecture:** Telegram callback routing remains in `UpdateRouter`, but it becomes an adapter that converts callback data into `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef` values. `JoinSessionHandler` and `LeaveSessionHandler` operate on those neutral values, persist players by `(platform, external_user_id)`, and update schedules through `IPlatformMessenger`. + +**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Gitea Actions. + +--- + +## Issue Context + +- Issue: `#25 refactor: obobshchit JoinSession i LeaveSession pod platform-neutral interactions` +- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor` +- Version bump: patch, `2.1.0` -> `2.1.1`. The issue is labeled refactor, not breaking; do not use a major bump without explicit approval. +- Existing untracked file: `CLAUDE.md`; do not stage or modify it. + +## File Map + +- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs` + - Reflection tests proving join/leave command records expose neutral properties and no Telegram-specific identity/message fields. +- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs` + - Source-level regression tests for handler SQL and messenger boundaries. +- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs` + - Add a migration test for nullable legacy `players.telegram_id`, required for non-Telegram player inserts. +- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql` + - Drop `NOT NULL` from legacy Telegram-only player columns. +- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs` + - Change `JoinSessionCommand` to neutral properties and query/upsert players by platform identity. +- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs` + - Change `LeaveSessionCommand` to neutral properties and find participants by platform identity. +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` + - Convert Telegram callback data into neutral command values using `TelegramPlatformIds`. +- Modify: version files after implementation: + - `Directory.Build.props` + - `compose.yaml` + - `.gitea/workflows/deploy.yml` + - `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +## Task 1: RED - Command Model Tests + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs` + +- [ ] **Step 1: Write failing command-shape tests** + +```csharp +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Shared.Platform; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class PlatformNeutralSessionInteractionCommandTests +{ + [Fact] + public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext() + { + AssertProperty("SessionId", typeof(Guid)); + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("InteractionId", typeof(string)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertNoTelegramSpecificProperties(); + } + + [Fact] + public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext() + { + AssertProperty("SessionId", typeof(Guid)); + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("InteractionId", typeof(string)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertNoTelegramSpecificProperties(); + } + + private static void AssertProperty(string name, Type expectedType) + { + var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name); + + Assert.Equal(expectedType, property.PropertyType); + } + + private static void AssertNoTelegramSpecificProperties() + { + var names = typeof(T).GetProperties().Select(property => property.Name).ToArray(); + + Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal)); + Assert.DoesNotContain("ChatId", names); + Assert.DoesNotContain("MessageId", names); + Assert.DoesNotContain("TelegramUserId", names); + Assert.DoesNotContain("TelegramUsername", names); + } +} +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```powershell +dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests +``` + +Expected: FAIL because `JoinSessionCommand` and `LeaveSessionCommand` still expose `TelegramUserId`, `ChatId`, and `MessageId`, and do not expose `User`, `Group`, or `ScheduleMessage`. + +## Task 2: RED - SQL and Boundary Tests + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs` +- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs` + +- [ ] **Step 1: Write failing handler source tests** + +```csharp +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class PlatformNeutralSessionInteractionSqlTests +{ + [Fact] + public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity() + { + var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); + + Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal); + Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal); + Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal); + Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal); + Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal); + Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal); + } + + [Fact] + public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity() + { + var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); + + Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal); + Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal); + Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal); + Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal); + Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal); + } + + [Fact] + public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference() + { + var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); + var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); + + Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal); + Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal); + Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal); + Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal); + Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal); + Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} +``` + +- [ ] **Step 2: Add failing migration assertion** + +Append to `PlatformIdentityMigrationTests`: + +```csharp +[Fact] +public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId() +{ + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql"); + + Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal); + Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal); +} +``` + +- [ ] **Step 3: Verify RED** + +Run: + +```powershell +dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionSqlTests|MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId" +``` + +Expected: FAIL because handlers still use Telegram-specific properties and the V017 migration file does not exist. + +## Task 3: GREEN - Add Migration + +**Files:** +- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql` + +- [ ] **Step 1: Create the migration** + +```sql +-- ============================================================= +-- V017: Allow platform-neutral players +-- ============================================================= +-- Legacy Telegram identity columns remain for backward compatibility, +-- but non-Telegram platform users do not have Telegram ids. +-- ============================================================= + +ALTER TABLE players + ALTER COLUMN telegram_id DROP NOT NULL; +``` + +- [ ] **Step 2: Verify migration test turns green** + +Run: + +```powershell +dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId +``` + +Expected: PASS. + +## Task 4: GREEN - Refactor JoinSessionCommand and Handler + +**Files:** +- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs` + +- [ ] **Step 1: Replace command record** + +Replace the existing `JoinSessionCommand` declaration with: + +```csharp +public sealed record JoinSessionCommand( + Guid SessionId, + PlatformUser User, + string InteractionId, + PlatformGroup Group, + PlatformMessageRef ScheduleMessage); +``` + +- [ ] **Step 2: Replace player upsert** + +Use platform identity parameters: + +```csharp +var platform = command.User.Platform.ToString(); +var legacyTelegramId = command.User.Platform == PlatformKind.Telegram + ? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture) + : (long?)null; +var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram + ? command.User.ExternalUsername + : null; + +var playerId = await connection.ExecuteScalarAsync( + @"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) + VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE + SET display_name = EXCLUDED.display_name, + telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username), + platform = EXCLUDED.platform, + external_user_id = EXCLUDED.external_user_id, + external_username = EXCLUDED.external_username + RETURNING id;", + new + { + LegacyTelegramId = legacyTelegramId, + Name = command.User.DisplayName, + LegacyTelegramUsername = legacyTelegramUsername, + Platform = platform, + command.User.ExternalUserId, + command.User.ExternalUsername + }, + transaction); +``` + +Add `using System.Globalization;` at the top. + +- [ ] **Step 3: Update participant display query** + +Change the participant projection to prefer platform-neutral username: + +```sql +COALESCE(p.external_username, p.telegram_username) as TelegramUsername +``` + +- [ ] **Step 4: Update schedule message and interaction reply usage** + +Use: + +```csharp +await messenger.UpdateScheduleAsync( + new PlatformScheduleMessage( + command.Group, + view, + command.ScheduleMessage), + ct); +``` + +and: + +```csharp +private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => + messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); +``` + +Replace all `command.CallbackQueryId` calls with `command.InteractionId`. + +- [ ] **Step 5: Verify command and SQL tests for join** + +Run: + +```powershell +dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext|JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity" +``` + +Expected: PASS for join-focused tests. + +## Task 5: GREEN - Refactor LeaveSessionCommand and Handler + +**Files:** +- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs` + +- [ ] **Step 1: Replace command record** + +Replace the existing `LeaveSessionCommand` declaration with: + +```csharp +public sealed record LeaveSessionCommand( + Guid SessionId, + PlatformUser User, + string InteractionId, + PlatformGroup Group, + PlatformMessageRef ScheduleMessage); +``` + +- [ ] **Step 2: Replace participant lookup** + +Use platform identity instead of Telegram id: + +```csharp +var platform = command.User.Platform.ToString(); + +var participant = await connection.QuerySingleOrDefaultAsync( + """ + SELECT sp.id AS ParticipantRowId, + p.display_name AS DisplayName, + sp.registration_status AS RegistrationStatus + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId + AND sp.is_gm = false + FOR UPDATE OF sp + """, + new { command.SessionId, Platform = platform, command.User.ExternalUserId }, + transaction); +``` + +- [ ] **Step 3: Update participant display query** + +Change the participant projection to: + +```sql +COALESCE(p.external_username, p.telegram_username) AS TelegramUsername +``` + +- [ ] **Step 4: Update schedule message and interaction reply usage** + +Use: + +```csharp +await messenger.UpdateScheduleAsync( + new PlatformScheduleMessage( + command.Group, + view, + command.ScheduleMessage), + ct); +``` + +Replace all `command.CallbackQueryId` calls with `command.InteractionId`. + +- [ ] **Step 5: Verify leave tests** + +Run: + +```powershell +dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext|LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity|SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference" +``` + +Expected: PASS. + +## Task 6: GREEN - Convert Telegram Router to Neutral Commands + +**Files:** +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` + +- [ ] **Step 1: Add local conversion values in `HandleCallbackQueryAsync`** + +After parsing `action`, add: + +```csharp +var user = TelegramPlatformIds.User( + query.From.Id, + query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"), + query.From.Username); +var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title); +var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId); +``` + +- [ ] **Step 2: Update join command construction** + +```csharp +var command = new JoinSessionCommand( + SessionId: joinSessionId, + User: user, + InteractionId: query.Id, + Group: group, + ScheduleMessage: scheduleMessage); +``` + +- [ ] **Step 3: Update leave command construction** + +```csharp +var command = new LeaveSessionCommand( + SessionId: leaveSessionId, + User: user, + InteractionId: query.Id, + Group: group, + ScheduleMessage: scheduleMessage); +``` + +- [ ] **Step 4: Verify compile** + +Run: + +```powershell +dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests +``` + +Expected: PASS. + +## Task 7: REFACTOR - Clean Up and Full Test Pass + +**Files:** +- Modify only files already listed if cleanup is needed. + +- [ ] **Step 1: Remove now-unused Telegram handler imports** + +Check `JoinSessionHandler.cs` and `LeaveSessionHandler.cs` for unused: + +```csharp +using GmRelay.Bot.Infrastructure.Telegram; +``` + +Remove it from handlers if no longer needed. + +- [ ] **Step 2: Run focused tests** + +```powershell +dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests" +``` + +Expected: PASS. + +- [ ] **Step 3: Run full test suite** + +```powershell +dotnet test .\GM-Relay.slnx +``` + +Expected: PASS. + +- [ ] **Step 4: Build solution** + +```powershell +dotnet build .\GM-Relay.slnx +``` + +Expected: PASS. + +## Task 8: Version Bump + +**Files:** +- Modify: `Directory.Build.props` +- Modify: `compose.yaml` +- Modify: `.gitea/workflows/deploy.yml` +- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +- [ ] **Step 1: Update version from `2.1.0` to `2.1.1`** + +Expected exact replacements: + +```xml +2.1.1 +``` + +```yaml +VERSION: 2.1.1 +``` + +```yaml +image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1 +image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1 +``` + +```razor + +``` + +- [ ] **Step 2: Verify synchronized versions** + +Run: + +```powershell +rg "|image: git.codeanddice.ru/toutsu/gmrelay-|VERSION:|nav-version" Directory.Build.props compose.yaml .gitea\workflows\deploy.yml src\GmRelay.Web\Components\Layout\NavMenu.razor +``` + +Expected: all project image/app/deploy UI versions show `2.1.1`. + +## Task 9: PR, CI, Review, Merge, Deploy, Release + +**Files:** +- No additional source changes expected. + +- [ ] **Step 1: Create branch after approval** + +```powershell +git checkout -b refactor/issue-25-platform-neutral-join-leave +``` + +- [ ] **Step 2: Stage only intended files** + +```powershell +git add docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor +``` + +- [ ] **Step 3: Commit** + +```powershell +git commit -m "refactor: make session join leave platform-neutral" +``` + +- [ ] **Step 4: Push and create Gitea PR** + +```powershell +git push -u origin refactor/issue-25-platform-neutral-join-leave +``` + +PR title: + +```text +refactor: make session join leave platform-neutral +``` + +PR body: + +```markdown +## Summary +- Closes #25. +- Converts join/leave session interaction commands from Telegram-specific fields to platform-neutral `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef`. +- Persists and looks up session participants by `(platform, external_user_id)`. +- Keeps Telegram callback data and schedule update behavior intact. + +## Test plan +- `dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"` +- `dotnet test .\GM-Relay.slnx` +- `dotnet build .\GM-Relay.slnx` + +## Workflow +- [ ] CI passes +- [ ] Code review approved +- [ ] Deployed +- [ ] Release published +``` + +- [ ] **Step 5: Watch CI, request review, merge, deploy, release** + +Use Gitea MCP for PR creation, CI polling, review, merge, deploy monitoring, and release `v2.1.1`. Close issue #25 after release and add a comment linking the PR and release. + +## Self-Review + +- Spec coverage: issue scope is covered by neutral command records, Telegram adapter conversion, platform identity SQL, messenger-based schedule updates, and tests. +- Placeholder scan: no `TBD`, `TODO`, or "fill later" steps remain. +- Type consistency: commands consistently use `PlatformUser User`, `string InteractionId`, `PlatformGroup Group`, and `PlatformMessageRef ScheduleMessage`. diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs index 1e23645..8301f8d 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -1,20 +1,18 @@ +using System.Globalization; using Dapper; using Npgsql; using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; -using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; public sealed record JoinSessionCommand( Guid SessionId, - long TelegramUserId, - string DisplayName, - string? TelegramUsername, - string CallbackQueryId, - long ChatId, - int MessageId); + PlatformUser User, + string InteractionId, + PlatformGroup Group, + PlatformMessageRef ScheduleMessage); // DTOs for AOT compilation internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers); @@ -33,17 +31,35 @@ public sealed class JoinSessionHandler( try { // 1. Убеждаемся, что игрок есть в базе + var platform = command.User.Platform.ToString(); + var legacyTelegramId = command.User.Platform == PlatformKind.Telegram + ? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture) + : (long?)null; + var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram + ? command.User.ExternalUsername + : null; + var playerId = await connection.ExecuteScalarAsync( @"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 + VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE SET display_name = EXCLUDED.display_name, - telegram_username = 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) + telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username), + platform = EXCLUDED.platform, + external_user_id = EXCLUDED.external_user_id, + external_username = EXCLUDED.external_username RETURNING id;", - new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername }, + new + { + LegacyTelegramId = legacyTelegramId, + Name = command.User.DisplayName, + LegacyTelegramUsername = legacyTelegramUsername, + Platform = platform, + command.User.ExternalUserId, + command.User.ExternalUsername + }, transaction); // 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав. @@ -58,7 +74,7 @@ public sealed class JoinSessionHandler( if (batchInfo is null) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct); + await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return; } @@ -79,7 +95,7 @@ public sealed class JoinSessionHandler( var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Вы уже в листе ожидания!" : "Вы уже записаны!"; - await AnswerAsync(command.CallbackQueryId, alreadyText, ct); + await AnswerAsync(command.InteractionId, alreadyText, ct); return; } @@ -113,7 +129,7 @@ public sealed class JoinSessionHandler( if (inserted == 0) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct); + await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct); return; } @@ -128,7 +144,7 @@ public sealed class JoinSessionHandler( var batchParticipants = await connection.QueryAsync( @"SELECT sp.session_id as SessionId, p.display_name as DisplayName, - p.telegram_username as TelegramUsername, + COALESCE(p.external_username, p.telegram_username) as TelegramUsername, sp.registration_status as RegistrationStatus FROM session_participants sp JOIN players p ON sp.player_id = p.id @@ -144,15 +160,15 @@ public sealed class JoinSessionHandler( var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( - TelegramPlatformIds.Group(command.ChatId), + command.Group, view, - TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)), + command.ScheduleMessage), ct); var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted ? "Основной состав заполнен. Вы добавлены в лист ожидания." : "Вы успешно записаны!"; - await AnswerAsync(command.CallbackQueryId, callbackText, ct); + await AnswerAsync(command.InteractionId, callbackText, ct); } catch (Exception ex) { @@ -165,10 +181,10 @@ public sealed class JoinSessionHandler( var errorText = transactionCommitted ? "Регистрация сохранена, но не удалось обновить сообщение расписания." : "Произошла ошибка при регистрации."; - await AnswerAsync(command.CallbackQueryId, errorText, ct); + await AnswerAsync(command.InteractionId, errorText, ct); } } - private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) => - messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct); + private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => + messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs index 230e82c..872c5b9 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs @@ -3,16 +3,15 @@ using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Npgsql; -using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; public sealed record LeaveSessionCommand( Guid SessionId, - long TelegramUserId, - string CallbackQueryId, - long ChatId, - int MessageId); + PlatformUser User, + string InteractionId, + PlatformGroup Group, + PlatformMessageRef ScheduleMessage); internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers); internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus); @@ -47,17 +46,19 @@ public sealed class LeaveSessionHandler( if (session is null) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct); + await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return; } if (SessionStatus.IsCancelled(session.Status)) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct); + await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); return; } + var platform = command.User.Platform.ToString(); + var participant = await connection.QuerySingleOrDefaultAsync( """ SELECT sp.id AS ParticipantRowId, @@ -66,17 +67,18 @@ public sealed class LeaveSessionHandler( 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 = @Platform + AND p.external_user_id = @ExternalUserId AND sp.is_gm = false FOR UPDATE OF sp """, - new { command.SessionId, command.TelegramUserId }, + new { command.SessionId, Platform = platform, command.User.ExternalUserId }, transaction); if (participant is null) { await transaction.RollbackAsync(ct); - await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct); + await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct); return; } @@ -170,7 +172,7 @@ public sealed class LeaveSessionHandler( """ SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, + COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, sp.registration_status AS RegistrationStatus FROM session_participants sp JOIN players p ON sp.player_id = p.id @@ -187,9 +189,9 @@ public sealed class LeaveSessionHandler( var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( - TelegramPlatformIds.Group(command.ChatId), + command.Group, view, - TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)), + command.ScheduleMessage), ct); var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted @@ -198,7 +200,7 @@ public sealed class LeaveSessionHandler( ? "Вы отписались от сессии." : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; - await AnswerAsync(command.CallbackQueryId, callbackText, ct); + await AnswerAsync(command.InteractionId, callbackText, ct); } catch (Exception ex) { @@ -211,10 +213,10 @@ public sealed class LeaveSessionHandler( var errorText = transactionCommitted ? "Запись снята, но не удалось обновить сообщение расписания." : "Произошла ошибка при отмене записи."; - await AnswerAsync(command.CallbackQueryId, errorText, ct); + await AnswerAsync(command.InteractionId, errorText, ct); } } - private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) => - messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct); + private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => + messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); } diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 0538a55..1491842 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -69,17 +69,21 @@ public sealed class UpdateRouter( var parts = data.Split(':', 3); var action = parts[0]; + var user = TelegramPlatformIds.User( + query.From.Id, + query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"), + query.From.Username); + var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title); + var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId); if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId)) { var command = new JoinSessionCommand( SessionId: joinSessionId, - TelegramUserId: query.From.Id, - DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"), - TelegramUsername: query.From.Username, - CallbackQueryId: query.Id, - ChatId: message.Chat.Id, - MessageId: message.MessageId); + User: user, + InteractionId: query.Id, + Group: group, + ScheduleMessage: scheduleMessage); await joinSessionHandler.HandleAsync(command, ct); return; @@ -89,10 +93,10 @@ public sealed class UpdateRouter( { var command = new LeaveSessionCommand( SessionId: leaveSessionId, - TelegramUserId: query.From.Id, - CallbackQueryId: query.Id, - ChatId: message.Chat.Id, - MessageId: message.MessageId); + User: user, + InteractionId: query.Id, + Group: group, + ScheduleMessage: scheduleMessage); await leaveSessionHandler.HandleAsync(command, ct); return; diff --git a/src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql b/src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql new file mode 100644 index 0000000..b7ca0dc --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql @@ -0,0 +1,9 @@ +-- ============================================================= +-- V017: Allow platform-neutral players +-- ============================================================= +-- Legacy Telegram identity columns remain for backward compatibility, +-- but non-Telegram platform users do not have Telegram ids. +-- ============================================================= + +ALTER TABLE players + ALTER COLUMN telegram_id DROP NOT NULL; diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index a54f6d4..55cf503 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs new file mode 100644 index 0000000..b7e2049 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs @@ -0,0 +1,47 @@ +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Shared.Platform; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class PlatformNeutralSessionInteractionCommandTests +{ + [Fact] + public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext() + { + AssertProperty("SessionId", typeof(Guid)); + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("InteractionId", typeof(string)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertNoTelegramSpecificProperties(); + } + + [Fact] + public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext() + { + AssertProperty("SessionId", typeof(Guid)); + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("InteractionId", typeof(string)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertNoTelegramSpecificProperties(); + } + + private static void AssertProperty(string name, Type expectedType) + { + var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name); + + Assert.Equal(expectedType, property.PropertyType); + } + + private static void AssertNoTelegramSpecificProperties() + { + var names = typeof(T).GetProperties().Select(property => property.Name).ToArray(); + + Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal)); + Assert.DoesNotContain("ChatId", names); + Assert.DoesNotContain("MessageId", names); + Assert.DoesNotContain("TelegramUserId", names); + Assert.DoesNotContain("TelegramUsername", names); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs new file mode 100644 index 0000000..71ef4aa --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs @@ -0,0 +1,61 @@ +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class PlatformNeutralSessionInteractionSqlTests +{ + [Fact] + public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity() + { + var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); + + Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal); + Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal); + Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal); + Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal); + Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal); + Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal); + } + + [Fact] + public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity() + { + var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); + + Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal); + Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal); + Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal); + Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal); + Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal); + } + + [Fact] + public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference() + { + var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); + var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); + + Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal); + Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal); + Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal); + Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal); + Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal); + Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs index 483a5da..7a6f723 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs @@ -99,6 +99,15 @@ public sealed class PlatformIdentityMigrationTests Assert.Contains("external_username", statsMigration, StringComparison.Ordinal); } + [Fact] + public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql"); + + Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal); + Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory);