refactor(#22): разделить SessionBatchRenderer на neutral view и Telegram renderer

- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Все вызовы обновлены на новую цепочку ViewBuilder → TelegramRenderer
This commit is contained in:
root
2026-05-06 07:57:23 +00:00
parent 5dee2d87f5
commit 52b7b3e0fd
25 changed files with 1268 additions and 156 deletions
+752
View File
@@ -0,0 +1,752 @@
# Issue #23: Добавить platform identity и platform_messages в БД
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Расширить PostgreSQL-схему нейтральными platform-полями и таблицей platform_messages, чтобы Discord-адаптер мог работать без чтения `telegram_*` legacy-полей. Сохранить обратную совместимость с Telegram-сценариями.
**Architecture:**
- Добавляем generic `platform` enum-like поля (`VARCHAR(50)`) + `external_*` ID-поля рядом с legacy `telegram_*`.
- Создаём таблицу `platform_messages` для отслеживания batch-сообщений на любой платформе.
- Миграция обратима: `telegram_*` НЕ удаляются, новые поля nullable/с дефолтами.
- После миграции обновляем SQL-запросы в handlers (Task 8-12) чтобы сначала писать и читать через новые поля.
**Tech Stack:** PostgreSQL 16+, DbUp, Dapper, .NET 10, Npgsql.
**Milestone:** Версия 2.0 — Discord Bot MVP
---
## Task 1: Написать миграцию V011__add_platform_identity.sql
**Objective:** Создать обратимую миграцию, добавляющую platform-поля и таблицу platform_messages.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql`
**Step 1: Write failing test**
```bash
# Проверяем, что миграция ещё не существует
ls src/GmRelay.Bot/Migrations/V011__*.sql
# Expected: FAIL — file not found
```
**Step 2: Write migration script**
```sql
-- =============================================================
-- V011: Add platform identity fields for multi-platform support
-- Telegram remains default platform; old telegram_* fields kept.
-- =============================================================
-- 1) players: add platform identity columns
ALTER TABLE players
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_user_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255);
-- Populate external_user_id/external_username from existing telegram data
UPDATE players
SET external_user_id = telegram_id::VARCHAR(255),
external_username = telegram_username
WHERE external_user_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_players_platform_external_user
ON players (platform, external_user_id);
-- 2) game_groups: add platform identity columns
ALTER TABLE game_groups
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_group_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE game_groups
SET external_group_id = telegram_chat_id::VARCHAR(255)
WHERE external_group_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_game_groups_platform_external_group
ON game_groups (platform, external_group_id);
-- 3) sessions: add external_channel_id for platform-neutral thread/channel tracking
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE sessions
SET external_channel_id = thread_id::VARCHAR(255)
WHERE external_channel_id IS NULL AND thread_id IS NOT NULL;
-- 4) platform_messages: new table for tracking messages across platforms
CREATE TABLE IF NOT EXISTS platform_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(50) NOT NULL,
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
batch_id UUID NOT NULL,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
external_channel_id VARCHAR(255),
external_thread_id VARCHAR(255),
external_message_id VARCHAR(255) NOT NULL,
purpose VARCHAR(50) NOT NULL DEFAULT 'schedule'
CHECK (purpose IN ('schedule','reminder','confirmation','reschedule_vote','direct_notification')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_platform_messages_group_batch
ON platform_messages (group_id, batch_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_session
ON platform_messages (session_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_purpose
ON platform_messages (purpose);
-- 5) Backfill platform_messages from existing sessions.batch_message_id data
INSERT INTO platform_messages (platform, group_id, batch_id, session_id, external_channel_id, external_message_id, purpose)
SELECT 'telegram',
s.group_id,
s.batch_id,
s.id,
NULL,
s.batch_message_id::VARCHAR(255),
'schedule'
FROM sessions s
WHERE s.batch_message_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM platform_messages pm
WHERE pm.batch_id = s.batch_id AND pm.external_message_id = s.batch_message_id::VARCHAR(255)
);
```
**Step 3: Verify migration exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
# Expected: file exists
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
git commit -m "chore(db): add V011 migration — platform identity and platform_messages"
```
---
## Task 2: Создать shared enum PlatformKind
**Objective:** Ввести типизированное перечисление платформ для использования в коде и БД.
**Files:**
- Create: `src/GmRelay.Shared/Domain/PlatformKind.cs`
- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` (если нужен `[JsonStringEnumConverter]` или Dapper mapping)
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
using GmRelay.Shared.Domain;
public class PlatformKindTests
{
[Fact]
public void Telegram_HasExpectedValue()
{
Assert.Equal("telegram", PlatformKind.Telegram.Value);
}
[Fact]
public void Discord_HasExpectedValue()
{
Assert.Equal("discord", PlatformKind.Discord.Value);
}
[Fact]
public void Equals_SameValue_ReturnsTrue()
{
Assert.Equal(PlatformKind.Telegram, PlatformKind.Telegram);
}
[Fact]
public void Parse_KnownValue_ReturnsInstance()
{
var kind = PlatformKind.Parse("discord");
Assert.Equal(PlatformKind.Discord, kind);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: FAIL — PlatformKind not found
```
**Step 2: Implement PlatformKind**
```csharp
// src/GmRelay.Shared/Domain/PlatformKind.cs
namespace GmRelay.Shared.Domain;
public sealed class PlatformKind : IEquatable<PlatformKind>
{
public static readonly PlatformKind Telegram = new("telegram");
public static readonly PlatformKind Discord = new("discord");
public string Value { get; }
private PlatformKind(string value) => Value = value;
public static PlatformKind Parse(string value)
=> value.ToLowerInvariant() switch
{
"telegram" => Telegram,
"discord" => Discord,
_ => throw new ArgumentException($"Unknown platform: {value}", nameof(value))
};
public bool Equals(PlatformKind? other) => other is not null && Value == other.Value;
public override bool Equals(object? obj) => obj is PlatformKind other && Equals(other);
public override int GetHashCode() => Value.GetHashCode(StringComparison.OrdinalIgnoreCase);
public override string ToString() => Value;
}
```
**Step 3: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Shared/Domain/PlatformKind.cs tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
git commit -m "feat(shared): add PlatformKind value object"
```
---
## Task 3: Создать Dapper TypeHandler для PlatformKind
**Objective:** Обеспечить автоматическую сериализацию/десериализацию PlatformKind ↔ VARCHAR в PostgreSQL.
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs`
- Modify: `src/GmRelay.Bot/Program.cs` или точку регистрации Dapper handlers
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
using Dapper;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Domain;
public class PlatformKindTypeHandlerTests
{
[Fact]
public void PlatformKind_SetValue_ReturnsString()
{
var handler = new PlatformKindTypeHandler();
var param = new Npgsql.NpgsqlParameter();
handler.SetValue(param, PlatformKind.Discord);
Assert.Equal("discord", param.Value);
}
[Fact]
public void PlatformKind_Parse_ReturnsKind()
{
var handler = new PlatformKindTypeHandler();
var result = handler.Parse(typeof(PlatformKind), "telegram");
Assert.Equal(PlatformKind.Telegram, result);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: FAIL — handler not found
```
**Step 2: Implement handler**
```csharp
// src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs
using Dapper;
using GmRelay.Shared.Domain;
using System.Data;
namespace GmRelay.Bot.Infrastructure.Database;
public sealed class PlatformKindTypeHandler : SqlMapper.TypeHandler<PlatformKind>
{
public override void SetValue(IDbDataParameter parameter, PlatformKind value)
{
parameter.Value = value.Value;
}
public override PlatformKind Parse(object value)
{
var str = value as string ?? value.ToString() ?? string.Empty;
return PlatformKind.Parse(str);
}
}
```
**Step 3: Register handler**
В `Program.cs` (или в `DbMigrator` / startup extension) добавить:
```csharp
SqlMapper.AddTypeHandler(new PlatformKindTypeHandler());
```
**Step 4: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: PASS
```
**Step 5: Commit**
```bash
git add src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
git commit -m "feat(db): add Dapper PlatformKindTypeHandler"
```
---
## Task 4: Создать down-migration V011__add_platform_identity.sql (rollback)
**Objective:** Обеспечить обратимость миграции для отката при ошибке.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql`
**Step 1: Write rollback script**
```sql
-- Rollback for V011: remove platform identity fields and platform_messages table
-- NOTE: This drops NEW data. Use only in emergency.
DROP TABLE IF EXISTS platform_messages;
ALTER TABLE sessions DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE game_groups
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_group_id,
DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE players
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_user_id,
DROP COLUMN IF EXISTS external_username;
```
**Step 2: Verify file exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
# Expected: file exists
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
git commit -m "chore(db): add V011 rollback script"
```
---
## Task 5: Прогнать миграцию на integration-БД и проверить структуру
**Objective:** Убедиться, что миграция применяется без ошибок, backfill работает, индексы созданы.
**Files:**
- Test: скрипт в `tests/integration/verify_v011.sql` или ручная проверка через psql
**Step 1: Run migrations**
```bash
cd src/GmRelay.Bot
dotnet run --migrate-only # или через DbMigrator в Program.cs
```
**Step 2: Verify schema**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d players" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d game_groups" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d sessions" | grep external
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages"
```
Expected: все новые поля и таблица присутствуют.
**Step 3: Verify backfill**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM players WHERE external_user_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM game_groups WHERE external_group_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM platform_messages;"
```
Expected: counts > 0 для существующих данных.
**Step 4: Verify rollback works**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages" # should fail (table missing)
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql # re-apply
```
**Step 5: Commit** (если добавили тесты)
```bash
git add tests/integration/verify_v011.sql
git commit -m "test(db): add V011 integration verification"
```
---
## Task 6: Обновить CreateSessionHandler — писать в platform identity при создании
**Objective:** При создании сессии заполнять новые `external_*` поля, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs
[Fact]
public async Task HandleAsync_CreatesPlayerWithPlatformIdentity()
{
// Arrange: mock NpgsqlDataSource + ITelegramBotClient
// Act: send /newsession
// Assert: INSERT into players includes external_user_id = telegram_id string
}
```
Run:
```bash
dotnet test --filter "FullyQualifiedName~CreateSessionHandlerTests"
# Expected: FAIL — test or implementation missing
```
**Step 2: Update handler SQL**
В `CreateSessionHandler.cs`, обновить INSERT для players:
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username;
```
И INSERT для game_groups:
```sql
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'telegram', @ChatId::VARCHAR(255))
RETURNING id;
```
**Step 3: Run handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~CreateSession"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on session creation"
```
---
## Task 7: Обновить JoinSessionHandler — писать в platform identity
**Objective:** При join записывать `platform`, `external_user_id`, `external_username`.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
**Step 1: Update SQL**
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username
RETURNING id;
```
**Step 2: Verify no regression**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~JoinSession"
# Expected: PASS or no existing tests (manual smoke)
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on join"
```
---
## Task 8: Обновить ListSessionsHandler — читать через platform identity
**Objective:** Запрос списка сессий должен работать через `external_group_id`, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs`
**Step 1: Update SQL**
Заменить `g.telegram_chat_id = @ChatId` на `g.external_group_id = @ChatId::VARCHAR(255)`.
```sql
SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, ...
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.external_group_id = @ChatId::VARCHAR(255)
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY ...
ORDER BY s.scheduled_at ASC
```
Также обновить проверку прав:
```sql
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.external_user_id = @TelegramUserId::VARCHAR(255)
) AS CanManage
```
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ListSessions"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs
git commit -m "feat(sessions): read group via external_group_id in list handler"
```
---
## Task 9: Обновить CancelSessionHandler, LeaveSessionHandler, PromoteWaitlistedPlayerHandler
**Objective:** Аналогично Task 7-8: обновить SQL запросы на использование `external_user_id` и `external_group_id`.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
**Step 1: Update SQL in each handler**
Replace patterns:
- `p.telegram_id = @TelegramUserId``p.external_user_id = @TelegramUserId::VARCHAR(255)`
- `g.telegram_chat_id = @ChatId``g.external_group_id = @ChatId::VARCHAR(255)`
- `s.group_id = g.id` stays
**Step 2: Run all session handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Session"
# Expected: all PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/
git commit -m "feat(sessions): migrate session handlers to platform identity fields"
```
---
## Task 10: Обновить Reschedule и Reminder handlers
**Objective:** Обновить reschedule handlers и reminder sender на platform identity.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
- `src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs`
- `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
**Step 1: Update SQL**
Replace `telegram_id` / `telegram_chat_id` usages with `external_user_id` / `external_group_id` where это lookup.
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule"
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reminder"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ src/GmRelay.Bot/Features/Reminders/ src/GmRelay.Bot/Features/Notifications/
git commit -m "feat(reschedule,reminders): migrate to platform identity fields"
```
---
## Task 11: Обновить Web Dashboard queries (TelegramAuthService, GroupDetails, MiniApp)
**Objective:** Веб-компоненты, которые читают `telegram_id`, должны также поддерживать `external_user_id`.
**Files:**
- Modify:
- `src/GmRelay.Web/Services/TelegramAuthService.cs`
- `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
**Step 1: Update TelegramAuthService**
```sql
SELECT p.id, p.display_name, p.telegram_username, p.external_user_id, p.external_username
FROM players p
WHERE p.telegram_id = @TelegramId
OR p.external_user_id = @TelegramId::VARCHAR(255)
```
**Step 2: Update GroupDetails.razor queries**
Replace `telegram_chat_id` lookups with `external_group_id`.
**Step 3: Run web tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Web"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/
git commit -m "feat(web): migrate web dashboard to platform identity fields"
```
---
## Task 12: Создать документацию по platform identity в schema notes
**Objective:** Зафиксировать, что `telegram_*` deprecated, а новые поля — путь вперёд.
**Files:**
- Create: `docs/db-schema/platform-migration-notes.md`
**Step 1: Write doc**
```markdown
# Platform Identity Migration Notes
## Status
- `telegram_*` fields: DEPRECATED but kept for backward compatibility.
- `platform`, `external_*` fields: ACTIVE, used by all new handlers.
## Tables
| Table | Legacy | New Fields |
|-------|--------|-----------|
| players | telegram_id, telegram_username | platform, external_user_id, external_username |
| game_groups | telegram_chat_id | platform, external_group_id, external_channel_id |
| sessions | thread_id, batch_message_id | external_channel_id |
| platform_messages | — | NEW table tracking all batch messages |
## Backfill
V011 migration backfills `external_*` from existing `telegram_*` data.
## Future
V012+ (or separate migration) will eventually drop `telegram_*` once all code paths use `external_*`.
```
**Step 2: Commit**
```bash
git add docs/db-schema/platform-migration-notes.md
git commit -m "docs(db): add platform identity migration notes"
```
---
## Task 13: Final integration smoke test
**Objective:** Убедиться, что весь бот стартует, миграции проходят, и основные команды работают.
**Step 1: Build**
```bash
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj -c Release
# Expected: 0 errors, 0 warnings
```
**Step 2: Run all tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --no-build
# Expected: all PASS
```
**Step 3: Start bot and test /newsession + /listsessions**
```bash
dotnet run --project src/GmRelay.Bot/GmRelay.Bot.csproj
# Manual smoke in Telegram: /newsession → verify session created, players.external_user_id populated
```
**Step 4: Commit final state**
```bash
git status # should be clean
git log --oneline -5
```
---
## Verification Checklist
- [ ] V011 migration applied successfully with backfill
- [ ] Rollback script tested and works
- [ ] PlatformKind + Dapper TypeHandler present and tested
- [ ] All session handlers use `external_*` fields
- [ ] Telegram commands (`/newsession`, `/listsessions`, join/leave) still work
- [ ] Web dashboard auth and group pages still work
- [ ] All tests pass
- [ ] No compiler warnings
+4
View File
@@ -0,0 +1,4 @@
usage: hermes mcp [-h] [--accept-hooks]
{serve,add,remove,rm,list,ls,test,configure,config,login}
...
hermes mcp: error: argument mcp_action: invalid choice: 'call' (choose from 'serve', 'add', 'remove', 'rm', 'list', 'ls', 'test', 'configure', 'config', 'login')
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.9.9</Version>
<Version>1.10.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.9
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.0
restart: always
depends_on:
db:
@@ -30,7 +30,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.9
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.0
restart: always
depends_on:
db:
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -102,7 +103,8 @@ public sealed class CancelSessionHandler(
await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
try
{
@@ -4,6 +4,7 @@ using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -183,7 +184,8 @@ public sealed class CreateSessionHandler(
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
Message batchMessage;
@@ -4,6 +4,7 @@ using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -136,7 +137,8 @@ public sealed class JoinSessionHandler(
transactionCommitted = true;
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -182,7 +183,8 @@ public sealed class LeaveSessionHandler(
await transaction.CommitAsync(ct);
transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -162,7 +163,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
await transaction.CommitAsync(ct);
transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
@@ -6,6 +6,7 @@ using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -375,8 +376,8 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(
proposal.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -304,7 +305,8 @@ public sealed class RescheduleVotingDeadlineService(
if (proposal.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
@@ -0,0 +1,51 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <summary>
/// Handles editing batch messages that may be either text or photo messages.
/// When the batch was created with SendPhoto (image + caption), we need
/// EditMessageCaption instead of EditMessageText.
/// </summary>
public static class BatchMessageEditor
{
/// <summary>
/// Edits a batch message, automatically detecting whether it is a text or photo message.
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
/// </summary>
public static async Task EditBatchMessageAsync(
ITelegramBotClient bot,
long chatId,
int messageId,
string text,
InlineKeyboardMarkup? replyMarkup,
CancellationToken ct = default)
{
try
{
await bot.EditMessageText(
chatId: chatId,
messageId: messageId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
{
// The batch message is a photo — use EditMessageCaption instead.
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
await bot.EditMessageCaption(
chatId: chatId,
messageId: messageId,
caption: caption,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
}
}
@@ -0,0 +1,56 @@
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public static class TelegramSessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
{
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in view.Sessions)
{
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var actionRow = session.AvailableActions
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
.ToArray();
if (actionRow.Length > 0)
buttons.Add(actionRow);
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
}
-4
View File
@@ -7,8 +7,4 @@
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
</ItemGroup>
</Project>
@@ -0,0 +1,13 @@
namespace GmRelay.Shared.Rendering;
/// <summary>
/// Заглушка для Discord-рендерера.
/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26).
/// </summary>
public static class DiscordSessionBatchRenderer
{
public static object Render(SessionBatchViewModel view)
{
throw new NotImplementedException("Discord renderer will be implemented in issue #26.");
}
}
@@ -0,0 +1,4 @@
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -1,81 +0,0 @@
using GmRelay.Shared.Domain;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
public static class SessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(
string title,
IReadOnlyList<SessionBatchDto> sessions,
IReadOnlyList<ParticipantBatchDto> participants)
{
var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in activeSessions)
{
var sessionPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
.ToList();
var waitlistedPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.ToList();
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({sessionPlayers.Count}):\n";
if (sessionPlayers.Count > 0)
{
messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (waitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n";
messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"),
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}")
});
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
{
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
{
return $"⏳ В лист ожидания {dateTitle}";
}
return $"✋ На {dateTitle}";
}
}
@@ -0,0 +1,59 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Rendering;
public static class SessionBatchViewBuilder
{
public static SessionBatchViewModel Build(
string title,
IReadOnlyList<SessionBatchDto> sessions,
IReadOnlyList<ParticipantBatchDto> participants)
{
var orderedSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
var sessionItems = new List<SessionViewItem>();
foreach (var session in orderedSessions)
{
var activePlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
.ToList();
var waitlistedPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
.ToList();
var actions = new List<AvailableAction>();
if (!SessionStatus.IsCancelled(session.Status))
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
var joinLabel = GetJoinButtonText(session, activePlayers.Count, dateTitle);
actions.Add(new AvailableAction("join_session", joinLabel, session.SessionId));
actions.Add(new AvailableAction("leave_session", $"🚪 Выйти {dateTitle}", session.SessionId));
}
sessionItems.Add(new SessionViewItem(
session.SessionId,
session.ScheduledAt,
session.Status,
session.MaxPlayers,
activePlayers.Count,
activePlayers,
waitlistedPlayers,
actions));
}
return new SessionBatchViewModel(title, sessionItems);
}
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
{
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
{
return $"⏳ В лист ожидания {dateTitle}";
}
return $"✋ На {dateTitle}";
}
}
@@ -0,0 +1,27 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchViewModel(
string Title,
IReadOnlyList<SessionViewItem> Sessions);
public sealed record SessionViewItem(
Guid SessionId,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
IReadOnlyList<AvailableAction> AvailableActions);
public sealed record PlayerViewItem(
string DisplayName,
string? TelegramUsername,
string RegistrationStatus);
public sealed record AvailableAction(
string ActionKey,
string Label,
Guid SessionId);
@@ -2,7 +2,7 @@ using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
namespace GmRelay.Web.Services;
/// <summary>
/// Handles editing batch messages that may be either text or photo messages.
+7 -3
View File
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Web.Services;
namespace GmRelay.Web.Services;
@@ -883,7 +884,8 @@ public sealed class SessionService(
await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
@@ -1091,7 +1093,8 @@ public sealed class SessionService(
await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId,
text: renderResult.Text,
@@ -1198,7 +1201,8 @@ public sealed class SessionService(
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = batchId })).ToList();
var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
var view = SessionBatchViewBuilder.Build(title, sessions, participants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
@@ -0,0 +1,56 @@
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Web.Services;
public static class TelegramSessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
{
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in view.Sessions)
{
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var actionRow = session.AvailableActions
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
.ToArray();
if (actionRow.Length > 0)
buttons.Add(actionRow);
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
}
@@ -1,56 +0,0 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchRendererTests
{
[Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var result = SessionBatchRenderer.Render("Campaign", sessions, participants);
var text = result.Text;
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
Assert.Contains("Campaign", text);
Assert.True(firstIndex < secondIndex);
Assert.True(secondIndex < thirdIndex);
Assert.Contains("Места: 0/2", text);
Assert.Contains("Места: 1/4", text);
Assert.Contains("@alice", text);
Assert.Contains("Лист ожидания (1)", text);
Assert.Contains("Charlie", text);
Assert.Contains("Bob", text);
Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Collection(
buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData));
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("cancel_session:", StringComparison.Ordinal) == true);
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("reschedule_session:", StringComparison.Ordinal) == true);
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("promote_waitlist:", StringComparison.Ordinal) == true);
}
}
@@ -0,0 +1,113 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchViewBuilderTests
{
[Fact]
public void Build_ShouldOrderSessionsByDate()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var result = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
Assert.Equal("Campaign", result.Title);
Assert.Equal(3, result.Sessions.Count);
Assert.Equal(firstSessionId, result.Sessions[0].SessionId);
Assert.Equal(secondSessionId, result.Sessions[1].SessionId);
Assert.Equal(cancelledSessionId, result.Sessions[2].SessionId);
}
[Fact]
public void Build_ShouldCalculatePlayerCounts()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
var participants = new[]
{
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted)
};
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var session = result.Sessions[0];
Assert.Equal(2, session.ActivePlayerCount);
Assert.Equal(4, session.MaxPlayers);
Assert.Equal(2, session.ActivePlayers.Count);
Assert.Equal(1, session.WaitlistedPlayers.Count);
}
[Fact]
public void Build_ShouldIncludeActionsForActiveSessions()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var actions = result.Sessions[0].AvailableActions;
Assert.Equal(2, actions.Count);
Assert.Equal("join_session", actions[0].ActionKey);
Assert.Equal("leave_session", actions[1].ActionKey);
Assert.Equal(sessionId, actions[0].SessionId);
Assert.Equal(sessionId, actions[1].SessionId);
}
[Fact]
public void Build_ShouldNotIncludeActionsForCancelledSessions()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
Assert.Empty(result.Sessions[0].AvailableActions);
}
[Fact]
public void Build_ShouldMarkWaitlistActionWhenFull()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
Assert.Contains("ожидания", joinAction.Label);
}
[Fact]
public void Build_ShouldIncludePlayerUsernames()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null) };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var player = result.Sessions[0].ActivePlayers[0];
Assert.Equal("Alice", player.DisplayName);
Assert.Equal("alice", player.TelegramUsername);
}
}
@@ -0,0 +1,101 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class TelegramSessionBatchRendererTests
{
[Fact]
public void Render_ShouldProduceSameTextAsOldRenderer()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
// Old renderer output
var (oldText, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
// New pipeline output
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
var (newText, newMarkup) = TelegramSessionBatchRenderer.Render(view);
Assert.Equal(oldText, newText);
}
[Fact]
public void Render_ShouldProduceSameButtonsAsOldRenderer()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var (_, oldMarkup) = SessionBatchRenderer.Render("Campaign", sessions, participants);
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
var (_, newMarkup) = TelegramSessionBatchRenderer.Render(view);
var oldButtons = oldMarkup.InlineKeyboard.SelectMany(row => row).ToList();
var newButtons = newMarkup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(oldButtons.Count, newButtons.Count);
for (int i = 0; i < oldButtons.Count; i++)
{
Assert.Equal(oldButtons[i].CallbackData, newButtons[i].CallbackData);
Assert.Equal(oldButtons[i].Text, newButtons[i].Text);
}
}
[Fact]
public void Render_ShouldSkipButtonsForCancelledSessions()
{
var cancelledSessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
Assert.Empty(markup.InlineKeyboard);
}
[Fact]
public void Render_ShouldShowWaitlistButtonWhenFull()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
var joinButton = buttons.First(b => b.CallbackData?.StartsWith("join_session:") == true);
Assert.Contains("ожидания", joinButton.Text);
}
}