# 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`.