Compare commits

...

3 Commits

Author SHA1 Message Date
Toutsu ab59d234f3 Merge pull request #82: refactor: make session join leave platform-neutral
Deploy Telegram Bot / build-and-push (push) Successful in 3m45s
Deploy Telegram Bot / scan-images (push) Successful in 1m0s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-18 13:38:22 +03:00
Toutsu e791fc2f4a refactor: make session join leave platform-neutral
PR Checks / test-and-build (pull_request) Successful in 5m3s
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
Toutsu cb515b0e05 Merge pull request #81: feat: refresh dashboard design with fantasy RPG aesthetic
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / scan-images (push) Successful in 1m4s
Deploy Telegram Bot / deploy (push) Successful in 11s
🎨 Dashboard design refresh
- Complete fantasy RPG aesthetic overhaul
- Glass-morphism cards with gradient borders
- Cinzel + Jura typography
- Atmospheric backgrounds with noise texture

🧹 Chore: migrated k8s manifests to gmrelay-k8s repo

Bump version → 2.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:29:16 +03:00
12 changed files with 803 additions and 56 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 2.1.0 VERSION: 2.1.1
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.1.0</Version> <Version>2.1.1</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+2 -2
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.0 image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -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<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:
```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<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`:
```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<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:
```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<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:
```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
<Version>2.1.1</Version>
```
```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
<div class="nav-version">v2.1.1</div>
```
- [ ] **Step 2: Verify synchronized versions**
Run:
```powershell
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**
```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`.
@@ -1,20 +1,18 @@
using System.Globalization;
using Dapper; using Dapper;
using Npgsql; using Npgsql;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand( public sealed record JoinSessionCommand(
Guid SessionId, Guid SessionId,
long TelegramUserId, PlatformUser User,
string DisplayName, string InteractionId,
string? TelegramUsername, PlatformGroup Group,
string CallbackQueryId, PlatformMessageRef ScheduleMessage);
long ChatId,
int MessageId);
// DTOs for AOT compilation // DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers); internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
@@ -33,17 +31,35 @@ public sealed class JoinSessionHandler(
try try
{ {
// 1. Убеждаемся, что игрок есть в базе // 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<Guid>( var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) @"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username) VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (telegram_id) DO UPDATE 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, SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username, telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = COALESCE(players.platform, 'Telegram'), platform = EXCLUDED.platform,
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT), external_user_id = EXCLUDED.external_user_id,
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username) external_username = EXCLUDED.external_username
RETURNING id;", 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); transaction);
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав. // 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
@@ -58,7 +74,7 @@ public sealed class JoinSessionHandler(
if (batchInfo is null) if (batchInfo is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct); await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return; return;
} }
@@ -79,7 +95,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!" ? "Вы уже в листе ожидания!"
: "Вы уже записаны!"; : "Вы уже записаны!";
await AnswerAsync(command.CallbackQueryId, alreadyText, ct); await AnswerAsync(command.InteractionId, alreadyText, ct);
return; return;
} }
@@ -113,7 +129,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0) if (inserted == 0)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct); await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
return; return;
} }
@@ -128,7 +144,7 @@ public sealed class JoinSessionHandler(
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, @"SELECT sp.session_id as SessionId,
p.display_name as DisplayName, p.display_name as DisplayName,
p.telegram_username as TelegramUsername, COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
sp.registration_status as RegistrationStatus sp.registration_status as RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id 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()); var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync( await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage( new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId), command.Group,
view, view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)), command.ScheduleMessage),
ct); ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания." ? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!"; : "Вы успешно записаны!";
await AnswerAsync(command.CallbackQueryId, callbackText, ct); await AnswerAsync(command.InteractionId, callbackText, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -165,10 +181,10 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания." ? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации."; : "Произошла ошибка при регистрации.";
await AnswerAsync(command.CallbackQueryId, errorText, ct); await AnswerAsync(command.InteractionId, errorText, ct);
} }
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) => private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct); messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
} }
@@ -3,16 +3,15 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand( public sealed record LeaveSessionCommand(
Guid SessionId, Guid SessionId,
long TelegramUserId, PlatformUser User,
string CallbackQueryId, string InteractionId,
long ChatId, PlatformGroup Group,
int MessageId); PlatformMessageRef ScheduleMessage);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers); internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus); internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
@@ -47,17 +46,19 @@ public sealed class LeaveSessionHandler(
if (session is null) if (session is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct); await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return; return;
} }
if (SessionStatus.IsCancelled(session.Status)) if (SessionStatus.IsCancelled(session.Status))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct); await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return; return;
} }
var platform = command.User.Platform.ToString();
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>( var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
""" """
SELECT sp.id AS ParticipantRowId, SELECT sp.id AS ParticipantRowId,
@@ -66,17 +67,18 @@ public sealed class LeaveSessionHandler(
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId 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 AND sp.is_gm = false
FOR UPDATE OF sp FOR UPDATE OF sp
""", """,
new { command.SessionId, command.TelegramUserId }, new { command.SessionId, Platform = platform, command.User.ExternalUserId },
transaction); transaction);
if (participant is null) if (participant is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct); await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
return; return;
} }
@@ -170,7 +172,7 @@ public sealed class LeaveSessionHandler(
""" """
SELECT sp.session_id AS SessionId, SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
sp.registration_status AS RegistrationStatus sp.registration_status AS RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id 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); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync( await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage( new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId), command.Group,
view, view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)), command.ScheduleMessage),
ct); ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
@@ -198,7 +200,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии." ? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await AnswerAsync(command.CallbackQueryId, callbackText, ct); await AnswerAsync(command.InteractionId, callbackText, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -211,10 +213,10 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания." ? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи."; : "Произошла ошибка при отмене записи.";
await AnswerAsync(command.CallbackQueryId, errorText, ct); await AnswerAsync(command.InteractionId, errorText, ct);
} }
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) => private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct); messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
} }
@@ -69,17 +69,21 @@ public sealed class UpdateRouter(
var parts = data.Split(':', 3); var parts = data.Split(':', 3);
var action = parts[0]; 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)) if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
{ {
var command = new JoinSessionCommand( var command = new JoinSessionCommand(
SessionId: joinSessionId, SessionId: joinSessionId,
TelegramUserId: query.From.Id, User: user,
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"), InteractionId: query.Id,
TelegramUsername: query.From.Username, Group: group,
CallbackQueryId: query.Id, ScheduleMessage: scheduleMessage);
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await joinSessionHandler.HandleAsync(command, ct); await joinSessionHandler.HandleAsync(command, ct);
return; return;
@@ -89,10 +93,10 @@ public sealed class UpdateRouter(
{ {
var command = new LeaveSessionCommand( var command = new LeaveSessionCommand(
SessionId: leaveSessionId, SessionId: leaveSessionId,
TelegramUserId: query.From.Id, User: user,
CallbackQueryId: query.Id, InteractionId: query.Id,
ChatId: message.Chat.Id, Group: group,
MessageId: message.MessageId); ScheduleMessage: scheduleMessage);
await leaveSessionHandler.HandleAsync(command, ct); await leaveSessionHandler.HandleAsync(command, ct);
return; return;
@@ -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;
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v2.1.0</div> <div class="nav-version">v2.1.1</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -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<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);
}
}
@@ -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<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}'.");
}
}
@@ -99,6 +99,15 @@ public sealed class PlatformIdentityMigrationTests
Assert.Contains("external_username", statsMigration, StringComparison.Ordinal); 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<string> ReadRepositoryFileAsync(string relativePath) private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{ {
var directory = new DirectoryInfo(AppContext.BaseDirectory); var directory = new DirectoryInfo(AppContext.BaseDirectory);