Files
GmRelayBot/docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md
T
Toutsu 8bcd16fbc9
PR Checks / test-and-build (pull_request) Successful in 12m35s
refactor: add platform messenger contracts
Introduce platform-neutral PlatformKind, PlatformUser, PlatformGroup, and IPlatformMessenger contracts in GmRelay.Shared.

Route Telegram session schedule updates, direct notifications, interaction replies, and calendar export through TelegramPlatformMessenger while preserving existing Telegram behavior.

Bump version -> 2.0.1
2026-05-15 12:30:37 +03:00

20 KiB

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:

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:

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:

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

IPlatformMessenger exposes the required outward operations:

namespace GmRelay.Shared.Platform;

public interface IPlatformMessenger
{
    Task<PlatformMessageRef> 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

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:

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:

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

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<string> 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:

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:

builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
  • Step 3: Run adapter source tests

Run:

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:

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:

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:

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:

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:

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:

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:

dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj

Expected: Passed.

  • Step 3: Build solution

Run:

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:

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

git checkout -b codex/refactor/issue-24-platform-messenger
  • Step 2: Commit
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.