# Platform Messenger Contracts Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement issue #24 by adding platform-neutral platform identity and messaging contracts, then routing the Telegram session flows through a Telegram adapter without changing Telegram behavior. **Architecture:** Keep update routing and Telegram update parsing at the `GmRelay.Bot.Infrastructure.Telegram` boundary, but move outbound messaging decisions behind `GmRelay.Shared.Platform.IPlatformMessenger`. `GmRelay.Shared` owns platform-neutral DTOs and contracts; `GmRelay.Bot` owns `TelegramPlatformMessenger`, which translates neutral requests into `Telegram.Bot` calls and reuses the existing Telegram renderers/editing rules. **Tech Stack:** .NET 10, C# preview, xUnit, Dapper.AOT constraints, Telegram.Bot in `GmRelay.Bot` only, platform-neutral shared contracts in `GmRelay.Shared`. --- ## Issue Context - Gitea issue: #24, `refactor: ввести PlatformKind, PlatformUser, PlatformGroup и IPlatformMessenger` - Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`, `pending-approval` - Acceptance criteria: - New contracts live in a platform-neutral layer. - Telegram flow goes through the adapter without behavior changes. - A future DiscordBot can reference the contract without depending on Telegram assemblies. ## Proposed Version Bump Current version is `2.0.0` in: - `Directory.Build.props` - `compose.yaml` - `.gitea/workflows/deploy.yml` - `src/GmRelay.Web/Components/Layout/NavMenu.razor` Issue label is `type:refactor`; per workflow rules this is not a major bump and has no user-facing feature label. Proposed bump: `2.0.0` -> `2.0.1`. ## Files - Create: `src/GmRelay.Shared/Platform/PlatformKind.cs` - Create: `src/GmRelay.Shared/Platform/PlatformUser.cs` - Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs` - Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` - Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` - Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` - Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs` - Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` - Modify: `src/GmRelay.Bot/Program.cs` - Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs` - Modify: `src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs` - Modify: version files listed above ## Design ### Shared Contracts `PlatformKind` is a sentinel enum where `Max` is not a sendable platform: ```csharp namespace GmRelay.Shared.Platform; public enum PlatformKind { Telegram = 0, Discord = 1, Max = 2 } ``` `PlatformUser` and `PlatformGroup` carry external platform identity while keeping current Telegram IDs representable as strings: ```csharp namespace GmRelay.Shared.Platform; public sealed record PlatformUser( PlatformKind Platform, string ExternalUserId, string DisplayName, string? ExternalUsername); public sealed record PlatformGroup( PlatformKind Platform, string ExternalGroupId, string DisplayName, string? ExternalChannelId = null, string? ExternalThreadId = null); ``` Outbound message contracts stay independent of Telegram/Discord SDK types: ```csharp using GmRelay.Shared.Rendering; namespace GmRelay.Shared.Platform; public sealed record PlatformMessageRef( PlatformKind Platform, string ExternalGroupId, string? ExternalThreadId, string ExternalMessageId); public sealed record PlatformMessageAction( string Key, string Label, string Payload); public sealed record PlatformScheduleMessage( PlatformGroup Group, SessionBatchViewModel View, PlatformMessageRef? ExistingMessage, string? ImageReference = null); public sealed record PlatformPrivateMessage( PlatformUser Recipient, string HtmlText); public sealed record PlatformInteractionReply( string InteractionId, string Text, bool ShowAlert = false); public sealed record PlatformCalendarFile( PlatformGroup Group, string FileName, byte[] Content, string CaptionHtml, IReadOnlyList Actions); ``` `IPlatformMessenger` exposes the required outward operations: ```csharp namespace GmRelay.Shared.Platform; public interface IPlatformMessenger { Task SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct); Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct); Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct); Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct); } ``` ### Telegram Adapter `TelegramPlatformMessenger` lives in `GmRelay.Bot.Infrastructure.Telegram`, depends on `ITelegramBotClient`, and translates neutral DTOs to existing Telegram calls: - `SendScheduleAsync` renders `SessionBatchViewModel` with `TelegramSessionBatchRenderer.Render`. - `UpdateScheduleAsync` calls `BatchMessageEditor.EditBatchMessageAsync`. - `SendGroupMessageAsync` calls `SendMessage` with `ParseMode.Html` and optional `messageThreadId`. - `SendPrivateMessageAsync` calls `SendMessage` to `PlatformUser.ExternalUserId`. - `AnswerInteractionAsync` calls `AnswerCallbackQuery`. - `SendCalendarFileAsync` calls `SendDocument` and maps URL actions to inline keyboard buttons. ### Handler Scope Refactor outbound Telegram calls in these flows to `IPlatformMessenger`: - Join/leave/promote waitlist schedule updates and callback replies. - Cancel schedule update, group cancellation message, direct notification and callback reply. - Reschedule initiation, voting message updates, immediate reschedule schedule update, direct notifications and callback replies. - Export calendar file sending. Keep Telegram inbound DTOs at the boundary for now: - `UpdateRouter` still receives `Telegram.Bot.Types.Update`. - Text message parsing in reschedule input still receives `Telegram.Bot.Types.Message`. - `CreateSessionHandler` can keep photo/topic creation via `ITelegramBotClient` because issue #24 targets outbound schedule/interaction/private/calendar contract, not replacing all Telegram update primitives in one PR. ## Tasks ### Task 1: RED - Shared Contract Tests **Files:** - Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs` - [ ] **Step 1: Write failing tests for neutral contracts** ```csharp using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; namespace GmRelay.Bot.Tests.Platform; public sealed class PlatformContractsTests { [Fact] public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel() { Assert.Equal(0, (int)PlatformKind.Telegram); Assert.Equal(1, (int)PlatformKind.Discord); Assert.Equal(2, (int)PlatformKind.Max); } [Fact] public void PlatformContracts_ShouldBeTelegramAssemblyFree() { var contractTypes = new[] { typeof(PlatformUser), typeof(PlatformGroup), typeof(PlatformMessageRef), typeof(PlatformMessageAction), typeof(PlatformScheduleMessage), typeof(PlatformPrivateMessage), typeof(PlatformInteractionReply), typeof(PlatformCalendarFile), typeof(IPlatformMessenger) }; Assert.All(contractTypes, type => Assert.DoesNotContain( "Telegram", string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)), StringComparison.OrdinalIgnoreCase)); } [Fact] public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes() { var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); var view = SessionBatchViewBuilder.Build( "Campaign", [new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")], []); var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1"); var message = new PlatformScheduleMessage(group, view, ExistingMessage: null); Assert.Equal(PlatformKind.Discord, message.Group.Platform); Assert.Same(view, message.View); } } ``` - [ ] **Step 2: Run tests and verify RED** Run: ```powershell dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests ``` Expected: compile failure because `GmRelay.Shared.Platform` types do not exist. ### Task 2: GREEN - Add Shared Contracts **Files:** - Create: `src/GmRelay.Shared/Platform/PlatformKind.cs` - Create: `src/GmRelay.Shared/Platform/PlatformUser.cs` - Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs` - Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` - Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` - [ ] **Step 1: Add the contract files exactly as described in the Design section** - [ ] **Step 2: Run PlatformContractsTests and verify GREEN** Run: ```powershell dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests ``` Expected: `Passed`. ### Task 3: RED - Adapter and Flow Source Tests **Files:** - Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` - [ ] **Step 1: Write source tests for adapter wiring and target flows** ```csharp namespace GmRelay.Bot.Tests.Infrastructure.Telegram; public sealed class TelegramPlatformMessengerSourceTests { [Fact] public async Task Program_ShouldRegisterTelegramPlatformMessenger() { var program = await ReadRepositoryFileAsync("src/GmRelay.Bot/Program.cs"); Assert.Contains("IPlatformMessenger", program, StringComparison.Ordinal); Assert.Contains("TelegramPlatformMessenger", program, StringComparison.Ordinal); } [Theory] [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")] public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath) { var source = await ReadRepositoryFileAsync(relativePath); Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal); Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal); } [Fact] public async Task TelegramPlatformMessenger_ShouldOwnTelegramBotClientCalls() { var source = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs"); Assert.Contains("ITelegramBotClient", source, StringComparison.Ordinal); Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal); Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal); Assert.Contains("SendDocument", source, StringComparison.Ordinal); } private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null) { var candidate = Path.Combine(directory.FullName, relativePath); if (File.Exists(candidate)) { return await File.ReadAllTextAsync(candidate); } directory = directory.Parent; } throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); } } ``` - [ ] **Step 2: Run tests and verify RED** Run: ```powershell dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests ``` Expected: failures because `TelegramPlatformMessenger` is missing and handlers still call Telegram APIs directly. ### Task 4: GREEN - Implement TelegramPlatformMessenger and Registration **Files:** - Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` - Modify: `src/GmRelay.Bot/Program.cs` - [ ] **Step 1: Implement adapter** Implementation notes: - Parse Telegram chat/thread/message IDs from neutral string IDs with `long.Parse` and `int.Parse`. - Use `ParseMode.Html` for HTML text. - Map `PlatformMessageAction` URLs to `InlineKeyboardButton.WithUrl`. - Return a `PlatformMessageRef` with message IDs converted to strings. - [ ] **Step 2: Register adapter** Add `using GmRelay.Shared.Platform;` and register: ```csharp builder.Services.AddSingleton(); ``` - [ ] **Step 3: Run adapter source tests** Run: ```powershell dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests ``` Expected: some handler source tests still fail until Task 5. ### Task 5: GREEN - Refactor Session Flows Through Adapter **Files:** - Modify target handler files listed in Task 3 - Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs` - [ ] **Step 1: Replace constructor dependencies** Use `IPlatformMessenger messenger` in target handlers for outbound operations. Keep `ITelegramBotClient` only where the handler still performs inbound Telegram-specific work that is out of scope, such as message deletion or forum topic creation. - [ ] **Step 2: Convert Telegram IDs to neutral platform objects** Use helper code equivalent to: ```csharp private static PlatformGroup TelegramGroup(long chatId, string? title, int? threadId = null) => new( PlatformKind.Telegram, chatId.ToString(System.Globalization.CultureInfo.InvariantCulture), title ?? "Telegram chat", ExternalChannelId: chatId.ToString(System.Globalization.CultureInfo.InvariantCulture), ExternalThreadId: threadId?.ToString(System.Globalization.CultureInfo.InvariantCulture)); private static PlatformUser TelegramUser(long telegramId, string displayName, string? username = null) => new( PlatformKind.Telegram, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), displayName, username); ``` - [ ] **Step 3: Replace schedule updates** Build `SessionBatchViewModel` as before, then call: ```csharp await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( group, view, new PlatformMessageRef(PlatformKind.Telegram, group.ExternalGroupId, group.ExternalThreadId, messageId.ToString(System.Globalization.CultureInfo.InvariantCulture))), ct); ``` - [ ] **Step 4: Replace interaction replies** Use: ```csharp await messenger.AnswerInteractionAsync( new PlatformInteractionReply(command.CallbackQueryId, text, showAlert: false), ct); ``` - [ ] **Step 5: Replace direct notifications** `DirectSessionNotificationSender` should become a small compatibility service over `IPlatformMessenger`: ```csharp await messenger.SendPrivateMessageAsync( new PlatformPrivateMessage( new PlatformUser(PlatformKind.Telegram, recipient.TelegramId.ToString(CultureInfo.InvariantCulture), recipient.DisplayName, null), htmlText), ct); ``` - [ ] **Step 6: Replace calendar file sending** `ExportCalendarHandler` builds the same ICS bytes and calls `SendCalendarFileAsync`, preserving the subscription URL button as a `PlatformMessageAction`. - [ ] **Step 7: Run target source tests** Run: ```powershell dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests ``` Expected: `Passed`. ### Task 6: Regression Tests **Files:** - Existing tests only unless a compiler failure exposes a missing using or changed behavior. - [ ] **Step 1: Run rendering and routing tests** Run: ```powershell dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Rendering|FullyQualifiedName~Telegram|FullyQualifiedName~RescheduleSession" ``` Expected: `Passed`. - [ ] **Step 2: Run all tests** Run: ```powershell dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj ``` Expected: `Passed`. - [ ] **Step 3: Build solution** Run: ```powershell dotnet build GM-Relay.slnx ``` Expected: `Build succeeded` with warnings treated as errors. ### Task 7: Version Bump **Files:** - Modify: `Directory.Build.props` - Modify: `compose.yaml` - Modify: `.gitea/workflows/deploy.yml` - Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - [ ] **Step 1: Update all four version locations to `2.0.1`** - [ ] **Step 2: Verify sync** Run: ```powershell rg -n "2\\.0\\.0|2\\.0\\.1" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor ``` Expected: no `2.0.0` matches in these files and `2.0.1` appears in all required locations. ### Task 8: Documentation Review **Files:** - Review: `README.md` - Review: `docs/adr/002-platform-neutral-batch-rendering.md` - [ ] **Step 1: Check README and ADR for platform contract accuracy** - [ ] **Step 2: Update docs if they now misrepresent platform-neutral responsibilities** Expected likely doc change: README currently lists current version as `v1.15.0`, which is already inconsistent with repo version `2.0.0`. If this PR bumps to `2.0.1`, update that line to `v2.0.1`. ### Task 9: Commit, PR, CI, Review, Merge, Deploy, Release **Files:** - Stage only files intentionally changed for issue #24. - [ ] **Step 1: Create branch** ```powershell git checkout -b codex/refactor/issue-24-platform-messenger ``` - [ ] **Step 2: Commit** ```powershell git add src/GmRelay.Shared/Platform src/GmRelay.Bot tests/GmRelay.Bot.Tests Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md docs/adr/002-platform-neutral-batch-rendering.md git commit -m "refactor: add platform messenger contracts" ``` - [ ] **Step 3: Push and create PR via Gitea** - [ ] **Step 4: Wait for PR CI and fix failures if any** - [ ] **Step 5: Run code review subagent and address findings** - [ ] **Step 6: Merge PR after CI and review** - [ ] **Step 7: Monitor deploy workflow** - [ ] **Step 8: Create release `v2.0.1` with Russian release notes** - [ ] **Step 9: Close issue #24 with PR and release links** ## Self-Review - Spec coverage: all issue acceptance criteria map to Shared contracts, Telegram adapter, handler source tests, and build/test verification. - Placeholder scan: no `TBD`, `TODO`, or "fill later" placeholders are left in this plan. - Type consistency: all snippets use `GmRelay.Shared.Platform`, `PlatformKind.Telegram`, `PlatformMessageRef`, and `IPlatformMessenger` consistently. - Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope.