diff --git a/.hermes/plans/gmrelay-issue-23.md b/.hermes/plans/gmrelay-issue-23.md new file mode 100644 index 0000000..1036475 --- /dev/null +++ b/.hermes/plans/gmrelay-issue-23.md @@ -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 +{ + 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 +{ + 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 diff --git a/1 b/1 new file mode 100644 index 0000000..368fccf --- /dev/null +++ b/1 @@ -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') diff --git a/Directory.Build.props b/Directory.Build.props index 2eda9c7..f5c440c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.9.9 + 1.10.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index 791836d..e380308 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 71057cb..d0b40db 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -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 { diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 853da9b..cf442b8 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -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()); + var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty()); + var renderResult = TelegramSessionBatchRenderer.Render(view); Message batchMessage; diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs index a7aec54..27aa360 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -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, diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs index 9ba1415..d8c4258 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs @@ -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, diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs index 7d0c650..2850de6 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -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, diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index 078c8cc..9e12054 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -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, diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index a678649..3a4a847 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -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, diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/BatchMessageEditor.cs b/src/GmRelay.Bot/Infrastructure/Telegram/BatchMessageEditor.cs new file mode 100644 index 0000000..96a51cc --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/BatchMessageEditor.cs @@ -0,0 +1,51 @@ +using Telegram.Bot; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace GmRelay.Bot.Infrastructure.Telegram; + +/// +/// 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. +/// +public static class BatchMessageEditor +{ + /// + /// Edits a batch message, automatically detecting whether it is a text or photo message. + /// Tries EditMessageText first; on failure falls back to EditMessageCaption. + /// + 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); + } + } +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs new file mode 100644 index 0000000..60fa0d7 --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs @@ -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 = $"🎲 Новые игры: {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" + + $"Расписание:\n\n"; + + var buttons = new List(); + + foreach (var session in view.Sessions) + { + messageText += $"📅 {session.ScheduledAt.FormatMoscow()}\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 += " Пока никто не записался\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 += "❌ Сессия отменена\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)); + } +} diff --git a/src/GmRelay.Shared/GmRelay.Shared.csproj b/src/GmRelay.Shared/GmRelay.Shared.csproj index a14b1b5..97acdba 100644 --- a/src/GmRelay.Shared/GmRelay.Shared.csproj +++ b/src/GmRelay.Shared/GmRelay.Shared.csproj @@ -7,8 +7,4 @@ preview - - - - diff --git a/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs new file mode 100644 index 0000000..a796f16 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/DiscordSessionBatchRenderer.cs @@ -0,0 +1,13 @@ +namespace GmRelay.Shared.Rendering; + +/// +/// Заглушка для Discord-рендерера. +/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26). +/// +public static class DiscordSessionBatchRenderer +{ + public static object Render(SessionBatchViewModel view) + { + throw new NotImplementedException("Discord renderer will be implemented in issue #26."); + } +} diff --git a/src/GmRelay.Shared/Rendering/SessionBatchDto.cs b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs new file mode 100644 index 0000000..658ed43 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs @@ -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); diff --git a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs deleted file mode 100644 index 6564a4f..0000000 --- a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs +++ /dev/null @@ -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 sessions, - IReadOnlyList participants) - { - var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList(); - - var messageText = $"🎲 Новые игры: {System.Net.WebUtility.HtmlEncode(title)}\n\n" + - $"Расписание:\n\n"; - - var buttons = new List(); - - 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 += $"📅 {session.ScheduledAt.FormatMoscow()}\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 += " Пока никто не записался\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 += "❌ Сессия отменена\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}"; - } -} diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs new file mode 100644 index 0000000..b7bf2a6 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs @@ -0,0 +1,59 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Shared.Rendering; + +public static class SessionBatchViewBuilder +{ + public static SessionBatchViewModel Build( + string title, + IReadOnlyList sessions, + IReadOnlyList participants) + { + var orderedSessions = sessions.OrderBy(s => s.ScheduledAt).ToList(); + var sessionItems = new List(); + + 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(); + 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}"; + } +} diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs new file mode 100644 index 0000000..95191d5 --- /dev/null +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs @@ -0,0 +1,27 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Shared.Rendering; + +public sealed record SessionBatchViewModel( + string Title, + IReadOnlyList Sessions); + +public sealed record SessionViewItem( + Guid SessionId, + DateTime ScheduledAt, + string Status, + int? MaxPlayers, + int ActivePlayerCount, + IReadOnlyList ActivePlayers, + IReadOnlyList WaitlistedPlayers, + IReadOnlyList AvailableActions); + +public sealed record PlayerViewItem( + string DisplayName, + string? TelegramUsername, + string RegistrationStatus); + +public sealed record AvailableAction( + string ActionKey, + string Label, + Guid SessionId); diff --git a/src/GmRelay.Shared/Rendering/BatchMessageEditor.cs b/src/GmRelay.Web/Services/BatchMessageEditor.cs similarity index 98% rename from src/GmRelay.Shared/Rendering/BatchMessageEditor.cs rename to src/GmRelay.Web/Services/BatchMessageEditor.cs index 194b119..0906c75 100644 --- a/src/GmRelay.Shared/Rendering/BatchMessageEditor.cs +++ b/src/GmRelay.Web/Services/BatchMessageEditor.cs @@ -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; /// /// Handles editing batch messages that may be either text or photo messages. diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index e9ba54d..88145ee 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -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()); + var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty()); + 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()); + var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty()); + 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, diff --git a/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs new file mode 100644 index 0000000..28919f7 --- /dev/null +++ b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs @@ -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 = $"🎲 Новые игры: {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" + + $"Расписание:\n\n"; + + var buttons = new List(); + + foreach (var session in view.Sessions) + { + messageText += $"📅 {session.ScheduledAt.FormatMoscow()}\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 += " Пока никто не записался\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 += "❌ Сессия отменена\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)); + } +} diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs deleted file mode 100644 index 918c27b..0000000 --- a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs +++ /dev/null @@ -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); - } -} diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs new file mode 100644 index 0000000..12c9d91 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchViewBuilderTests.cs @@ -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(); + + 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(); + + 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); + } +} diff --git a/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs new file mode 100644 index 0000000..119d6ae --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs @@ -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(); + + 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); + } +}