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.
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.
- Add a migration test for nullable legacy
- Create:
src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql- Drop
NOT NULLfrom legacy Telegram-only player columns.
- Drop
- Modify:
src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs- Change
JoinSessionCommandto neutral properties and query/upsert players by platform identity.
- Change
- Modify:
src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs- Change
LeaveSessionCommandto neutral properties and find participants by platform identity.
- Change
- Modify:
src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs- Convert Telegram callback data into neutral command values using
TelegramPlatformIds.
- Convert Telegram callback data into neutral command values using
- Modify: version files after implementation:
Directory.Build.propscompose.yaml.gitea/workflows/deploy.ymlsrc/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.0to2.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, andPlatformMessageRef ScheduleMessage.