Files
GmRelayBot/docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md
T
Toutsu e791fc2f4a
PR Checks / test-and-build (pull_request) Successful in 5m3s
refactor: make session join leave platform-neutral
Convert join/leave interaction commands to PlatformUser, PlatformGroup, and PlatformMessageRef. Persist and look up participants by platform identity while keeping Telegram callbacks intact. Add V017 migration and TDD coverage. Bump version to 2.1.1.
2026-05-18 13:30:48 +03:00

21 KiB

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

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<JoinSessionCommand>("SessionId", typeof(Guid));
        AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
        AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
        AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
        AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
        AssertNoTelegramSpecificProperties<JoinSessionCommand>();
    }

    [Fact]
    public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
    {
        AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
        AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
        AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
        AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
        AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
        AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
    }

    private static void AssertProperty<T>(string name, Type expectedType)
    {
        var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);

        Assert.Equal(expectedType, property.PropertyType);
    }

    private static void AssertNoTelegramSpecificProperties<T>()
    {
        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:

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

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<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: Add failing migration assertion

Append to PlatformIdentityMigrationTests:

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

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

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

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:

public sealed record JoinSessionCommand(
    Guid SessionId,
    PlatformUser User,
    string InteractionId,
    PlatformGroup Group,
    PlatformMessageRef ScheduleMessage);
  • Step 2: Replace player upsert

Use platform identity parameters:

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<Guid>(
    @"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:

COALESCE(p.external_username, p.telegram_username) as TelegramUsername
  • Step 4: Update schedule message and interaction reply usage

Use:

await messenger.UpdateScheduleAsync(
    new PlatformScheduleMessage(
        command.Group,
        view,
        command.ScheduleMessage),
    ct);

and:

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:

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:

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:

var platform = command.User.Platform.ToString();

var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
    """
    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:

COALESCE(p.external_username, p.telegram_username) AS TelegramUsername
  • Step 4: Update schedule message and interaction reply usage

Use:

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:

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:

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
var command = new JoinSessionCommand(
    SessionId: joinSessionId,
    User: user,
    InteractionId: query.Id,
    Group: group,
    ScheduleMessage: scheduleMessage);
  • Step 3: Update leave command construction
var command = new LeaveSessionCommand(
    SessionId: leaveSessionId,
    User: user,
    InteractionId: query.Id,
    Group: group,
    ScheduleMessage: scheduleMessage);
  • Step 4: Verify compile

Run:

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:

using GmRelay.Bot.Infrastructure.Telegram;

Remove it from handlers if no longer needed.

  • Step 2: Run focused tests
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"

Expected: PASS.

  • Step 3: Run full test suite
dotnet test .\GM-Relay.slnx

Expected: PASS.

  • Step 4: Build solution
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:

<Version>2.1.1</Version>
VERSION: 2.1.1
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
<div class="nav-version">v2.1.1</div>
  • Step 2: Verify synchronized versions

Run:

rg "<Version>|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

git checkout -b refactor/issue-25-platform-neutral-join-leave
  • Step 2: Stage only intended files
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
git commit -m "refactor: make session join leave platform-neutral"
  • Step 4: Push and create Gitea PR
git push -u origin refactor/issue-25-platform-neutral-join-leave

PR title:

refactor: make session join leave platform-neutral

PR body:

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