Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab59d234f3 | |||
| e791fc2f4a | |||
| cb515b0e05 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 2.1.0
|
VERSION: 2.1.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+47
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user