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);