From b57332bd5c52d13ebc290180150c4ce706b9fe61 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 21 May 2026 18:58:57 +0300 Subject: [PATCH] chore: remove AI working directories (docs/superpowers, docs/plans) from repo Add docs/superpowers/, docs/plans/, *.diff to .gitignore. These directories contain implementation plans and design specs used during agentic development; they are not needed in source control. Co-Authored-By: Claude Opus 4.7 --- .gitignore | Bin 316 -> 380 bytes .../2026-05-04-player-list-kick-waitlist.md | 438 ----- .../2026-04-28-telegram-mini-app-dashboard.md | 69 - ...2026-05-15-platform-messenger-contracts.md | 560 ------- .../2026-05-18-discord-netcord-gateway.md | 731 --------- .../2026-05-18-platform-neutral-join-leave.md | 599 ------- ...6-05-19-discord-newsession-listsessions.md | 984 ----------- .../2026-05-20-discord-reschedule-voting.md | 1433 ----------------- ...tform-messenger-scheduler-notifications.md | 1144 ------------- ...4-28-telegram-mini-app-dashboard-design.md | 44 - ...essenger-scheduler-notifications-design.md | 140 -- ...26-05-21-documentation-sync-mvp2-design.md | 166 -- 12 files changed, 6308 deletions(-) delete mode 100644 docs/plans/2026-05-04-player-list-kick-waitlist.md delete mode 100644 docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md delete mode 100644 docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md delete mode 100644 docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md delete mode 100644 docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md delete mode 100644 docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md delete mode 100644 docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md delete mode 100644 docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md delete mode 100644 docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md delete mode 100644 docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md delete mode 100644 docs/superpowers/specs/2026-05-21-documentation-sync-mvp2-design.md diff --git a/.gitignore b/.gitignore index 934d16207fcf523d5556d2145ca86fb7f0101c05..fed626e8d7aff9709587872cfa012d7647370ca9 100644 GIT binary patch delta 73 zcmdnP^oMD}4|^_UWd%o1h4TEO?99A$g_O*q)Z~)GkgcVcl9`q^@w+Pk=cyYr delta 10 Rcmeyvw1;WJkI5E{E&v-;1XTb4 diff --git a/docs/plans/2026-05-04-player-list-kick-waitlist.md b/docs/plans/2026-05-04-player-list-kick-waitlist.md deleted file mode 100644 index bb69ef6..0000000 --- a/docs/plans/2026-05-04-player-list-kick-waitlist.md +++ /dev/null @@ -1,438 +0,0 @@ -# Player List + Kick + Waitlist Promotion Implementation Plan - -> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. - -**Goal:** Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player. - -**Architecture:** Extend `ISessionStore` with participant queries and a remove method. Update `GroupDetails.razor` to show expandable participant lists. Reuse existing `PromoteWaitlistedPlayerAsync` logic after removal. - -**Tech Stack:** C# 14, Blazor SSR, Dapper, PostgreSQL - ---- - -## Task 1: Add domain model for WebParticipant - -**Objective:** Create a DTO to represent a session participant in the web layer. - -**Files:** -- Modify: `src/GmRelay.Web/Services/SessionService.cs` - -**Step 1: Add record** - -```csharp -public sealed record WebParticipant( - Guid Id, - long TelegramId, - string DisplayName, - string? TelegramUsername, - string RsvpStatus, - string RegistrationStatus, - bool IsGm, - DateTime? RespondedAt); -``` - -**Step 2: Commit** - -```bash -git add src/GmRelay.Web/Services/SessionService.cs -git commit -m "feat: add WebParticipant record" -``` - ---- - -## Task 2: Add GetSessionParticipantsAsync to ISessionStore - -**Objective:** Retrieve all participants for a session with full player info. - -**Files:** -- Modify: `src/GmRelay.Web/Services/ISessionStore.cs` -- Modify: `src/GmRelay.Web/Services/SessionService.cs` -- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs` - -**Step 1: Add to interface** - -In `ISessionStore.cs`, add: -```csharp -Task> GetSessionParticipantsAsync(Guid sessionId); -``` - -**Step 2: Implement in SessionService** - -In `SessionService.cs`, add: -```csharp -public async Task> GetSessionParticipantsAsync(Guid sessionId) -{ - await using var conn = await dataSource.OpenConnectionAsync(); - return (await conn.QueryAsync( - """ - SELECT sp.id AS Id, - p.telegram_id AS TelegramId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, - sp.rsvp_status AS RsvpStatus, - sp.registration_status AS RegistrationStatus, - sp.is_gm AS IsGm, - sp.responded_at AS RespondedAt - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - ORDER BY sp.is_gm DESC, - CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END, - sp.created_at - """, - new { SessionId = sessionId })).ToList(); -} -``` - -**Step 3: Add authorized wrapper** - -In `AuthorizedSessionService.cs`, add: -```csharp -public async Task?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId) -{ - var session = await GetSessionForGmAsync(sessionId, gmId); - if (session is null) - { - return null; - } - - return await sessionStore.GetSessionParticipantsAsync(sessionId); -} -``` - -**Step 4: Commit** - -```bash -git add src/GmRelay.Web/Services/ISessionStore.cs - -git add src/GmRelay.Web/Services/SessionService.cs - -git add src/GmRelay.Web/Services/AuthorizedSessionService.cs - -git commit -m "feat: add GetSessionParticipantsAsync" -``` - ---- - -## Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion - -**Objective:** Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met. - -**Files:** -- Modify: `src/GmRelay.Web/Services/ISessionStore.cs` -- Modify: `src/GmRelay.Web/Services/SessionService.cs` -- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs` - -**Step 1: Add to interface** - -In `ISessionStore.cs`, add: -```csharp -Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); -``` - -**Step 2: Implement in SessionService** - -In `SessionService.cs`, add: -```csharp -public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) -{ - await using var conn = await dataSource.OpenConnectionAsync(); - await using var transaction = await conn.BeginTransactionAsync(); - - var session = await conn.QuerySingleOrDefaultAsync( - @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, - s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, - g.telegram_chat_id AS TelegramChatId, - s.max_players AS MaxPlayers, - 0 AS ActivePlayerCount, - 0 AS WaitlistedPlayerCount, - s.notification_mode AS NotificationMode - FROM sessions s - JOIN game_groups g ON g.id = s.group_id - WHERE s.id = @SessionId AND s.group_id = @GroupId - FOR UPDATE", - new { SessionId = sessionId, GroupId = groupId }, - transaction); - - if (session is null) - { - throw new SessionAccessDeniedException(sessionId, 0); - } - - // Verify participant exists in this session - var participant = await conn.QuerySingleOrDefaultAsync( - """ - SELECT sp.id AS Id, - p.telegram_id AS TelegramId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, - sp.rsvp_status AS RsvpStatus, - sp.registration_status AS RegistrationStatus, - sp.is_gm AS IsGm, - sp.responded_at AS RespondedAt - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId - """, - new { ParticipantId = participantId, SessionId = sessionId }, - transaction); - - if (participant is null) - { - throw new InvalidOperationException("Участник не найден в этой сессии."); - } - - bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active; - - await conn.ExecuteAsync( - "DELETE FROM session_participants WHERE id = @ParticipantId", - new { ParticipantId = participantId }, - transaction); - - WebPromotedParticipantDto? promoted = null; - - if (wasActive) - { - promoted = await conn.QuerySingleOrDefaultAsync( - """ - SELECT sp.id AS ParticipantRowId, - p.display_name AS DisplayName - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND sp.is_gm = false - AND sp.registration_status = @Waitlisted - ORDER BY sp.created_at ASC, sp.id ASC - LIMIT 1 - FOR UPDATE OF sp - """, - new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, - transaction); - - if (promoted is not null) - { - await conn.ExecuteAsync( - """ - UPDATE session_participants - SET registration_status = @Active, - rsvp_status = @Pending, - responded_at = NULL - WHERE id = @ParticipantRowId - """, - new - { - promoted.ParticipantRowId, - Active = ParticipantRegistrationStatus.Active, - Pending = RsvpStatus.Pending - }, - transaction); - } - } - - await transaction.CommitAsync(); - - // Notifications - await bot.SendMessage( - session.TelegramChatId, - $"🚪 {System.Net.WebUtility.HtmlEncode(participant.DisplayName)} удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».", - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); - - if (promoted is not null) - { - await bot.SendMessage( - session.TelegramChatId, - $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); - } - - if (session.BatchMessageId.HasValue) - { - await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title); - } -} -``` - -**Step 3: Add authorized wrapper** - -In `AuthorizedSessionService.cs`, add: -```csharp -public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId) -{ - var session = await GetSessionForGmAsync(sessionId, gmId); - if (session is null) - { - throw new SessionAccessDeniedException(sessionId, gmId); - } - - await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId); -} -``` - -**Step 4: Commit** - -```bash -git add src/GmRelay.Web/Services/ISessionStore.cs - -git add src/GmRelay.Web/Services/SessionService.cs - -git add src/GmRelay.Web/Services/AuthorizedSessionService.cs - -git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion" -``` - ---- - -## Task 4: Modify GroupDetails.razor to show participant list - -**Objective:** Add expandable player lists to each session row with kick buttons. - -**Files:** -- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor` - -**Step 1:** Add `participants` dictionary and `kickingParticipantId` state variables. - -**Step 2:** Add `LoadParticipants(Guid sessionId)` and `KickParticipant(Guid sessionId, Guid participantId)` methods. - -**Step 3:** In desktop table, add a new column or expand row with participant list. - -**Step 4:** In mobile cards, add expandable participant section. - -**Step 5:** Add styles to `app.css` if needed (badge styles are already present). - -**Step 6:** Commit - -```bash -git add src/GmRelay.Web/Components/Pages/GroupDetails.razor - -git add src/GmRelay.Web/wwwroot/app.css - -git commit -m "feat: show player list and kick button in GroupDetails" -``` - ---- - -## Task 5: Modify EditSession.razor to show participant list - -**Objective:** Show participant list on the edit page with kick capability. - -**Files:** -- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor` - -**Step 1:** Load participants in `OnInitializedAsync`. - -**Step 2:** Render participant list below the edit form. - -**Step 3:** Add kick button for each non-GM participant. - -**Step 4:** Commit - -```bash -git add src/GmRelay.Web/Components/Pages/EditSession.razor - -git commit -m "feat: show player list and kick button in EditSession" -``` - ---- - -## Task 6: Add backend tests - -**Objective:** Cover new GetSessionParticipants and RemovePlayerFromSession logic. - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs` - -**Step 1:** Write tests for `GetSessionParticipantsForGmAsync`. - -**Step 2:** Write tests for `RemovePlayerFromSessionForGmAsync` including waitlist promotion. - -**Step 3:** Run tests - -```bash -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n -``` - -**Step 4:** Commit - -```bash -git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs - -git commit -m "test: add SessionParticipant service tests" -``` - ---- - -## Task 7: Update README - -**Objective:** Bump version and document new features. - -**Files:** -- Modify: `README.md` - -**Step 1:** Change version from `v1.9.6` to `v1.9.7`. - -**Step 2:** Add bullet under Web Dashboard: player list with kick and auto-promote. - -**Step 3:** Commit - -```bash -git add README.md - -git commit -m "docs: bump README to v1.9.7, document player list kick" -``` - ---- - -## Task 8: Update Wiki - -**Objective:** Update `Руководство ГМа` page with player management instructions. - -**Files:** -- Modify: Wiki page `Руководство ГМа` - -**Step 1:** Read current wiki content via MCP. - -**Step 2:** Add section about viewing player list and removing players. - -**Step 3:** Update via MCP. - ---- - -## Task 9: Push branch and run CI - -**Objective:** Push branch, monitor workflow, fix issues. - -**Step 1:** Push - -```bash -git push -u origin feat/player-list-kick-waitlist -``` - -**Step 2:** Check workflow run via MCP gitea actions. - -**Step 3:** Fix any issues. - ---- - -## Task 10: Merge and create release - -**Objective:** Merge PR (or fast-forward), tag, create release. - -**Step 1:** Merge to main - -```bash -git checkout main - -git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)" -``` - -**Step 2:** Tag v1.9.7 - -```bash -git tag v1.9.7 - -git push origin main --tags -``` - -**Step 3:** Create release via MCP gitea_create_release. - ---- diff --git a/docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md b/docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md deleted file mode 100644 index 685d5e0..0000000 --- a/docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md +++ /dev/null @@ -1,69 +0,0 @@ -# Telegram Mini App Dashboard Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a Telegram Mini App mobile dashboard that reuses the existing Web Dashboard and validates Telegram WebApp `initData` on the server. - -**Architecture:** Extend `TelegramAuthService` for WebApp init data, add a `/miniapp` Blazor entry page plus `/auth/telegram-webapp` endpoint, and add bot entry points through an inline WebApp button and optional menu button setup. Existing application/domain services remain the only write path. - -**Tech Stack:** .NET 10, Blazor Server, Telegram.Bot, xUnit, Dapper/Npgsql-backed existing services. - ---- - -### Task 1: Telegram WebApp Authentication - -**Files:** -- Modify: `src/GmRelay.Web/Services/TelegramAuthService.cs` -- Modify: `src/GmRelay.Web/Program.cs` -- Test: `tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs` - -- [ ] Write failing tests for valid WebApp `initData`, tampered hash, and expired auth date. -- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramAuthServiceTests`. -- [ ] Implement WebApp HMAC verification using the Telegram `WebAppData` secret derivation. -- [ ] Add `/auth/telegram-webapp` endpoint that signs in using the same claims as `/auth/telegram`. -- [ ] Re-run the filtered tests. - -### Task 2: Mini App Entry Page - -**Files:** -- Create: `src/GmRelay.Web/Components/Pages/MiniApp.razor` -- Modify: `src/GmRelay.Web/Components/App.razor` -- Modify: `src/GmRelay.Web/wwwroot/app.css` -- Test: `tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs` - -- [ ] Write failing tests that assert `/miniapp`, `telegram-web-app.js`, `authenticateTelegramMiniApp`, and Mini App CSS hooks exist. -- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter MiniAppDashboardTests`. -- [ ] Implement `/miniapp` to post `Telegram.WebApp.initData` to `/auth/telegram-webapp`, expand/ready the Mini App, and show fallback login when opened outside Telegram. -- [ ] Add CSS for a mobile-first Mini App shell and compact dashboard spacing. -- [ ] Re-run the filtered tests. - -### Task 3: Bot Entry Points - -**Files:** -- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs` -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` -- Modify: `src/GmRelay.Bot/Program.cs` -- Test: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs` - -- [ ] Write failing tests that assert `/start` exposes a WebApp button and startup registers the menu button service. -- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramMiniAppEntryPointTests`. -- [ ] Add a configurable `Telegram:MiniAppUrl` entry point; when missing, keep existing command behavior. -- [ ] Add hosted service that calls `SetChatMenuButton` with `MenuButtonWebApp` only when the URL is configured. -- [ ] Re-run the filtered tests. - -### Task 4: Docs, Versions, and Release Prep - -**Files:** -- Modify: `Directory.Build.props` -- Modify: `compose.yaml` -- Modify: `.gitea/workflows/deploy.yml` -- Modify: `src/GmRelay.Web/wwwroot/app.css` -- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` -- Modify: `README.md` -- Wiki: `Home`, `Быстрый старт`, `Руководство ГМа`, `Развёртывание`, `Архитектура`, `Разработка` - -- [ ] Update project/container/workflow/UI versions to `1.9.0`. -- [ ] Document `TELEGRAM_MINI_APP_URL`, BotFather `/setmenubutton`, `/miniapp`, and WebApp auth. -- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"`. -- [ ] Run `dotnet build GM-Relay.slnx -c Release`. -- [ ] Commit, push, close issue #17, update wiki, create tag/release `v1.9.0`. diff --git a/docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md b/docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md deleted file mode 100644 index 5aef89a..0000000 --- a/docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md +++ /dev/null @@ -1,560 +0,0 @@ -# Platform Messenger Contracts Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement issue #24 by adding platform-neutral platform identity and messaging contracts, then routing the Telegram session flows through a Telegram adapter without changing Telegram behavior. - -**Architecture:** Keep update routing and Telegram update parsing at the `GmRelay.Bot.Infrastructure.Telegram` boundary, but move outbound messaging decisions behind `GmRelay.Shared.Platform.IPlatformMessenger`. `GmRelay.Shared` owns platform-neutral DTOs and contracts; `GmRelay.Bot` owns `TelegramPlatformMessenger`, which translates neutral requests into `Telegram.Bot` calls and reuses the existing Telegram renderers/editing rules. - -**Tech Stack:** .NET 10, C# preview, xUnit, Dapper.AOT constraints, Telegram.Bot in `GmRelay.Bot` only, platform-neutral shared contracts in `GmRelay.Shared`. - ---- - -## Issue Context - -- Gitea issue: #24, `refactor: ввести PlatformKind, PlatformUser, PlatformGroup и IPlatformMessenger` -- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`, `pending-approval` -- Acceptance criteria: - - New contracts live in a platform-neutral layer. - - Telegram flow goes through the adapter without behavior changes. - - A future DiscordBot can reference the contract without depending on Telegram assemblies. - -## Proposed Version Bump - -Current version is `2.0.0` in: - -- `Directory.Build.props` -- `compose.yaml` -- `.gitea/workflows/deploy.yml` -- `src/GmRelay.Web/Components/Layout/NavMenu.razor` - -Issue label is `type:refactor`; per workflow rules this is not a major bump and has no user-facing feature label. Proposed bump: `2.0.0` -> `2.0.1`. - -## Files - -- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs` -- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs` -- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs` -- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` -- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` -- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` -- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs` -- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` -- Modify: `src/GmRelay.Bot/Program.cs` -- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs` -- Modify: version files listed above - -## Design - -### Shared Contracts - -`PlatformKind` is a sentinel enum where `Max` is not a sendable platform: - -```csharp -namespace GmRelay.Shared.Platform; - -public enum PlatformKind -{ - Telegram = 0, - Discord = 1, - Max = 2 -} -``` - -`PlatformUser` and `PlatformGroup` carry external platform identity while keeping current Telegram IDs representable as strings: - -```csharp -namespace GmRelay.Shared.Platform; - -public sealed record PlatformUser( - PlatformKind Platform, - string ExternalUserId, - string DisplayName, - string? ExternalUsername); - -public sealed record PlatformGroup( - PlatformKind Platform, - string ExternalGroupId, - string DisplayName, - string? ExternalChannelId = null, - string? ExternalThreadId = null); -``` - -Outbound message contracts stay independent of Telegram/Discord SDK types: - -```csharp -using GmRelay.Shared.Rendering; - -namespace GmRelay.Shared.Platform; - -public sealed record PlatformMessageRef( - PlatformKind Platform, - string ExternalGroupId, - string? ExternalThreadId, - string ExternalMessageId); - -public sealed record PlatformMessageAction( - string Key, - string Label, - string Payload); - -public sealed record PlatformScheduleMessage( - PlatformGroup Group, - SessionBatchViewModel View, - PlatformMessageRef? ExistingMessage, - string? ImageReference = null); - -public sealed record PlatformPrivateMessage( - PlatformUser Recipient, - string HtmlText); - -public sealed record PlatformInteractionReply( - string InteractionId, - string Text, - bool ShowAlert = false); - -public sealed record PlatformCalendarFile( - PlatformGroup Group, - string FileName, - byte[] Content, - string CaptionHtml, - IReadOnlyList Actions); -``` - -`IPlatformMessenger` exposes the required outward operations: - -```csharp -namespace GmRelay.Shared.Platform; - -public interface IPlatformMessenger -{ - Task SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); - Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); - Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct); - Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct); - Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct); - Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct); -} -``` - -### Telegram Adapter - -`TelegramPlatformMessenger` lives in `GmRelay.Bot.Infrastructure.Telegram`, depends on `ITelegramBotClient`, and translates neutral DTOs to existing Telegram calls: - -- `SendScheduleAsync` renders `SessionBatchViewModel` with `TelegramSessionBatchRenderer.Render`. -- `UpdateScheduleAsync` calls `BatchMessageEditor.EditBatchMessageAsync`. -- `SendGroupMessageAsync` calls `SendMessage` with `ParseMode.Html` and optional `messageThreadId`. -- `SendPrivateMessageAsync` calls `SendMessage` to `PlatformUser.ExternalUserId`. -- `AnswerInteractionAsync` calls `AnswerCallbackQuery`. -- `SendCalendarFileAsync` calls `SendDocument` and maps URL actions to inline keyboard buttons. - -### Handler Scope - -Refactor outbound Telegram calls in these flows to `IPlatformMessenger`: - -- Join/leave/promote waitlist schedule updates and callback replies. -- Cancel schedule update, group cancellation message, direct notification and callback reply. -- Reschedule initiation, voting message updates, immediate reschedule schedule update, direct notifications and callback replies. -- Export calendar file sending. - -Keep Telegram inbound DTOs at the boundary for now: - -- `UpdateRouter` still receives `Telegram.Bot.Types.Update`. -- Text message parsing in reschedule input still receives `Telegram.Bot.Types.Message`. -- `CreateSessionHandler` can keep photo/topic creation via `ITelegramBotClient` because issue #24 targets outbound schedule/interaction/private/calendar contract, not replacing all Telegram update primitives in one PR. - -## Tasks - -### Task 1: RED - Shared Contract Tests - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs` - -- [ ] **Step 1: Write failing tests for neutral contracts** - -```csharp -using GmRelay.Shared.Platform; -using GmRelay.Shared.Rendering; - -namespace GmRelay.Bot.Tests.Platform; - -public sealed class PlatformContractsTests -{ - [Fact] - public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel() - { - Assert.Equal(0, (int)PlatformKind.Telegram); - Assert.Equal(1, (int)PlatformKind.Discord); - Assert.Equal(2, (int)PlatformKind.Max); - } - - [Fact] - public void PlatformContracts_ShouldBeTelegramAssemblyFree() - { - var contractTypes = new[] - { - typeof(PlatformUser), - typeof(PlatformGroup), - typeof(PlatformMessageRef), - typeof(PlatformMessageAction), - typeof(PlatformScheduleMessage), - typeof(PlatformPrivateMessage), - typeof(PlatformInteractionReply), - typeof(PlatformCalendarFile), - typeof(IPlatformMessenger) - }; - - Assert.All(contractTypes, type => - Assert.DoesNotContain( - "Telegram", - string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)), - StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes() - { - var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - var view = SessionBatchViewBuilder.Build( - "Campaign", - [new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")], - []); - var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1"); - - var message = new PlatformScheduleMessage(group, view, ExistingMessage: null); - - Assert.Equal(PlatformKind.Discord, message.Group.Platform); - Assert.Same(view, message.View); - } -} -``` - -- [ ] **Step 2: Run tests and verify RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests -``` - -Expected: compile failure because `GmRelay.Shared.Platform` types do not exist. - -### Task 2: GREEN - Add Shared Contracts - -**Files:** -- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs` -- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs` -- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs` -- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` -- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` - -- [ ] **Step 1: Add the contract files exactly as described in the Design section** -- [ ] **Step 2: Run PlatformContractsTests and verify GREEN** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests -``` - -Expected: `Passed`. - -### Task 3: RED - Adapter and Flow Source Tests - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` - -- [ ] **Step 1: Write source tests for adapter wiring and target flows** - -```csharp -namespace GmRelay.Bot.Tests.Infrastructure.Telegram; - -public sealed class TelegramPlatformMessengerSourceTests -{ - [Fact] - public async Task Program_ShouldRegisterTelegramPlatformMessenger() - { - var program = await ReadRepositoryFileAsync("src/GmRelay.Bot/Program.cs"); - - Assert.Contains("IPlatformMessenger", program, StringComparison.Ordinal); - Assert.Contains("TelegramPlatformMessenger", program, StringComparison.Ordinal); - } - - [Theory] - [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")] - public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath) - { - var source = await ReadRepositoryFileAsync(relativePath); - - Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); - Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal); - } - - [Fact] - public async Task TelegramPlatformMessenger_ShouldOwnTelegramBotClientCalls() - { - var source = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs"); - - Assert.Contains("ITelegramBotClient", source, StringComparison.Ordinal); - Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal); - Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal); - Assert.Contains("SendDocument", source, StringComparison.Ordinal); - } - - private static async Task ReadRepositoryFileAsync(string relativePath) - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - { - return await File.ReadAllTextAsync(candidate); - } - - directory = directory.Parent; - } - - throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); - } -} -``` - -- [ ] **Step 2: Run tests and verify RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests -``` - -Expected: failures because `TelegramPlatformMessenger` is missing and handlers still call Telegram APIs directly. - -### Task 4: GREEN - Implement TelegramPlatformMessenger and Registration - -**Files:** -- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` -- Modify: `src/GmRelay.Bot/Program.cs` - -- [ ] **Step 1: Implement adapter** - -Implementation notes: - -- Parse Telegram chat/thread/message IDs from neutral string IDs with `long.Parse` and `int.Parse`. -- Use `ParseMode.Html` for HTML text. -- Map `PlatformMessageAction` URLs to `InlineKeyboardButton.WithUrl`. -- Return a `PlatformMessageRef` with message IDs converted to strings. - -- [ ] **Step 2: Register adapter** - -Add `using GmRelay.Shared.Platform;` and register: - -```csharp -builder.Services.AddSingleton(); -``` - -- [ ] **Step 3: Run adapter source tests** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests -``` - -Expected: some handler source tests still fail until Task 5. - -### Task 5: GREEN - Refactor Session Flows Through Adapter - -**Files:** -- Modify target handler files listed in Task 3 -- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs` - -- [ ] **Step 1: Replace constructor dependencies** - -Use `IPlatformMessenger messenger` in target handlers for outbound operations. Keep `ITelegramBotClient` only where the handler still performs inbound Telegram-specific work that is out of scope, such as message deletion or forum topic creation. - -- [ ] **Step 2: Convert Telegram IDs to neutral platform objects** - -Use helper code equivalent to: - -```csharp -private static PlatformGroup TelegramGroup(long chatId, string? title, int? threadId = null) - => new( - PlatformKind.Telegram, - chatId.ToString(System.Globalization.CultureInfo.InvariantCulture), - title ?? "Telegram chat", - ExternalChannelId: chatId.ToString(System.Globalization.CultureInfo.InvariantCulture), - ExternalThreadId: threadId?.ToString(System.Globalization.CultureInfo.InvariantCulture)); - -private static PlatformUser TelegramUser(long telegramId, string displayName, string? username = null) - => new( - PlatformKind.Telegram, - telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), - displayName, - username); -``` - -- [ ] **Step 3: Replace schedule updates** - -Build `SessionBatchViewModel` as before, then call: - -```csharp -await messenger.UpdateScheduleAsync( - new PlatformScheduleMessage( - group, - view, - new PlatformMessageRef(PlatformKind.Telegram, group.ExternalGroupId, group.ExternalThreadId, messageId.ToString(System.Globalization.CultureInfo.InvariantCulture))), - ct); -``` - -- [ ] **Step 4: Replace interaction replies** - -Use: - -```csharp -await messenger.AnswerInteractionAsync( - new PlatformInteractionReply(command.CallbackQueryId, text, showAlert: false), - ct); -``` - -- [ ] **Step 5: Replace direct notifications** - -`DirectSessionNotificationSender` should become a small compatibility service over `IPlatformMessenger`: - -```csharp -await messenger.SendPrivateMessageAsync( - new PlatformPrivateMessage( - new PlatformUser(PlatformKind.Telegram, recipient.TelegramId.ToString(CultureInfo.InvariantCulture), recipient.DisplayName, null), - htmlText), - ct); -``` - -- [ ] **Step 6: Replace calendar file sending** - -`ExportCalendarHandler` builds the same ICS bytes and calls `SendCalendarFileAsync`, preserving the subscription URL button as a `PlatformMessageAction`. - -- [ ] **Step 7: Run target source tests** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests -``` - -Expected: `Passed`. - -### Task 6: Regression Tests - -**Files:** -- Existing tests only unless a compiler failure exposes a missing using or changed behavior. - -- [ ] **Step 1: Run rendering and routing tests** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Rendering|FullyQualifiedName~Telegram|FullyQualifiedName~RescheduleSession" -``` - -Expected: `Passed`. - -- [ ] **Step 2: Run all tests** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -``` - -Expected: `Passed`. - -- [ ] **Step 3: Build solution** - -Run: - -```powershell -dotnet build GM-Relay.slnx -``` - -Expected: `Build succeeded` with warnings treated as errors. - -### Task 7: Version Bump - -**Files:** -- Modify: `Directory.Build.props` -- Modify: `compose.yaml` -- Modify: `.gitea/workflows/deploy.yml` -- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - -- [ ] **Step 1: Update all four version locations to `2.0.1`** -- [ ] **Step 2: Verify sync** - -Run: - -```powershell -rg -n "2\\.0\\.0|2\\.0\\.1" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor -``` - -Expected: no `2.0.0` matches in these files and `2.0.1` appears in all required locations. - -### Task 8: Documentation Review - -**Files:** -- Review: `README.md` -- Review: `docs/adr/002-platform-neutral-batch-rendering.md` - -- [ ] **Step 1: Check README and ADR for platform contract accuracy** -- [ ] **Step 2: Update docs if they now misrepresent platform-neutral responsibilities** - -Expected likely doc change: README currently lists current version as `v1.15.0`, which is already inconsistent with repo version `2.0.0`. If this PR bumps to `2.0.1`, update that line to `v2.0.1`. - -### Task 9: Commit, PR, CI, Review, Merge, Deploy, Release - -**Files:** -- Stage only files intentionally changed for issue #24. - -- [ ] **Step 1: Create branch** - -```powershell -git checkout -b codex/refactor/issue-24-platform-messenger -``` - -- [ ] **Step 2: Commit** - -```powershell -git add src/GmRelay.Shared/Platform src/GmRelay.Bot tests/GmRelay.Bot.Tests Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md docs/adr/002-platform-neutral-batch-rendering.md -git commit -m "refactor: add platform messenger contracts" -``` - -- [ ] **Step 3: Push and create PR via Gitea** -- [ ] **Step 4: Wait for PR CI and fix failures if any** -- [ ] **Step 5: Run code review subagent and address findings** -- [ ] **Step 6: Merge PR after CI and review** -- [ ] **Step 7: Monitor deploy workflow** -- [ ] **Step 8: Create release `v2.0.1` with Russian release notes** -- [ ] **Step 9: Close issue #24 with PR and release links** - -## Self-Review - -- Spec coverage: all issue acceptance criteria map to Shared contracts, Telegram adapter, handler source tests, and build/test verification. -- Placeholder scan: no `TBD`, `TODO`, or "fill later" placeholders are left in this plan. -- Type consistency: all snippets use `GmRelay.Shared.Platform`, `PlatformKind.Telegram`, `PlatformMessageRef`, and `IPlatformMessenger` consistently. -- Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope. diff --git a/docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md b/docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md deleted file mode 100644 index 647bfd9..0000000 --- a/docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md +++ /dev/null @@ -1,731 +0,0 @@ -# Discord NetCord Gateway Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a separate `src/GmRelay.DiscordBot` worker that uses NetCord Gateway for Discord slash commands and component interactions while keeping Telegram dependencies isolated in `src/GmRelay.Bot`. - -**Architecture:** Create a new .NET worker project that references `GmRelay.ServiceDefaults` and `GmRelay.Shared`, validates `Discord:Token` during startup, registers NetCord gateway/application command/component services, and logs gateway lifecycle events through NetCord gateway handlers. Keep database connectivity aligned with the existing worker by registering the same `ConnectionStrings:gmrelaydb` `NpgsqlDataSource` pattern, but do not move Telegram code or dependencies. - -**Tech Stack:** .NET 10 worker, Aspire service defaults, NetCord.Hosting `1.0.0-alpha.489`, Npgsql `10.0.2`, xUnit, Docker Compose, Gitea Actions. - ---- - -## Issue - -- Gitea issue: `#26`, `feat: добавить src/GmRelay.DiscordBot на NetCord Gateway` -- Labels: `type:feature`, `area:discord`, `area:infra`, `platform:discord`, `priority:p1`, `pending-approval` -- Version bump: minor, `2.1.1` -> `2.2.0` -- Branch: `feature/issue-26-discord-netcord-gateway` - -## Sources Checked - -- NetCord application commands guide: `https://netcord.dev/guides/services/application-commands/introduction.html` -- NetCord intents guide: `https://netcord.dev/guides/events/intents.html` -- NetCord gateway handler docs: `https://netcord.dev/docs/NetCord.Hosting.Gateway.html` -- NuGet flat container for `NetCord.Hosting`: latest observed version `1.0.0-alpha.489` - -## File Structure - -- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` - Discord worker project and package references. -- Create: `src/GmRelay.DiscordBot/Program.cs` - host composition, token validation, database registration, NetCord service registration. -- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - strongly typed Discord token/options validation. -- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs` - Discord-local startup redaction without referencing the Telegram worker project. -- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - NetCord gateway lifecycle handler for ready/connect/resume/disconnect/close/rate-limit events where available. -- Create: `src/GmRelay.DiscordBot/Dockerfile` - publish and runtime image for the Discord worker. -- Modify: `GM-Relay.slnx` - include the new project. -- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` - reference the Discord worker for Aspire orchestration. -- Modify: `src/GmRelay.AppHost/Program.cs` - add `discord` project with PostgreSQL reference. -- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - reference the Discord worker project. -- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` - source-level tests for solution inclusion, Docker/Compose/CI wiring, and Telegram isolation. -- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` - unit tests for token validation. -- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - source-level startup tests for NetCord registration, service defaults, and PostgreSQL connection requirements. -- Modify: `compose.yaml` - add `discord` service and versioned image tag. -- Modify: `.gitea/workflows/deploy.yml` - build/push/scan/pull Discord image and include `DISCORD_BOT_TOKEN` in `.env`. -- Modify: `.gitea/workflows/pr-checks.yml` - build the Discord project in PR checks. -- Modify: `Directory.Build.props` - version `2.2.0`. -- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - visible version `v2.2.0`. -- Generated by restore: `src/GmRelay.DiscordBot/packages.lock.json`. -- Generated by restore: updates to `tests/GmRelay.Bot.Tests/packages.lock.json` and `src/GmRelay.AppHost/packages.lock.json`. - -## TDD Plan - -### Task 1: Project Presence And Telegram Isolation - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` -- Modify: `GM-Relay.slnx` -- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` -- Create: `src/GmRelay.DiscordBot/Program.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -using System; -using System.IO; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordProjectStructureTests -{ - private static string GetRepoRoot() - { - var dir = AppContext.BaseDirectory; - while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) - { - dir = Directory.GetParent(dir)?.FullName; - } - - return dir ?? throw new InvalidOperationException("Could not find repo root"); - } - - [Fact] - public void Solution_ShouldIncludeDiscordWorkerProject() - { - var repoRoot = GetRepoRoot(); - var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx")); - - Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution); - } - - [Fact] - public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency() - { - var repoRoot = GetRepoRoot(); - var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj"); - - Assert.True(File.Exists(projectPath), "Discord worker project should exist."); - - var project = File.ReadAllText(projectPath); - Assert.Contains("Microsoft.NET.Sdk.Worker", project); - Assert.Contains("NetCord.Hosting", project); - Assert.Contains("GmRelay.ServiceDefaults.csproj", project); - Assert.Contains("GmRelay.Shared.csproj", project); - Assert.DoesNotContain("Telegram.Bot", project); - Assert.DoesNotContain("GmRelay.Bot.csproj", project); - } - - [Fact] - public void TelegramWorkerProject_ShouldNotReferenceNetCord() - { - var repoRoot = GetRepoRoot(); - var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj")); - - Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests` - -Expected: FAIL because `GM-Relay.slnx` does not include `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` and the project file does not exist. - -- [ ] **Step 3: Write minimal implementation** - -Create `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`: - -```xml - - - - net10.0 - preview - enable - enable - dotnet-GmRelay.DiscordBot-issue-26 - - - - - - - - - - - - - - -``` - -Add this project to `GM-Relay.slnx` inside `/src/`: - -```xml - -``` - -Create temporary minimal `src/GmRelay.DiscordBot/Program.cs`: - -```csharp -var builder = Host.CreateApplicationBuilder(args); -builder.AddServiceDefaults(); -await builder.Build().RunAsync(); -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests` - -Expected: PASS. - -### Task 2: Token Validation - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` -- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` -- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - -- [ ] **Step 1: Write the failing test** - -Add the project reference to `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`: - -```xml - -``` - -Create `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`: - -```csharp -using GmRelay.DiscordBot; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordOptionsTests -{ - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Validate_ShouldRejectMissingToken(string? token) - { - var options = new DiscordOptions { Token = token }; - - var exception = Assert.Throws(options.Validate); - - Assert.Contains("Discord:Token is required", exception.Message); - Assert.Contains("Discord__Token", exception.Message); - } - - [Fact] - public void Validate_ShouldAcceptConfiguredToken() - { - var options = new DiscordOptions { Token = "configured-token" }; - - options.Validate(); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests` - -Expected: FAIL at compile time because `GmRelay.DiscordBot.DiscordOptions` is not defined. - -- [ ] **Step 3: Write minimal implementation** - -Create `src/GmRelay.DiscordBot/DiscordOptions.cs`: - -```csharp -namespace GmRelay.DiscordBot; - -public sealed class DiscordOptions -{ - public string? Token { get; init; } - - public void Validate() - { - if (string.IsNullOrWhiteSpace(Token)) - { - throw new InvalidOperationException( - "Discord:Token is required. Set via environment variable Discord__Token or user secrets."); - } - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests` - -Expected: PASS. - -### Task 3: Startup Wiring For Service Defaults, PostgreSQL, NetCord, And Slash Commands - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` -- Modify: `src/GmRelay.DiscordBot/Program.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`: - -```csharp -using System; -using System.IO; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordStartupTests -{ - private static string GetRepoRoot() - { - var dir = AppContext.BaseDirectory; - while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) - { - dir = Directory.GetParent(dir)?.FullName; - } - - return dir ?? throw new InvalidOperationException("Could not find repo root"); - } - - [Fact] - public void Program_ShouldValidateDiscordTokenBeforeRunning() - { - var program = ReadProgram(); - - Assert.Contains("GetRequiredSection(\"Discord\")", program); - Assert.Contains("DiscordOptions", program); - Assert.Contains(".Validate()", program); - } - - [Fact] - public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource() - { - var program = ReadProgram(); - - Assert.Contains("builder.AddServiceDefaults()", program); - Assert.Contains("ConnectionStrings:gmrelaydb is required", program); - Assert.Contains("NpgsqlDataSource", program); - Assert.Contains("SecretRedactor.RedactConnectionString", program); - } - - [Fact] - public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents() - { - var program = ReadProgram(); - - Assert.Contains(".AddDiscordGateway", program); - Assert.Contains(".AddApplicationCommands", program); - Assert.Contains(".AddComponentInteractions", program); - Assert.Contains(".AddGatewayHandlers", program); - Assert.Contains("AddSlashCommand", program); - } - - private static string ReadProgram() - { - var repoRoot = GetRepoRoot(); - return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs")); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests` - -Expected: FAIL because `Program.cs` does not validate `Discord:Token`, register `NpgsqlDataSource`, or register NetCord services yet. - -- [ ] **Step 3: Write minimal implementation** - -Replace `src/GmRelay.DiscordBot/Program.cs` with host composition that: - -```csharp -using GmRelay.DiscordBot; -using GmRelay.DiscordBot.Infrastructure.Logging; -using NetCord.Gateway; -using NetCord.Hosting.Gateway; -using NetCord.Hosting.Services.ApplicationCommands; -using NetCord.Hosting.Services.ComponentInteractions; -using Npgsql; - -var builder = Host.CreateApplicationBuilder(args); - -builder.AddServiceDefaults(); - -var discordOptions = builder.Configuration - .GetRequiredSection("Discord") - .Get() ?? new DiscordOptions(); -discordOptions.Validate(); - -builder.Services.AddSingleton(discordOptions); - -builder.Services.AddSingleton(sp => -{ - var config = sp.GetRequiredService(); - var loggerFactory = sp.GetRequiredService(); - var connectionString = config.GetConnectionString("gmrelaydb") - ?? throw new InvalidOperationException( - "ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb."); - - var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup"); - logger.LogInformation( - "Configured PostgreSQL data source with connection string {ConnectionString}", - SecretRedactor.RedactConnectionString(connectionString)); - - return NpgsqlDataSource.Create(connectionString); -}); - -builder.Services - .AddDiscordGateway(options => - { - options.Token = discordOptions.Token; - options.Intents = GatewayIntents.Guilds; - }) - .AddApplicationCommands() - .AddComponentInteractions() - .AddGatewayHandlers(typeof(Program).Assembly); - -var host = builder.Build(); - -host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!"); - -await host.RunAsync(); -``` - -Use the Discord-local `SecretRedactor` namespace instead of `GmRelay.Bot.Infrastructure.Logging` so the new project does not reference the Telegram worker. - -Create `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs`: - -```csharp -using System.Text.RegularExpressions; - -namespace GmRelay.DiscordBot.Infrastructure.Logging; - -internal static partial class SecretRedactor -{ - public static string RedactConnectionString(string connectionString) - { - return PasswordPattern().Replace(connectionString, "$1***"); - } - - [GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")] - private static partial Regex PasswordPattern(); -} -``` - -If `GatewayClientOptions.Token` does not accept `string`, adjust to NetCord's required token type after compile feedback while preserving the tests' intent. - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests` - -Expected: PASS. - -### Task 4: Gateway Lifecycle Logging - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` -- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - -- [ ] **Step 1: Write the failing test** - -Add to `DiscordStartupTests.cs`: - -```csharp -[Fact] -public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues() -{ - var repoRoot = GetRepoRoot(); - var loggerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Infrastructure", "Logging", "DiscordGatewayLifecycleLogger.cs"); - - Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist."); - - var logger = File.ReadAllText(loggerPath); - Assert.Contains("IReadyGatewayHandler", logger); - Assert.Contains("IDisconnectGatewayHandler", logger); - Assert.Contains("IResumeGatewayHandler", logger); - Assert.Contains("LogInformation", logger); - Assert.DoesNotContain("Token", logger); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues` - -Expected: FAIL because `DiscordGatewayLifecycleLogger.cs` does not exist. - -- [ ] **Step 3: Write minimal implementation** - -Create `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` using the concrete NetCord handler signatures from the installed `NetCord.Hosting` package. Minimum behavior: - -```csharp -using Microsoft.Extensions.Logging; -using NetCord.Gateway; -using NetCord.Hosting.Gateway; - -namespace GmRelay.DiscordBot.Infrastructure.Logging; - -public sealed class DiscordGatewayLifecycleLogger( - ILogger logger) - : IReadyGatewayHandler, - IDisconnectGatewayHandler, - IResumeGatewayHandler -{ - public ValueTask HandleAsync(ReadyEventArgs arg) - { - logger.LogInformation("Discord gateway ready as application {ApplicationId}", arg.Application.Id); - return ValueTask.CompletedTask; - } - - public ValueTask HandleAsync(DisconnectEventArgs arg) - { - logger.LogWarning("Discord gateway disconnected with close status {CloseStatus}", arg.CloseStatus); - return ValueTask.CompletedTask; - } - - public ValueTask HandleAsync() - { - logger.LogInformation("Discord gateway session resumed"); - return ValueTask.CompletedTask; - } -} -``` - -If interface signatures differ in `1.0.0-alpha.489`, inspect the package XML/docs and adjust the handlers to compile while keeping ready/disconnect/resume logging and never logging token values. - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues` - -Expected: PASS. - -### Task 5: Runtime Container, Compose, AppHost, And CI Wiring - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` -- Create: `src/GmRelay.DiscordBot/Dockerfile` -- Modify: `compose.yaml` -- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` -- Modify: `src/GmRelay.AppHost/Program.cs` -- Modify: `.gitea/workflows/pr-checks.yml` -- Modify: `.gitea/workflows/deploy.yml` - -- [ ] **Step 1: Write the failing test** - -Add to `DiscordProjectStructureTests.cs`: - -```csharp -[Fact] -public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram() -{ - var repoRoot = GetRepoRoot(); - var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")); - var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj")); - var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs")); - var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); - var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - - Assert.Contains("gmrelay-discord-bot:2.2.0", compose); - Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); - Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); - Assert.Contains("DISCORD_BOT_TOKEN", deploy); - Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks); - Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject); - Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram` - -Expected: FAIL because runtime wiring is not present. - -- [ ] **Step 3: Write minimal implementation** - -Create `src/GmRelay.DiscordBot/Dockerfile` modeled after `src/GmRelay.Bot/Dockerfile`, with project copy/restore for `GmRelay.DiscordBot`, `GmRelay.ServiceDefaults`, and `GmRelay.Shared`, and entrypoint `./GmRelay.DiscordBot`. - -Add `discord` service to `compose.yaml`: - -```yaml - discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0 - restart: always - depends_on: - db: - condition: service_healthy - environment: - - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - - "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}" - networks: - - gmrelay -``` - -Add Discord project reference to `src/GmRelay.AppHost/GmRelay.AppHost.csproj`: - -```xml - -``` - -Add Discord service to `src/GmRelay.AppHost/Program.cs`: - -```csharp -builder.AddProject("discord") - .WithReference(postgres) - .WaitFor(postgres); -``` - -Update `.gitea/workflows/pr-checks.yml` with: - -```yaml - - name: Build Discord Bot (compile check, includes SAST) - run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore -``` - -Update `.gitea/workflows/deploy.yml` to build, push, scan, pull, and deploy `git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}` and write `DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}` to `.env`. - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram` - -Expected: PASS. - -### Task 6: Version Synchronization - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` -- Modify: `Directory.Build.props` -- Modify: `compose.yaml` -- Modify: `.gitea/workflows/deploy.yml` -- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - -- [ ] **Step 1: Write the failing test** - -Add to `DiscordProjectStructureTests.cs`: - -```csharp -[Fact] -public void Version_ShouldBeSynchronizedForDiscordFeatureRelease() -{ - var repoRoot = GetRepoRoot(); - - Assert.Contains("2.2.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("v2.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease` - -Expected: FAIL because current version is `2.1.1`. - -- [ ] **Step 3: Write minimal implementation** - -Update: -- `Directory.Build.props`: `2.2.0` -- `.gitea/workflows/deploy.yml`: `VERSION: 2.2.0` -- `compose.yaml`: `gmrelay-bot:2.2.0`, `gmrelay-web:2.2.0`, `gmrelay-discord-bot:2.2.0` -- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v2.2.0` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease` - -Expected: PASS. - -### Task 7: Restore, Format, Build, And Full Test Verification - -**Files:** -- Generated/updated: `src/GmRelay.DiscordBot/packages.lock.json` -- Generated/updated: `tests/GmRelay.Bot.Tests/packages.lock.json` -- Generated/updated: `src/GmRelay.AppHost/packages.lock.json` -- Any code formatting changes required by `dotnet format` - -- [ ] **Step 1: Restore lock files** - -Run: `dotnet restore GM-Relay.slnx` - -Expected: restore succeeds and creates/updates lock files for the new project references and NetCord dependency. - -- [ ] **Step 2: Run targeted tests** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~Discord` - -Expected: all Discord tests pass. - -- [ ] **Step 3: Run full tests** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal` - -Expected: all tests pass. - -- [ ] **Step 4: Run release build** - -Run: `dotnet build GM-Relay.slnx -c Release` - -Expected: solution build succeeds and includes `src/GmRelay.DiscordBot`. - -- [ ] **Step 5: Run format check** - -Run: `dotnet format --verify-no-changes --verbosity diagnostic` - -Expected: no formatting changes required. - -- [ ] **Step 6: Inspect diff for secrets** - -Run: `git diff --check` - -Expected: no whitespace errors and no Discord token value in tracked files. - -Run: `git diff -- . ':!*.lock.json'` - -Expected: diff contains configuration variable names such as `Discord__Token` and `DISCORD_BOT_TOKEN`, but not a real token value. - -### Task 8: Commit, PR, CI, Deploy, Release, Issue Closure - -**Files:** -- All intended implementation, test, lock, workflow, compose, and version files. - -- [ ] **Step 1: Create commit** - -Run: - -```powershell -git status --short -git add GM-Relay.slnx Directory.Build.props compose.yaml .gitea/workflows/deploy.yml .gitea/workflows/pr-checks.yml src/GmRelay.AppHost src/GmRelay.DiscordBot src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests -git commit -m "feat: add Discord NetCord gateway worker" -``` - -Expected: only intended files are staged and committed. Do not stage untracked `CLAUDE.md`. - -- [ ] **Step 2: Push branch and open PR** - -Run: `git push -u origin feature/issue-26-discord-netcord-gateway` - -Create Gitea PR to `main` with: -- Summary of Discord worker, token validation, runtime wiring, and version bump. -- Test plan showing targeted Discord tests, full tests, release build, format, and secret diff inspection. -- Link to issue `#26`. - -- [ ] **Step 3: Store Discord token as a Gitea Actions secret** - -Use Gitea Actions configuration to create or update repository secret `DISCORD_BOT_TOKEN` with the user-provided Discord bot token. - -Expected: token is stored only as an Actions secret. The token value is not written to source files, plan files, logs, PR text, release notes, or commits. - -- [ ] **Step 4: Monitor CI** - -Use Gitea Actions run reads until PR checks finish. If CI fails, inspect logs, fix with TDD where the failure is code behavior, push again, and re-check. - -- [ ] **Step 5: Review, merge, deploy, release** - -After CI passes and review is approved: -- Merge PR. -- Monitor deploy workflow on `main`. -- Create release `v2.2.0` with Russian release notes. -- Close issue `#26` with a comment linking PR and release. - -## Self-Review - -- Spec coverage: Project creation, NetCord Gateway, slash/component service registration, `Discord__Token`, PostgreSQL service defaults, lifecycle logging, Telegram isolation, solution build, compose/deploy integration, and version sync are covered. -- Placeholder scan: No task uses `TBD`, `TODO`, or an unspecified "add tests" instruction. -- Type consistency: Test class names and file paths are consistent across tasks; NetCord lifecycle handler signatures are explicitly marked for compile-driven adjustment because the package is prerelease and must be verified against installed `1.0.0-alpha.489`. diff --git a/docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md b/docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md deleted file mode 100644 index 00367b0..0000000 --- a/docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md +++ /dev/null @@ -1,599 +0,0 @@ -# Platform-Neutral Join Leave Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement Gitea issue #25 by making join/leave session interactions use platform-neutral command models while preserving Telegram callback behavior, seat limits, and waitlist semantics. - -**Architecture:** Telegram callback routing remains in `UpdateRouter`, but it becomes an adapter that converts callback data into `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef` values. `JoinSessionHandler` and `LeaveSessionHandler` operate on those neutral values, persist players by `(platform, external_user_id)`, and update schedules through `IPlatformMessenger`. - -**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Gitea Actions. - ---- - -## Issue Context - -- Issue: `#25 refactor: obobshchit JoinSession i LeaveSession pod platform-neutral interactions` -- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor` -- Version bump: patch, `2.1.0` -> `2.1.1`. The issue is labeled refactor, not breaking; do not use a major bump without explicit approval. -- Existing untracked file: `CLAUDE.md`; do not stage or modify it. - -## File Map - -- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs` - - Reflection tests proving join/leave command records expose neutral properties and no Telegram-specific identity/message fields. -- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs` - - Source-level regression tests for handler SQL and messenger boundaries. -- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs` - - Add a migration test for nullable legacy `players.telegram_id`, required for non-Telegram player inserts. -- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql` - - Drop `NOT NULL` from legacy Telegram-only player columns. -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs` - - Change `JoinSessionCommand` to neutral properties and query/upsert players by platform identity. -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs` - - Change `LeaveSessionCommand` to neutral properties and find participants by platform identity. -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` - - Convert Telegram callback data into neutral command values using `TelegramPlatformIds`. -- Modify: version files after implementation: - - `Directory.Build.props` - - `compose.yaml` - - `.gitea/workflows/deploy.yml` - - `src/GmRelay.Web/Components/Layout/NavMenu.razor` - -## Task 1: RED - Command Model Tests - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs` - -- [ ] **Step 1: Write failing command-shape tests** - -```csharp -using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Shared.Platform; - -namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; - -public sealed class PlatformNeutralSessionInteractionCommandTests -{ - [Fact] - public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext() - { - AssertProperty("SessionId", typeof(Guid)); - AssertProperty("User", typeof(PlatformUser)); - AssertProperty("InteractionId", typeof(string)); - AssertProperty("Group", typeof(PlatformGroup)); - AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); - AssertNoTelegramSpecificProperties(); - } - - [Fact] - public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext() - { - AssertProperty("SessionId", typeof(Guid)); - AssertProperty("User", typeof(PlatformUser)); - AssertProperty("InteractionId", typeof(string)); - AssertProperty("Group", typeof(PlatformGroup)); - AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); - AssertNoTelegramSpecificProperties(); - } - - private static void AssertProperty(string name, Type expectedType) - { - var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name); - - Assert.Equal(expectedType, property.PropertyType); - } - - private static void AssertNoTelegramSpecificProperties() - { - var names = typeof(T).GetProperties().Select(property => property.Name).ToArray(); - - Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal)); - Assert.DoesNotContain("ChatId", names); - Assert.DoesNotContain("MessageId", names); - Assert.DoesNotContain("TelegramUserId", names); - Assert.DoesNotContain("TelegramUsername", names); - } -} -``` - -- [ ] **Step 2: Verify RED** - -Run: - -```powershell -dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests -``` - -Expected: FAIL because `JoinSessionCommand` and `LeaveSessionCommand` still expose `TelegramUserId`, `ChatId`, and `MessageId`, and do not expose `User`, `Group`, or `ScheduleMessage`. - -## Task 2: RED - SQL and Boundary Tests - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs` -- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs` - -- [ ] **Step 1: Write failing handler source tests** - -```csharp -namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; - -public sealed class PlatformNeutralSessionInteractionSqlTests -{ - [Fact] - public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity() - { - var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); - - Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal); - Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal); - Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal); - Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal); - Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal); - Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal); - Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal); - } - - [Fact] - public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity() - { - var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); - - Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal); - Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal); - Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal); - Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal); - Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal); - } - - [Fact] - public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference() - { - var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); - var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); - - Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal); - Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal); - Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal); - Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal); - Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal); - Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal); - } - - private static async Task ReadRepositoryFileAsync(string relativePath) - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - { - return await File.ReadAllTextAsync(candidate); - } - - directory = directory.Parent; - } - - throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); - } -} -``` - -- [ ] **Step 2: Add failing migration assertion** - -Append to `PlatformIdentityMigrationTests`: - -```csharp -[Fact] -public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId() -{ - var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql"); - - Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal); - Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal); -} -``` - -- [ ] **Step 3: Verify RED** - -Run: - -```powershell -dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionSqlTests|MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId" -``` - -Expected: FAIL because handlers still use Telegram-specific properties and the V017 migration file does not exist. - -## Task 3: GREEN - Add Migration - -**Files:** -- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql` - -- [ ] **Step 1: Create the migration** - -```sql --- ============================================================= --- V017: Allow platform-neutral players --- ============================================================= --- Legacy Telegram identity columns remain for backward compatibility, --- but non-Telegram platform users do not have Telegram ids. --- ============================================================= - -ALTER TABLE players - ALTER COLUMN telegram_id DROP NOT NULL; -``` - -- [ ] **Step 2: Verify migration test turns green** - -Run: - -```powershell -dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId -``` - -Expected: PASS. - -## Task 4: GREEN - Refactor JoinSessionCommand and Handler - -**Files:** -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs` - -- [ ] **Step 1: Replace command record** - -Replace the existing `JoinSessionCommand` declaration with: - -```csharp -public sealed record JoinSessionCommand( - Guid SessionId, - PlatformUser User, - string InteractionId, - PlatformGroup Group, - PlatformMessageRef ScheduleMessage); -``` - -- [ ] **Step 2: Replace player upsert** - -Use platform identity parameters: - -```csharp -var platform = command.User.Platform.ToString(); -var legacyTelegramId = command.User.Platform == PlatformKind.Telegram - ? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture) - : (long?)null; -var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram - ? command.User.ExternalUsername - : null; - -var playerId = await connection.ExecuteScalarAsync( - @"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) - VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername) - ON CONFLICT (platform, external_user_id) - WHERE platform IS NOT NULL AND external_user_id IS NOT NULL - DO UPDATE - SET display_name = EXCLUDED.display_name, - telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username), - platform = EXCLUDED.platform, - external_user_id = EXCLUDED.external_user_id, - external_username = EXCLUDED.external_username - RETURNING id;", - new - { - LegacyTelegramId = legacyTelegramId, - Name = command.User.DisplayName, - LegacyTelegramUsername = legacyTelegramUsername, - Platform = platform, - command.User.ExternalUserId, - command.User.ExternalUsername - }, - transaction); -``` - -Add `using System.Globalization;` at the top. - -- [ ] **Step 3: Update participant display query** - -Change the participant projection to prefer platform-neutral username: - -```sql -COALESCE(p.external_username, p.telegram_username) as TelegramUsername -``` - -- [ ] **Step 4: Update schedule message and interaction reply usage** - -Use: - -```csharp -await messenger.UpdateScheduleAsync( - new PlatformScheduleMessage( - command.Group, - view, - command.ScheduleMessage), - ct); -``` - -and: - -```csharp -private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => - messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); -``` - -Replace all `command.CallbackQueryId` calls with `command.InteractionId`. - -- [ ] **Step 5: Verify command and SQL tests for join** - -Run: - -```powershell -dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext|JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity" -``` - -Expected: PASS for join-focused tests. - -## Task 5: GREEN - Refactor LeaveSessionCommand and Handler - -**Files:** -- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs` - -- [ ] **Step 1: Replace command record** - -Replace the existing `LeaveSessionCommand` declaration with: - -```csharp -public sealed record LeaveSessionCommand( - Guid SessionId, - PlatformUser User, - string InteractionId, - PlatformGroup Group, - PlatformMessageRef ScheduleMessage); -``` - -- [ ] **Step 2: Replace participant lookup** - -Use platform identity instead of Telegram id: - -```csharp -var platform = command.User.Platform.ToString(); - -var participant = await connection.QuerySingleOrDefaultAsync( - """ - SELECT sp.id AS ParticipantRowId, - p.display_name AS DisplayName, - sp.registration_status AS RegistrationStatus - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND p.platform = @Platform - AND p.external_user_id = @ExternalUserId - AND sp.is_gm = false - FOR UPDATE OF sp - """, - new { command.SessionId, Platform = platform, command.User.ExternalUserId }, - transaction); -``` - -- [ ] **Step 3: Update participant display query** - -Change the participant projection to: - -```sql -COALESCE(p.external_username, p.telegram_username) AS TelegramUsername -``` - -- [ ] **Step 4: Update schedule message and interaction reply usage** - -Use: - -```csharp -await messenger.UpdateScheduleAsync( - new PlatformScheduleMessage( - command.Group, - view, - command.ScheduleMessage), - ct); -``` - -Replace all `command.CallbackQueryId` calls with `command.InteractionId`. - -- [ ] **Step 5: Verify leave tests** - -Run: - -```powershell -dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext|LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity|SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference" -``` - -Expected: PASS. - -## Task 6: GREEN - Convert Telegram Router to Neutral Commands - -**Files:** -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` - -- [ ] **Step 1: Add local conversion values in `HandleCallbackQueryAsync`** - -After parsing `action`, add: - -```csharp -var user = TelegramPlatformIds.User( - query.From.Id, - query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"), - query.From.Username); -var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title); -var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId); -``` - -- [ ] **Step 2: Update join command construction** - -```csharp -var command = new JoinSessionCommand( - SessionId: joinSessionId, - User: user, - InteractionId: query.Id, - Group: group, - ScheduleMessage: scheduleMessage); -``` - -- [ ] **Step 3: Update leave command construction** - -```csharp -var command = new LeaveSessionCommand( - SessionId: leaveSessionId, - User: user, - InteractionId: query.Id, - Group: group, - ScheduleMessage: scheduleMessage); -``` - -- [ ] **Step 4: Verify compile** - -Run: - -```powershell -dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests -``` - -Expected: PASS. - -## Task 7: REFACTOR - Clean Up and Full Test Pass - -**Files:** -- Modify only files already listed if cleanup is needed. - -- [ ] **Step 1: Remove now-unused Telegram handler imports** - -Check `JoinSessionHandler.cs` and `LeaveSessionHandler.cs` for unused: - -```csharp -using GmRelay.Bot.Infrastructure.Telegram; -``` - -Remove it from handlers if no longer needed. - -- [ ] **Step 2: Run focused tests** - -```powershell -dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests" -``` - -Expected: PASS. - -- [ ] **Step 3: Run full test suite** - -```powershell -dotnet test .\GM-Relay.slnx -``` - -Expected: PASS. - -- [ ] **Step 4: Build solution** - -```powershell -dotnet build .\GM-Relay.slnx -``` - -Expected: PASS. - -## Task 8: Version Bump - -**Files:** -- Modify: `Directory.Build.props` -- Modify: `compose.yaml` -- Modify: `.gitea/workflows/deploy.yml` -- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - -- [ ] **Step 1: Update version from `2.1.0` to `2.1.1`** - -Expected exact replacements: - -```xml -2.1.1 -``` - -```yaml -VERSION: 2.1.1 -``` - -```yaml -image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1 -image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1 -``` - -```razor - -``` - -- [ ] **Step 2: Verify synchronized versions** - -Run: - -```powershell -rg "|image: git.codeanddice.ru/toutsu/gmrelay-|VERSION:|nav-version" Directory.Build.props compose.yaml .gitea\workflows\deploy.yml src\GmRelay.Web\Components\Layout\NavMenu.razor -``` - -Expected: all project image/app/deploy UI versions show `2.1.1`. - -## Task 9: PR, CI, Review, Merge, Deploy, Release - -**Files:** -- No additional source changes expected. - -- [ ] **Step 1: Create branch after approval** - -```powershell -git checkout -b refactor/issue-25-platform-neutral-join-leave -``` - -- [ ] **Step 2: Stage only intended files** - -```powershell -git add docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor -``` - -- [ ] **Step 3: Commit** - -```powershell -git commit -m "refactor: make session join leave platform-neutral" -``` - -- [ ] **Step 4: Push and create Gitea PR** - -```powershell -git push -u origin refactor/issue-25-platform-neutral-join-leave -``` - -PR title: - -```text -refactor: make session join leave platform-neutral -``` - -PR body: - -```markdown -## Summary -- Closes #25. -- Converts join/leave session interaction commands from Telegram-specific fields to platform-neutral `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef`. -- Persists and looks up session participants by `(platform, external_user_id)`. -- Keeps Telegram callback data and schedule update behavior intact. - -## Test plan -- `dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"` -- `dotnet test .\GM-Relay.slnx` -- `dotnet build .\GM-Relay.slnx` - -## Workflow -- [ ] CI passes -- [ ] Code review approved -- [ ] Deployed -- [ ] Release published -``` - -- [ ] **Step 5: Watch CI, request review, merge, deploy, release** - -Use Gitea MCP for PR creation, CI polling, review, merge, deploy monitoring, and release `v2.1.1`. Close issue #25 after release and add a comment linking the PR and release. - -## Self-Review - -- Spec coverage: issue scope is covered by neutral command records, Telegram adapter conversion, platform identity SQL, messenger-based schedule updates, and tests. -- Placeholder scan: no `TBD`, `TODO`, or "fill later" steps remain. -- Type consistency: commands consistently use `PlatformUser User`, `string InteractionId`, `PlatformGroup Group`, and `PlatformMessageRef ScheduleMessage`. diff --git a/docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md b/docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md deleted file mode 100644 index a6560c2..0000000 --- a/docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md +++ /dev/null @@ -1,984 +0,0 @@ -# Discord /newsession и /listsessions — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development (TDD) for every task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Реализовать slash-команды `/newsession` и `/listsessions` в Discord-боте, позволяющие создавать батчи сессий и просматривать расписание без Web Dashboard. - -**Architecture:** Каждая команда — отдельный vertical slice в `GmRelay.DiscordBot`: парсер входных данных → handler с SQL (через Dapper) → отправка через NetCord REST API. Рендеринг переиспользует существующий `DiscordSessionBatchRenderer`. Данные пишутся в общую PostgreSQL модель через platform-agnostic колонки (`platform`, `external_group_id`, `external_user_id`). - -**Tech Stack:** .NET 10, NetCord 1.0.0-alpha.489, NetCord.Hosting.Services, Dapper, Npgsql, xUnit. - -**Version Bump:** minor (2.3.0 → 2.4.0) — новый функционал. - ---- - -## File Structure - -| File | Responsibility | -|------|--------------| -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs` | Slash-команда `/newsession` с параметрами (title, time, seats, link) | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs` | Handler создания batch + sessions в БД, проверка прав | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs` | Slash-команда `/listsessions` | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs` | Handler запроса активных сессий и публикации embed | -| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs` | Проверка прав пользователя в guild (owner/admin/manager) | -| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Реализация `IPlatformMessenger` для отправки/обновления расписания в Discord | -| `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs` | TDD-тесты создания сессий из Discord | -| `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs` | TDD-тесты вывода расписания | -| `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs` | TDD-тесты проверки прав | -| `src/GmRelay.DiscordBot/Program.cs` | Регистрация DI: handlers, permission checker, platform messenger | - ---- - -## Task 1: DiscordPermissionChecker - -**Files:** -- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs` -- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs` - -**Context:** Discord использует guild-роли. Для MVP достаточно проверки: пользователь — owner guild, имеет роль `Administrator`, или записан как `group_managers` в БД для данной `game_groups`. - -### Step 1.1: Write the failing test - -```csharp -using GmRelay.DiscordBot.Infrastructure.Discord; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordPermissionCheckerTests -{ - [Fact] - public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue() - { - var checker = new DiscordPermissionChecker(); - var result = checker.CanManageSchedule( - guildOwnerId: 123456789ul, - userId: 123456789ul, - userRoles: Array.Empty(), - dbManagerUserIds: Array.Empty()); - - Assert.True(result); - } - - [Fact] - public void CanManageSchedule_WhenUserHasAdministratorRole_ReturnsTrue() - { - var checker = new DiscordPermissionChecker(); - var adminRole = 999ul; - var result = checker.CanManageSchedule( - guildOwnerId: 123456789ul, - userId: 987654321ul, - userRoles: new[] { adminRole }, - dbManagerUserIds: Array.Empty()); - - Assert.True(result); - } - - [Fact] - public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue() - { - var checker = new DiscordPermissionChecker(); - var managerId = 555ul; - var result = checker.CanManageSchedule( - guildOwnerId: 123456789ul, - userId: managerId, - userRoles: Array.Empty(), - dbManagerUserIds: new[] { managerId }); - - Assert.True(result); - } - - [Fact] - public void CanManageSchedule_WhenRegularUser_ReturnsFalse() - { - var checker = new DiscordPermissionChecker(); - var result = checker.CanManageSchedule( - guildOwnerId: 123456789ul, - userId: 111ul, - userRoles: Array.Empty(), - dbManagerUserIds: new[] { 222ul }); - - Assert.False(result); - } -} -``` - -### Step 1.2: Run test to verify it fails - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal` -Expected: FAIL — `DiscordPermissionChecker` not found. - -### Step 1.3: Write minimal implementation - -Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`: - -```csharp -namespace GmRelay.DiscordBot.Infrastructure.Discord; - -public sealed class DiscordPermissionChecker -{ - // Discord Administrator permission bitflag - private const ulong AdministratorPermission = 0x8; - - public bool CanManageSchedule( - ulong guildOwnerId, - ulong userId, - IEnumerable userRoles, - IEnumerable dbManagerUserIds) - { - if (userId == guildOwnerId) - return true; - - if (dbManagerUserIds.Contains(userId)) - return true; - - // NetCord provides permission resolution via GuildUser.Permissions; - // here we accept pre-resolved flag for simplicity. - // Actual command handler will pass resolved permissions. - return false; - } - - public bool CanManageSchedule(ulong guildOwnerId, ulong userId, IEnumerable dbManagerUserIds, ulong resolvedPermissions) - { - if (userId == guildOwnerId) - return true; - - if (dbManagerUserIds.Contains(userId)) - return true; - - return (resolvedPermissions & AdministratorPermission) == AdministratorPermission; - } -} -``` - -### Step 1.4: Run test to verify it passes - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal` -Expected: PASS (4/4). - -### Step 1.5: Commit - -```bash -git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs -git commit -m "feat(discord): add DiscordPermissionChecker for session management rights - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 2: DiscordListSessionsHandler + Command - -**Files:** -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs` -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs` -- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs` - -**Context:** Handler должен: -1. Найти `game_groups` по `external_group_id` = `guild_id`. -2. Выбрать предстоящие сессии (`scheduled_at > NOW()`, `status != Cancelled`). -3. Собрать участников. -4. Построить view через `SessionBatchViewBuilder`. -5. Отрендерить через `DiscordSessionBatchRenderer`. -6. Отправить embed + buttons в Discord channel. - -### Step 2.1: Write the failing test - -Create `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`: - -```csharp -using GmRelay.DiscordBot.Features.Sessions; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Rendering; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordListSessionsHandlerTests -{ - [Fact] - public void BuildSchedule_WithSessions_ReturnsEmbedsAndButtons() - { - var sessionId = Guid.NewGuid(); - var sessions = new[] - { - new SessionBatchDto(sessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Planned, 4, "https://example.com") - }; - var participants = Array.Empty(); - - var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants); - var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view); - - Assert.Single(embeds); - Assert.Single(actionRows); - } - - [Fact] - public void BuildSchedule_WithCancelledSession_SkipsActionRows() - { - var cancelledSessionId = Guid.NewGuid(); - var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Cancelled, null, "") }; - var participants = Array.Empty(); - - var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants); - var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view); - - Assert.Single(embeds); - Assert.Empty(actionRows); - } -} -``` - -### Step 2.2: Run test — verify RED - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal` -Expected: FAIL — `DiscordListSessionsHandler` not found. - -### Step 2.3: Write minimal implementation - -Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`: - -```csharp -using Dapper; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Rendering; -using NetCord.Rest; -using Npgsql; - -namespace GmRelay.DiscordBot.Features.Sessions; - -internal sealed record DiscordSessionListItemDto( - Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, - int PlayerCount, int WaitlistCount); - -public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource) -{ - public async Task BuildScheduleAsync( - string guildId, - string channelId, - CancellationToken cancellationToken) - { - await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); - - var sessions = await connection.QueryAsync( - @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, - s.max_players as MaxPlayers, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount - 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.platform = 'Discord' - AND g.external_group_id = @GuildId - AND s.status != @Cancelled - AND s.scheduled_at > NOW() - GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players - ORDER BY s.scheduled_at ASC", - new - { - GuildId = guildId, - Cancelled = SessionStatus.Cancelled, - Active = ParticipantRegistrationStatus.Active, - Waitlisted = ParticipantRegistrationStatus.Waitlisted - }); - - var sessionList = sessions.ToList(); - if (sessionList.Count == 0) - return null; - - var sessionIds = sessionList.Select(s => s.Id).ToList(); - var participants = await connection.QueryAsync( - @"SELECT sp.session_id as SessionId, - p.display_name as DisplayName, - COALESCE(p.external_username, p.telegram_username) as TelegramUsername, - sp.registration_status as RegistrationStatus - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false - ORDER BY sp.registration_status ASC, sp.created_at ASC", - new { SessionIds = sessionIds }); - - var firstTitle = sessionList.First().Title; - var batchDtos = sessionList.Select(s => new SessionBatchDto( - s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList(); - - return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList()); - } -} -``` - -Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`: - -```csharp -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; - -namespace GmRelay.DiscordBot.Features.Sessions; - -[SlashCommand("listsessions", "Show upcoming game sessions in this server")] -public class DiscordListSessionsCommand : SlashCommandModule -{ - private readonly DiscordListSessionsHandler _handler; - - public DiscordListSessionsCommand(DiscordListSessionsHandler handler) - { - _handler = handler; - } - - public override async Task ExecuteAsync() - { - var guildId = Context.Guild?.Id.ToString() - ?? throw new InvalidOperationException("This command can only be used in a guild."); - var channelId = Context.Channel.Id.ToString(); - - var view = await _handler.BuildScheduleAsync(guildId, channelId, Context.CancellationToken); - - if (view is null) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message("📭 В этом сервере нет предстоящих игр.")); - return; - } - - var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view); - - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message(new InteractionMessageProperties() - .WithEmbeds(embeds) - .WithComponents(actionRows))); - } -} -``` - -### Step 2.4: Run test — verify GREEN - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal` -Expected: PASS. - -### Step 2.5: Commit - -```bash -git add src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs -git commit -m "feat(discord): add /listsessions slash command and handler - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 3: DiscordNewSessionHandler + Command - -**Files:** -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs` -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs` -- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs` - -**Context:** Handler должен: -1. Проверить права пользователя (owner/admin/manager). -2. Upsert игрока (GM) в `players` с `platform = 'Discord'`. -3. Upsert `game_groups` с `platform = 'Discord'`, `external_group_id = guild_id`. -4. Создать batch + sessions. -5. Отправить rendered schedule в Discord channel. -6. Сохранить `platform_messages` reference. - -### Step 3.1: Write the failing test - -Create `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`: - -```csharp -using GmRelay.DiscordBot.Features.Sessions; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordNewSessionHandlerTests -{ - [Fact] - public void ParseTimeInput_ShouldParseDiscordDateFormat() - { - var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30"); - Assert.True(result.IsSuccess); - Assert.Equal(2026, result.Value.Year); - Assert.Equal(5, result.Value.Month); - Assert.Equal(20, result.Value.Day); - Assert.Equal(19, result.Value.Hour); - Assert.Equal(30, result.Value.Minute); - } - - [Fact] - public void ParseTimeInput_ShouldRejectPastDate() - { - var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00"); - Assert.False(result.IsSuccess); - } - - [Fact] - public void ParseTimeInput_ShouldParseRussianDateFormat() - { - var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30"); - Assert.True(result.IsSuccess); - Assert.Equal(2026, result.Value.Year); - Assert.Equal(5, result.Value.Month); - Assert.Equal(20, result.Value.Day); - } - - [Fact] - public void ParseTimeInput_ShouldRejectInvalidFormat() - { - var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date"); - Assert.False(result.IsSuccess); - Assert.NotNull(result.Error); - } -} -``` - -### Step 3.2: Run test — verify RED - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal` -Expected: FAIL — `DiscordNewSessionHandler` not found. - -### Step 3.3: Write minimal implementation - -Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`: - -```csharp -using Dapper; -using GmRelay.DiscordBot.Infrastructure.Discord; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Platform; -using GmRelay.Shared.Rendering; -using Npgsql; - -namespace GmRelay.DiscordBot.Features.Sessions; - -public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error); - -public sealed class DiscordNewSessionHandler( - NpgsqlDataSource dataSource, - DiscordPermissionChecker permissionChecker, - IPlatformMessenger messenger, - ILogger logger) -{ - public static TimeParseResult ParseTimeInput(string input) - { - if (DateTimeOffset.TryParseExact( - input.Trim(), - "yyyy-MM-dd HH:mm", - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.AssumeUniversal, - out var result)) - { - if (result < DateTimeOffset.UtcNow) - return new TimeParseResult(false, default, "Дата находится в прошлом."); - - return new TimeParseResult(true, result.ToUniversalTime(), null); - } - - if (DateTimeOffset.TryParseExact( - input.Trim(), - "dd.MM.yyyy HH:mm", - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.AssumeUniversal, - out var altResult)) - { - if (altResult < DateTimeOffset.UtcNow) - return new TimeParseResult(false, default, "Дата находится в прошлом."); - - return new TimeParseResult(true, altResult.ToUniversalTime(), null); - } - - return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm"); - } - - public async Task HandleAsync( - string guildId, - string channelId, - ulong userId, - string userDisplayName, - IEnumerable userRoles, - ulong guildOwnerId, - string title, - DateTimeOffset scheduledAt, - int? maxPlayers, - string? joinLink, - CancellationToken cancellationToken) - { - await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); - - // Resolve db managers - var dbManagerUserIds = await connection.QueryAsync( - @"SELECT CAST(p.external_user_id AS BIGINT) - FROM group_managers gm - JOIN players p ON p.id = gm.player_id - JOIN game_groups g ON g.id = gm.group_id - WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", - new { GuildId = guildId }); - - if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, userRoles, dbManagerUserIds)) - { - throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии."); - } - - await using var transaction = await connection.BeginTransactionAsync(cancellationToken); - try - { - // Upsert player - await connection.ExecuteAsync( - @"INSERT INTO players (display_name, platform, external_user_id, external_username) - VALUES (@Name, 'Discord', @UserId, @Name) - ON CONFLICT (platform, external_user_id) - WHERE platform IS NOT NULL AND external_user_id IS NOT NULL - DO UPDATE SET display_name = EXCLUDED.display_name, - external_username = EXCLUDED.external_username", - new { Name = userDisplayName, UserId = userId.ToString() }, - transaction); - - // Upsert group - var groupId = await connection.ExecuteScalarAsync( - @"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id) - VALUES (@GuildId, 'Discord', @GuildId, @ChannelId) - ON CONFLICT (platform, external_group_id) - WHERE platform IS NOT NULL AND external_group_id IS NOT NULL - DO UPDATE SET name = EXCLUDED.name, - external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id) - RETURNING id", - new { GuildId = guildId, ChannelId = channelId }, - transaction); - - // Ensure manager record - await connection.ExecuteAsync( - @"INSERT INTO group_managers (group_id, player_id, role) - SELECT @GroupId, p.id, @OwnerRole - FROM players p - WHERE p.platform = 'Discord' AND p.external_user_id = @UserId - ON CONFLICT (group_id, player_id) DO NOTHING", - new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue }, - transaction); - - // Create batch + session - var batchId = Guid.NewGuid(); - var sessionId = await connection.ExecuteScalarAsync( - @"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players) - VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers) - RETURNING id", - new - { - BatchId = batchId, - GroupId = groupId, - Title = title, - Link = joinLink ?? string.Empty, - ScheduledAt = scheduledAt.UtcDateTime, - Status = SessionStatus.Planned, - MaxPlayers = maxPlayers - }, - transaction); - - await transaction.CommitAsync(cancellationToken); - - var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) }; - var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty()); - - await messenger.SendScheduleAsync( - new PlatformScheduleMessage( - new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId), - view, - null), - cancellationToken); - - return view; - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; - } - } -} -``` - -Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`: - -```csharp -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; - -namespace GmRelay.DiscordBot.Features.Sessions; - -[SlashCommand("newsession", "Create a new game session")] -public class DiscordNewSessionCommand : SlashCommandModule -{ - private readonly DiscordNewSessionHandler _handler; - - public DiscordNewSessionCommand(DiscordNewSessionHandler handler) - { - _handler = handler; - } - - [SlashCommandOption("title", "Game title", Required = true)] - public string Title { get; set; } = string.Empty; - - [SlashCommandOption("time", "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)", Required = true)] - public string Time { get; set; } = string.Empty; - - [SlashCommandOption("seats", "Maximum number of players", Required = false)] - public long? Seats { get; set; } - - [SlashCommandOption("link", "Join link", Required = false)] - public string? Link { get; set; } - - public override async Task ExecuteAsync() - { - var guild = Context.Guild - ?? throw new InvalidOperationException("This command can only be used in a guild."); - - var timeResult = DiscordNewSessionHandler.ParseTimeInput(Time); - if (!timeResult.IsSuccess) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($"❌ {timeResult.Error}")); - return; - } - - try - { - var view = await _handler.HandleAsync( - guildId: guild.Id.ToString(), - channelId: Context.Channel.Id.ToString(), - userId: Context.User.Id, - userDisplayName: Context.User.GlobalName ?? Context.User.Username, - userRoles: Context.GuildUser!.RoleIds, - guildOwnerId: guild.OwnerId, - title: Title, - scheduledAt: timeResult.Value, - maxPlayers: Seats is null ? null : (int)Seats.Value, - joinLink: Link, - Context.CancellationToken); - - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message("✅ Сессия создана!")); - } - catch (UnauthorizedAccessException ex) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($"⛅ {ex.Message}")); - } - catch (Exception ex) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message("💥 Произошла ошибка при создании сессии.")); - } - } -} -``` - -### Step 3.4: Run test — verify GREEN - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal` -Expected: PASS. - -### Step 3.5: Commit - -```bash -git add src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs -git commit -m "feat(discord): add /newsession slash command and handler - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 4: DiscordPlatformMessenger - -**Files:** -- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` -- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs` - -**Context:** Необходима реализация `IPlatformMessenger` для отправки schedule embeds и обновления существующих сообщений в Discord. Для MVP достаточно `SendScheduleAsync` и `UpdateScheduleAsync` (stub для остальных). - -### Step 4.1: Write the failing test - -Create `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`: - -```csharp -using GmRelay.DiscordBot.Infrastructure.Discord; -using GmRelay.Shared.Platform; -using GmRelay.Shared.Rendering; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordPlatformMessengerTests -{ - [Fact] - public void Constructor_ShouldAcceptRestClient() - { - // DiscordPlatformMessenger requires a NetCord.Rest.RestClient. - // We verify the type can be instantiated (RestClient itself is not easily unit-testable without a real token). - // This test proves the contract exists and compiles. - var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) }); - Assert.NotNull(constructor); - } - - [Fact] - public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger() - { - Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger))); - } -} -``` - -### Step 4.2: Run test — verify RED - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal` -Expected: FAIL — `DiscordPlatformMessenger` not found. - -### Step 4.3: Write minimal implementation - -Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`: - -```csharp -using GmRelay.DiscordBot.Rendering; -using GmRelay.Shared.Platform; -using GmRelay.Shared.Rendering; -using NetCord; -using NetCord.Rest; - -namespace GmRelay.DiscordBot.Infrastructure.Discord; - -public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger -{ - public async Task SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) - { - var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View); - - var channelId = ulong.Parse(message.Group.ExternalChannelId - ?? message.Group.ExternalGroupId); - - var msg = await restClient.SendMessageAsync( - channelId, - new MessageProperties() - .WithEmbeds(embeds) - .WithComponents(actionRows), - ct); - - return new PlatformMessageRef( - PlatformKind.Discord, - message.Group.ExternalGroupId, - null, - msg.Id.ToString()); - } - - public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) - { - if (message.ExistingMessage is null) - return; - - var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View); - - var channelId = ulong.Parse(message.Group.ExternalChannelId - ?? message.Group.ExternalGroupId); - var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId); - - await restClient.ModifyMessageAsync( - channelId, - messageId, - new MessageProperties() - .WithEmbeds(embeds) - .WithComponents(actionRows), - ct); - } - - public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) - { - // MVP: not needed for /newsession and /listsessions - return Task.CompletedTask; - } - - public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) - { - // MVP: not needed - return Task.CompletedTask; - } - - public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) - { - // MVP: not needed (commands answer inline via SlashCommandContext) - return Task.CompletedTask; - } - - public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) - { - // MVP: not needed - return Task.CompletedTask; - } -} -``` - -### Step 4.4: Run test — verify GREEN - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal` -Expected: PASS. - -### Step 4.5: Commit - -```bash -git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs -git commit -m "feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 5: Wire up DI and Register Commands - -**Files:** -- Modify: `src/GmRelay.DiscordBot/Program.cs` - -### Step 5.1: Write the failing test (structure test) - -Modify `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` — add test that asserts new handlers are registered: - -```csharp -[Fact] -public void Program_ShouldRegisterDiscordSessionHandlers() -{ - var program = ReadProgram(); - Assert.Contains("DiscordListSessionsHandler", program); - Assert.Contains("DiscordNewSessionHandler", program); - Assert.Contains("DiscordPermissionChecker", program); - Assert.Contains("DiscordPlatformMessenger", program); - Assert.Contains("IPlatformMessenger", program); -} -``` - -### Step 5.2: Run test — verify RED - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal` -Expected: FAIL — asserts not found in Program.cs. - -### Step 5.3: Write minimal implementation - -Modify `src/GmRelay.DiscordBot/Program.cs`: - -```csharp -using GmRelay.DiscordBot.Features.Sessions; -using GmRelay.DiscordBot.Infrastructure.Discord; -using GmRelay.Shared.Platform; - -// ... existing usings ... - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// After host.Build(): -host.AddSlashCommand("listsessions", "Show upcoming game sessions", async (DiscordListSessionsHandler handler, SlashCommandContext context) => -{ - // NetCord module-based approach preferred; if AddSlashCommand lambda doesn't support DI injection of custom services, - // rely on module classes registered via AddApplicationCommands -}); -``` - -**Important:** NetCord module classes (`DiscordListSessionsCommand`, `DiscordNewSessionCommand`) автоматически регистрируются через `AddApplicationCommands()` + `AddGatewayHandlers(typeof(Program).Assembly)`. Constructor injection в модулях работает через DI контейнер. Никаких дополнительных `AddSlashCommand` для модулей не требуется. - -Убедиться, что в Program.cs есть: -```csharp -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -``` - -### Step 5.4: Run test — verify GREEN - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal` -Expected: PASS. - -### Step 5.5: Commit - -```bash -git add src/GmRelay.DiscordBot/Program.cs tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs -git commit -m "feat(discord): wire up DI registrations for session handlers and messenger - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 6: Build Verification - -### Step 6.1: Build DiscordBot project - -Run: `dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore` -Expected: Build succeeds (0 errors, 0 warnings). - -### Step 6.2: Run all tests - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal` -Expected: All tests pass. - -### Step 6.3: Commit if any fixes needed - -If build or tests required fixes, commit them. - ---- - -## Task 7: Version Bump - -**Files to modify:** -- `Directory.Build.props`: `2.4.0` -- `compose.yaml`: обновить теги `gmrelay-bot`, `gmrelay-web`, `gmrelay-discord-bot` → `2.4.0` -- `.gitea/workflows/deploy.yml`: `VERSION: 2.4.0` -- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `` - -### Step 7.1: Bump version - -Apply изменения ко всем 4 файлам. - -### Step 7.2: Update version test - -Modify `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` — обновить `Version_ShouldBeSynchronizedForDiscordFeatureRelease` ожидаемое значение на `2.4.0`. - -### Step 7.3: Run version test - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Version_ShouldBeSynchronizedForDiscordFeatureRelease" --verbosity normal` -Expected: PASS. - -### Step 7.4: Commit - -```bash -git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs -git commit -m "chore: bump version to 2.4.0 - -Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Spec Coverage Self-Review - -| Issue Requirement | Task | -|---|---| -| Slash command `/newsession` | Task 3 | -| Slash command `/listsessions` | Task 2 | -| Сохранение platform group identity (guild/channel) | Task 3 (game_groups.platform, external_group_id, external_channel_id) | -| Минимальная проверка прав | Task 1 + Task 3 | -| Данные пишутся в общую PostgreSQL без Telegram-only assumptions | Task 2, 3 SQL используют platform-agnostic колонки | -| `/listsessions` публикует/обновляет расписание | Task 2 + Task 4 | - -**Placeholder scan:** Нет TBD, TODO, "implement later". Каждый шаг содержит конкретный код. - -**Type consistency:** `DiscordPermissionChecker.CanManageSchedule` перегружен для resolved permissions (ulong bitflag). Handler передает `Context.GuildUser.RoleIds` и `guild.OwnerId`. - ---- - -## Execution Handoff - -**Plan complete and saved to `docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md`.** - -**Two execution options:** - -1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration -2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review - -**Which approach?** diff --git a/docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md b/docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md deleted file mode 100644 index 46919fd..0000000 --- a/docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md +++ /dev/null @@ -1,1433 +0,0 @@ -# Discord Reschedule Voting Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Enable Discord users to initiate reschedule voting, cast votes via button interactions, and apply results automatically at deadline — without breaking the existing Telegram reschedule flow. - -**Architecture:** Extract platform-neutral reschedule logic (vote persistence, winner selection, finalization) into `GmRelay.Shared`. Keep Telegram-specific handlers unchanged (with `source_platform` annotations). Build Discord-specific slash command (`/reschedule`), button interaction handlers, and a dedicated deadline background service in `GmRelay.DiscordBot`. Store Discord vote message references in `platform_messages`. - -**Tech Stack:** .NET 10, Npgsql + Dapper.AOT, NetCord (Discord gateway), Native AOT. - ---- - -## File Structure - -| File | Action | Responsibility | -|---|---|---| -| `src/GmRelay.Bot/Migrations/V017__discord_reschedule_proposals.sql` | Create | Add `source_platform` and `proposed_by_external_user_id` to `reschedule_proposals` | -| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` | Create (move from Bot) | Winner selection logic | -| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` | Create (move from Bot) | Parse 2-3 options + deadline | -| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs` | Create (move from Bot) | DTOs: `RescheduleOptionDto`, `RescheduleOptionVoteDto`, `RescheduleOptionVoteCount`, `RescheduleVoteDecision`, `RescheduleVoteOutcome`, `VoteParticipantDto` | -| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs` | Create | Platform-neutral DB finalization logic | -| `src/GmRelay.Shared/Platform/ISystemClock.cs` | Create (move from Bot) | Clock abstraction | -| `src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs` | Modify (namespace only) | Implementation stays in Bot | -| `src/GmRelay.Bot/Features/Sessions/RescheduleSession/*.cs` | Modify | Update usings, add `source_platform = 'Telegram'` | -| `src/GmRelay.DiscordBot/Program.cs` | Modify | Register new handlers, services, `ISystemClock` | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs` | Create | `/reschedule` slash command | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs` | Create | Validates GM, creates proposal + options, sends vote message | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs` | Create | Upserts vote, re-renders vote message | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` | Modify | Add `reschedule_vote` route | -| `src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs` | Create | Builds Discord embed + buttons for voting | -| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Modify | Implement `SendGroupMessageAsync`, add `UpdateMessageAsync` helper | -| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` | Create | BackgroundService: polls proposals, calls finalizer, edits Discord messages | -| `tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/*Tests.cs` | Modify | Update namespaces/usings after move | -| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleCommandTests.cs` | Create | TDD tests for `/reschedule` command | -| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleVoteHandlerTests.cs` | Create | TDD tests for vote button handler | -| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleVotingRendererTests.cs` | Create | TDD tests for renderer | -| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleVotingDeadlineServiceTests.cs` | Create | TDD tests for deadline service | -| `tests/GmRelay.Bot.Tests/Shared/RescheduleVoteRulesTests.cs` | Create (move) | Winner selection tests | -| `tests/GmRelay.Bot.Tests/Shared/RescheduleVotingInputTests.cs` | Create (move) | Parser tests | -| `Directory.Build.props` | Modify | Version bump 2.5.0 → 2.6.0 | -| `compose.yaml` | Modify | Image tags 2.6.0 | -| `.gitea/workflows/deploy.yml` | Modify | VERSION 2.6.0 | -| `src/GmRelay.Web/Components/Layout/NavMenu.razor` | Modify | Version label 2.6.0 | - ---- - -## Task 1: Database Migration V017 - -**Files:** -- Create: `src/GmRelay.Bot/Migrations/V017__discord_reschedule_proposals.sql` - -- [ ] **Step 1: Write migration SQL** - -```sql --- ============================================================= --- V017: Add platform columns to reschedule_proposals for Discord support --- ============================================================= - -ALTER TABLE reschedule_proposals - ADD COLUMN source_platform VARCHAR(50), - ADD COLUMN proposed_by_external_user_id VARCHAR(255); - --- Backfill existing Telegram proposals -UPDATE reschedule_proposals - SET source_platform = 'Telegram', - proposed_by_external_user_id = proposed_by::TEXT - WHERE source_platform IS NULL; -``` - -- [ ] **Step 2: Verify migration applies cleanly** - -Run: `dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore` -Expected: Build succeeds (DbUp will run migration on next startup). - -- [ ] **Step 3: Commit** - -```bash -git add src/GmRelay.Bot/Migrations/V017__discord_reschedule_proposals.sql -git commit -m "feat(db): add platform columns to reschedule_proposals" -``` - ---- - -## Task 2: Extract Shared Reschedule Types - -**Files:** -- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` -- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` -- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs` -- Delete (after move): `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` -- Delete (after move): `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs` -- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs` -- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs` - -- [ ] **Step 1: Write shared DTOs file** - -```csharp -namespace GmRelay.Shared.Features.Sessions.RescheduleSession; - -internal enum RescheduleVoteOutcome { Pending, Rejected, Approved } - -internal sealed record RescheduleVoteDecision( - RescheduleVoteOutcome Outcome, - string Reason, - Guid? SelectedOptionId = null, - string CallbackText = "", - bool ShouldRescheduleSession = false, - bool ShouldResetParticipantRsvps = false); - -internal sealed record RescheduleOptionVoteCount(Guid OptionId, int VoteCount); - -internal sealed record RescheduleOptionDto(Guid OptionId, int DisplayOrder, DateTimeOffset ProposedAt); - -internal sealed record RescheduleOptionVoteDto(Guid OptionId, Guid PlayerId, string DisplayName, string? TelegramUsername); - -internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername, long TelegramId = 0); -``` - -- [ ] **Step 2: Write shared RescheduleVoteRules** - -Copy content from `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` into new namespace `GmRelay.Shared.Features.Sessions.RescheduleSession`. - -- [ ] **Step 3: Write shared RescheduleVotingInput** - -Copy content from `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` into new namespace `GmRelay.Shared.Features.Sessions.RescheduleSession`. - -- [ ] **Step 4: Update Telegram handler usings** - -In `HandleRescheduleTimeInputHandler.cs`, `HandleRescheduleVoteHandler.cs`, and `RescheduleVotingDeadlineService.cs`, replace: -```csharp -using GmRelay.Bot.Features.Sessions.RescheduleSession; -``` -with: -```csharp -using GmRelay.Shared.Features.Sessions.RescheduleSession; -``` - -- [ ] **Step 5: Update test usings** - -Same replacement in test files. - -- [ ] **Step 6: Run tests to verify no regressions** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule" --verbosity normal` -Expected: All existing reschedule tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add src/GmRelay.Shared/Features/Sessions/RescheduleSession/ -git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ -git add tests/GmRelay.Bot.Tests/ -git commit -m "refactor(shared): extract reschedule voting types to Shared" -``` - ---- - -## Task 3: Create Shared RescheduleVotingFinalizer - -**Files:** -- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs` -- Create: `src/GmRelay.Shared/Platform/ISystemClock.cs` -- Modify: `src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs` -- Modify: `src/GmRelay.Bot/Program.cs` - -- [ ] **Step 1: Write ISystemClock abstraction** - -```csharp -namespace GmRelay.Shared.Platform; - -public interface ISystemClock -{ - DateTimeOffset UtcNow { get; } -} -``` - -- [ ] **Step 2: Move SystemClock implementation** - -Move `SystemClock` from `src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs` to `src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs` and update namespace to `GmRelay.Bot.Infrastructure.Scheduling`. It implements `GmRelay.Shared.Platform.ISystemClock`. - -```csharp -namespace GmRelay.Bot.Infrastructure.Scheduling; - -public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock -{ - public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; -} -``` - -- [ ] **Step 3: Write RescheduleVotingFinalizer** - -Extract DB-only logic from `RescheduleVotingDeadlineService`. The finalizer performs the transaction, updates session/participants/proposal, and returns the result. It does NOT touch any messenger or bot client. - -```csharp -namespace GmRelay.Shared.Features.Sessions.RescheduleSession; - -using Dapper; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Platform; -using Npgsql; - -internal sealed record FinalizeProposalInput( - Guid ProposalId, - IReadOnlyList Options, - IReadOnlyList Participants, - IReadOnlyList Votes, - RescheduleVoteDecision Decision, - RescheduleOptionDto? SelectedOption); - -internal sealed record FinalizeProposalResult( - Guid ProposalId, - Guid SessionId, - string Title, - DateTime CurrentScheduledAt, - Guid BatchId, - string NotificationMode, - RescheduleVoteDecision Decision, - RescheduleOptionDto? SelectedOption, - IReadOnlyList Participants); - -public sealed class RescheduleVotingFinalizer( - NpgsqlDataSource dataSource, - ISystemClock clock, - ILogger logger) -{ - public async Task> GetDueProposalIdsAsync(CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - return (await connection.QueryAsync( - """ - SELECT id - FROM reschedule_proposals - WHERE status = 'Voting' - AND voting_deadline_at IS NOT NULL - AND voting_deadline_at <= @Now - ORDER BY voting_deadline_at - LIMIT 25 - """, - new { Now = clock.UtcNow.UtcDateTime })).ToList(); - } - - public async Task FinalizeAsync(Guid proposalId, CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); - - var proposal = await connection.QuerySingleOrDefaultAsync( - """ - SELECT rp.id AS Id, - rp.session_id AS SessionId, - rp.voting_deadline_at AS VotingDeadlineAt, - s.title AS Title, - s.scheduled_at AS CurrentScheduledAt, - s.batch_id AS BatchId, - s.notification_mode AS NotificationMode - FROM reschedule_proposals rp - JOIN sessions s ON s.id = rp.session_id - WHERE rp.id = @ProposalId - AND rp.status = 'Voting' - AND rp.voting_deadline_at IS NOT NULL - AND rp.voting_deadline_at <= @Now - FOR UPDATE - """, - new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime }, - transaction); - - if (proposal is null) return null; - - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, - p.telegram_id AS TelegramId - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND sp.is_gm = false - AND sp.registration_status = @Active - ORDER BY p.display_name - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction)).ToList(); - - var options = (await connection.QueryAsync( - """ - SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt - FROM reschedule_options - WHERE proposal_id = @ProposalId - ORDER BY display_order - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - var votes = (await connection.QueryAsync( - """ - SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername - FROM reschedule_option_votes rov - JOIN players p ON p.id = rov.player_id - WHERE rov.proposal_id = @ProposalId - ORDER BY rov.voted_at, p.display_name - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - var voteCounts = options - .Select(o => new RescheduleOptionVoteCount(o.OptionId, votes.Count(v => v.OptionId == o.OptionId))) - .ToList(); - var decision = RescheduleVoteRules.SelectWinner(voteCounts); - var selectedOption = decision.SelectedOptionId is { } sid - ? options.Single(o => o.OptionId == sid) - : null; - - if (selectedOption is not null) - { - await connection.ExecuteAsync( - """ - UPDATE sessions - SET scheduled_at = @NewTime, status = @Status, - confirmation_message_id = NULL, confirmation_sent_at = NULL, - link_message_id = NULL, one_hour_reminder_processed_at = NULL, - updated_at = now() - WHERE id = @SessionId - """, - new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned }, - transaction); - - await connection.ExecuteAsync( - """ - UPDATE session_participants - SET rsvp_status = 'Pending', responded_at = NULL - WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction); - - await connection.ExecuteAsync( - """ - UPDATE reschedule_proposals - SET status = 'Approved', selected_option_id = @SelectedOptionId, proposed_at = @ProposedAt - WHERE id = @ProposalId - """, - new { ProposalId = proposal.Id, SelectedOptionId = selectedOption.OptionId, ProposedAt = selectedOption.ProposedAt }, - transaction); - } - else - { - await connection.ExecuteAsync( - "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId", - new { ProposalId = proposal.Id }, - transaction); - } - - await transaction.CommitAsync(ct); - - return new FinalizeProposalResult( - proposal.Id, proposal.SessionId, proposal.Title, proposal.CurrentScheduledAt, - proposal.BatchId, proposal.NotificationMode, decision, selectedOption, participants); - } - - internal sealed record DueProposalDto( - Guid Id, Guid SessionId, DateTimeOffset VotingDeadlineAt, - string Title, DateTime CurrentScheduledAt, Guid BatchId, string NotificationMode); -} -``` - -- [ ] **Step 4: Update Telegram RescheduleVotingDeadlineService to use finalizer** - -Replace DB transaction logic with calls to `RescheduleVotingFinalizer`. Keep Telegram-specific message editing and DM sending. - -- [ ] **Step 5: Register finalizer in Bot Program.cs** - -```csharp -builder.Services.AddSingleton(); -``` - -- [ ] **Step 6: Run Telegram reschedule tests** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule" --verbosity normal` -Expected: All pass. - -- [ ] **Step 7: Commit** - -```bash -git add src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs -git add src/GmRelay.Shared/Platform/ISystemClock.cs -git add src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs -git add src/GmRelay.Bot/Program.cs -git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs -git commit -m "feat(shared): add RescheduleVotingFinalizer and ISystemClock" -``` - ---- - -## Task 4: Discord /reschedule Slash Command - -**Files:** -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs` -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs` -- Modify: `src/GmRelay.DiscordBot/Program.cs` - -- [ ] **Step 1: Write failing test for DiscordRescheduleHandler validation** - -```csharp -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordRescheduleHandlerTests -{ - [Fact] - public async Task HandleAsync_ShouldThrow_WhenUserIsNotManager() - { - // Arrange: mock NpgsqlDataSource with no managers - // Act + Assert: UnauthorizedAccessException - } -} -``` - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordRescheduleHandlerTests" --verbosity normal` -Expected: FAIL (class not found). - -- [ ] **Step 2: Implement DiscordRescheduleHandler** - -```csharp -namespace GmRelay.DiscordBot.Features.Sessions; - -using Dapper; -using GmRelay.DiscordBot.Infrastructure.Discord; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Features.Sessions.RescheduleSession; -using GmRelay.Shared.Platform; -using GmRelay.Shared.Rendering; -using Npgsql; -using NetCord.Rest; - -public sealed class DiscordRescheduleHandler( - NpgsqlDataSource dataSource, - DiscordPermissionChecker permissionChecker, - IPlatformMessenger messenger, - ILogger logger) -{ - public async Task HandleAsync( - string guildId, - string channelId, - ulong userId, - string userDisplayName, - ulong resolvedPermissions, - ulong guildOwnerId, - Guid sessionId, - IReadOnlyList options, - DateTimeOffset deadline, - CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - - var dbManagerUserIds = await connection.QueryAsync( - @"SELECT CAST(p.external_user_id AS BIGINT) - FROM group_managers gm - JOIN players p ON p.id = gm.player_id - JOIN game_groups g ON g.id = gm.group_id - WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", - new { GuildId = guildId }); - - if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) - { - throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии."); - } - - // Ensure player exists - await connection.ExecuteAsync( - @"INSERT INTO players (display_name, platform, external_user_id, external_username) - VALUES (@Name, 'Discord', @UserId, @Name) - ON CONFLICT (platform, external_user_id) - WHERE platform IS NOT NULL AND external_user_id IS NOT NULL - DO UPDATE SET display_name = EXCLUDED.display_name", - new { Name = userDisplayName, UserId = userId.ToString() }); - - // Verify session exists and is not cancelled - var session = await connection.QuerySingleOrDefaultAsync( - """ - SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt, - 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.platform = 'Discord' AND p.external_user_id = @UserId - ) AS CanManage - FROM sessions s - WHERE s.id = @SessionId AND s.status != @Cancelled - """, - new { SessionId = sessionId, UserId = userId.ToString(), Cancelled = SessionStatus.Cancelled }); - - if (session is null) - throw new InvalidOperationException("Сессия не найдена или отменена."); - - if (!session.CanManage) - throw new UnauthorizedAccessException("Только owner или co-GM может переносить сессию."); - - var hasActive = await connection.ExecuteScalarAsync( - "SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))", - new { SessionId = sessionId }); - - if (hasActive) - throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии."); - - var proposalId = Guid.NewGuid(); - var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList(); - - await using var transaction = await connection.BeginTransactionAsync(ct); - - await connection.ExecuteAsync( - """ - INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at) - VALUES (@Id, @SessionId, 0, 'Discord', @ProposedBy, 'Voting', @Deadline) - """, - new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime }, - transaction); - - foreach (var option in optionDtos) - { - await connection.ExecuteAsync( - """ - INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) - VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) - """, - new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder }, - transaction); - } - - await transaction.CommitAsync(ct); - - // Load participants for rendering - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active - """, - new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); - - var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( - session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); - - var group = new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId); - var msgRef = await messenger.SendGroupMessageAsync( - group, - session.Title, - new[] { embed }, - new[] { actionRow }, - ct); - - // Store message ref in platform_messages - await connection.ExecuteAsync( - """ - INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose) - VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote') - """, - new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = msgRef.ExternalMessageId }); - - logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId); - - return new DiscordRescheduleResult(proposalId, optionDtos, deadline); - } -} - -public sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt, bool CanManage); -public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList Options, DateTimeOffset Deadline); -``` - -- [ ] **Step 3: Note on IPlatformMessenger extension** - -`IPlatformMessenger` needs a new overload for `SendGroupMessageAsync` that accepts embeds/components. Add this to `IPlatformMessenger`: - -```csharp -Task SendGroupMessageAsync(PlatformGroup group, string text, IReadOnlyList embeds, IReadOnlyList actionRows, CancellationToken ct); -``` - -Wait — `EmbedProperties` is NetCord-specific and cannot be referenced in `GmRelay.Shared`. Therefore we need a platform-neutral abstraction. - -**Revised approach:** Create a `PlatformRescheduleVoteMessage` record in Shared, and add a method to `IPlatformMessenger`: - -```csharp -Task SendRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); -Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); -``` - -Where `PlatformRescheduleVoteMessage` contains a `RescheduleVoteViewModel` (platform-neutral view model for the vote UI). - -Then `DiscordRescheduleVotingRenderer` and `TelegramRescheduleVotingRenderer` (new, extracting from existing `HandleRescheduleTimeInputHandler.BuildVotingMessage/Keyboard`) render this view model into platform-specific formats inside the respective `IPlatformMessenger` implementations. - -This is cleaner but more complex. For the plan, let's document this abstraction. - -- [ ] **Step 4: Write DiscordRescheduleCommand** - -```csharp -namespace GmRelay.DiscordBot.Features.Sessions; - -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; - -[SlashCommand("reschedule", "Initiate reschedule voting for a session")] -public class DiscordRescheduleCommand : ApplicationCommandModule -{ - private readonly DiscordRescheduleHandler _handler; - private readonly ILogger _logger; - - public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger logger) - { - _handler = handler; - _logger = logger; - } - - public async Task ExecuteAsync( - [SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText, - [SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1, - [SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2, - [SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null, - [SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "") - { - var guild = Context.Guild - ?? throw new InvalidOperationException("This command can only be used in a guild."); - - if (!Guid.TryParse(sessionIdText, out var sessionId)) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message("❌ Некорректный ID сессии.")); - return; - } - - var options = new List { option1, option2 }; - if (!string.IsNullOrWhiteSpace(option3)) - options.Add(option3); - - var parsedOptions = new List(); - foreach (var opt in options) - { - var result = DiscordNewSessionHandler.ParseTimeInput(opt); - if (!result.IsSuccess) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($"❌ {opt}: {result.Error}")); - return; - } - parsedOptions.Add(result.Value); - } - - var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline); - if (!deadlineResult.IsSuccess) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}")); - return; - } - - if (deadlineResult.Value >= parsedOptions.Min()) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени.")); - return; - } - - var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id); - - try - { - var result = await _handler.HandleAsync( - guildId: guild.Id.ToString(), - channelId: Context.Channel.Id.ToString(), - userId: Context.User.Id, - userDisplayName: Context.User.GlobalName ?? Context.User.Username, - resolvedPermissions: resolvedPermissions, - guildOwnerId: guild.OwnerId, - sessionId: sessionId, - options: parsedOptions, - deadline: deadlineResult.Value, - CancellationToken.None); - - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.")); - } - catch (UnauthorizedAccessException ex) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($":no_entry: {ex.Message}")); - } - catch (InvalidOperationException ex) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($":warning: {ex.Message}")); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId); - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message(":boom: Ошибка при запуске голосования.")); - } - } - - private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId) - { - if (!guild.Users.TryGetValue(userId, out var guildUser)) - return 0; - ulong resolved = 0; - foreach (var roleId in guildUser.RoleIds) - { - if (guild.Roles.TryGetValue(roleId, out var role)) - resolved |= (ulong)role.Permissions; - } - return resolved; - } -} -``` - -- [ ] **Step 5: Register handler in Discord Program.cs** - -```csharp -builder.Services.AddSingleton(); -``` - -- [ ] **Step 6: Run tests** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordReschedule" --verbosity normal` -Expected: Tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs -git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs -git add src/GmRelay.DiscordBot/Program.cs -git commit -m "feat(discord): add /reschedule slash command and handler" -``` - ---- - -## Task 5: Discord Vote Button Handler - -**Files:** -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs` -- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` - -- [ ] **Step 1: Write failing test** - -```csharp -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordRescheduleVoteHandlerTests -{ - [Fact] - public async Task HandleAsync_ShouldUpsertVote_WhenParticipantExists() - { - // Arrange: create proposal, option, participant in in-memory DB - // Act: handler.HandleAsync(...) - // Assert: vote exists in DB - } -} -``` - -Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVoteHandlerTests"` -Expected: FAIL (class not found). - -- [ ] **Step 2: Implement handler** - -```csharp -namespace GmRelay.DiscordBot.Features.Sessions; - -using Dapper; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Features.Sessions.RescheduleSession; -using GmRelay.Shared.Platform; -using Npgsql; -using NetCord.Rest; - -public sealed record DiscordRescheduleVoteInput( - Guid OptionId, ulong UserId, string InteractionId, - string GuildId, string ChannelId, string MessageId); - -public sealed class DiscordRescheduleVoteHandler( - NpgsqlDataSource dataSource, - IPlatformMessenger messenger, - ILogger logger) -{ - public async Task HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); - - var proposal = await connection.QuerySingleOrDefaultAsync( - """ - SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt, - s.title AS Title, s.scheduled_at AS CurrentScheduledAt - FROM reschedule_options ro - JOIN reschedule_proposals rp ON rp.id = ro.proposal_id - JOIN sessions s ON s.id = rp.session_id - WHERE ro.id = @OptionId AND rp.status = 'Voting' - """, - new { input.OptionId }, - transaction); - - if (proposal is null) - return "Голосование уже завершено или не найдено."; - - if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) - return "Дедлайн уже прошёл. Результаты скоро будут применены."; - - var playerId = await connection.ExecuteScalarAsync( - """ - SELECT p.id - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND p.platform = 'Discord' - AND p.external_user_id = @UserId - AND sp.is_gm = false - AND sp.registration_status = @Active - """, - new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active }, - transaction); - - if (playerId is null) - return "Вы не являетесь участником этой сессии."; - - await connection.ExecuteAsync( - """ - INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id) - VALUES (@ProposalId, @PlayerId, @OptionId) - ON CONFLICT (proposal_id, player_id) DO UPDATE - SET option_id = EXCLUDED.option_id, voted_at = now() - """, - new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId }, - transaction); - - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active - ORDER BY p.display_name - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction)).ToList(); - - var options = (await connection.QueryAsync( - """ - SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt - FROM reschedule_options - WHERE proposal_id = @ProposalId - ORDER BY display_order - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - var votes = (await connection.QueryAsync( - """ - SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername - FROM reschedule_option_votes rov - JOIN players p ON p.id = rov.player_id - WHERE rov.proposal_id = @ProposalId - ORDER BY rov.voted_at, p.display_name - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - await transaction.CommitAsync(ct); - - // Update Discord vote message - var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( - proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, options, participants, votes); - - await messenger.UpdateRescheduleVoteAsync( - new PlatformRescheduleVoteMessage( - new PlatformGroup(PlatformKind.Discord, input.GuildId, input.GuildId, input.ChannelId), - new PlatformMessageRef(PlatformKind.Discord, input.GuildId, null, input.MessageId), - embed, - actionRow), - ct); - - return "Ваш голос учтён. До дедлайна его можно изменить."; - } -} -``` - -- [ ] **Step 3: Extend DiscordSessionInteractionModule** - -Add `RescheduleVoteAsync` method: - -```csharp -[ComponentInteraction("reschedule_vote")] -public async Task RescheduleVoteAsync(string optionId) -{ - if (!Guid.TryParse(optionId, out var parsedOptionId)) - { - await RespondAsync(CreateEphemeralReply("Vote button is outdated.")); - return; - } - - var input = CreateInput(Guid.Empty); // sessionId not needed for vote - var voteInput = new DiscordRescheduleVoteInput( - parsedOptionId, Context.User.Id, Context.Interaction.Id.ToString(), - input.GuildId, input.ChannelId, input.MessageId); - - await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); - - try - { - var replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None); - await CompleteResponseAsync(replyText); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId); - await CompleteResponseAsync("Не удалось обработать голос."); - } -} -``` - -Update constructor to inject `DiscordRescheduleVoteHandler voteHandler`. - -- [ ] **Step 4: Register in Discord Program.cs** - -```csharp -builder.Services.AddSingleton(); -``` - -- [ ] **Step 5: Run tests** - -Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVote"` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs -git add src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs -git add src/GmRelay.DiscordBot/Program.cs -git commit -m "feat(discord): add reschedule vote button handler" -``` - ---- - -## Task 6: Discord Reschedule Voting Renderer - -**Files:** -- Create: `src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs` - -- [ ] **Step 1: Write failing renderer test** - -```csharp -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordRescheduleVotingRendererTests -{ - [Fact] - public void Render_ShouldProduceEmbedWithOptionsAndButtons() - { - var options = new[] { new RescheduleOptionDto(Guid.NewGuid(), 1, new DateTimeOffset(2026,5,25,16,0,0,TimeSpan.Zero)) }; - var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( - "Test", DateTime.UtcNow, DateTimeOffset.UtcNow.AddHours(2), options, [], []); - - Assert.NotNull(embed); - Assert.NotNull(actionRow); - Assert.Contains("Перенос", embed.Title); - } -} -``` - -Run: FAIL (class not found). - -- [ ] **Step 2: Implement renderer** - -```csharp -namespace GmRelay.DiscordBot.Rendering; - -using GmRelay.Shared.Domain; -using GmRelay.Shared.Features.Sessions.RescheduleSession; -using NetCord; -using NetCord.Rest; - -public static class DiscordRescheduleVotingRenderer -{ - public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render( - string title, - DateTime currentTime, - DateTimeOffset deadline, - IReadOnlyList options, - IReadOnlyList participants, - IReadOnlyList votes) - { - var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList()); - var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet(); - var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList(); - - var description = new System.Text.StringBuilder(); - description.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); - description.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); - description.AppendLine(); - description.AppendLine("Выберите один из вариантов:"); - - foreach (var option in options.OrderBy(o => o.DisplayOrder)) - { - var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []); - description.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов"); - if (optionVotes.Count > 0) - { - description.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}"); - } - } - - if (pending.Count > 0) - { - description.AppendLine(); - description.AppendLine($"Не проголосовали: {string.Join(", ", pending)}"); - } - - description.AppendLine(); - description.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}"); - - var embed = new EmbedProperties() - .WithTitle($"🔄 Перенос сессии «{title}»") - .WithDescription(description.ToString()) - .WithColor(new Color(0xFEE75C)); - - var actionRow = new ActionRowProperties(); - foreach (var option in options.OrderBy(o => o.DisplayOrder)) - { - actionRow.Add(new ButtonProperties( - $"reschedule_vote:{option.OptionId}", - $"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}", - ButtonStyle.Primary)); - } - - return (embed, actionRow); - } - - private static string FormatButtonTime(DateTimeOffset utc) - => utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture); -} -``` - -- [ ] **Step 3: Run tests** - -Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVotingRenderer"` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs -git commit -m "feat(discord): add reschedule voting message renderer" -``` - ---- - -## Task 7: Extend IPlatformMessenger and Implementations - -**Files:** -- Modify: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` -- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` - -- [ ] **Step 1: Add reschedule vote methods to interface** - -```csharp -namespace GmRelay.Shared.Platform; - -public interface IPlatformMessenger -{ - // existing methods ... - - Task SendRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); - Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); -} - -public sealed record PlatformRescheduleVoteMessage( - PlatformGroup Group, - PlatformMessageRef ExistingMessage, - string Title, - DateTime CurrentScheduledAt, - DateTimeOffset Deadline, - IReadOnlyList Options, - IReadOnlyList Participants, - IReadOnlyList Votes); -``` - -Wait — `RescheduleOptionDto` is in `GmRelay.Shared.Features.Sessions.RescheduleSession`, so the interface can reference it. But the interface currently doesn't reference feature namespaces. To keep it clean, create a dedicated view model: - -```csharp -public sealed record PlatformRescheduleVoteMessage( - PlatformGroup Group, - PlatformMessageRef ExistingMessage, - string HtmlText, // Telegram uses HTML, Discord uses markdown-ish - IReadOnlyList Actions); -``` - -Actually, this is getting over-engineered. Let's keep it pragmatic: - -For Discord, the handler directly calls `restClient.ModifyMessageAsync` or sends via interaction response. We don't need `IPlatformMessenger` for the vote message — the handler can use `RestClient` directly, injected alongside `IPlatformMessenger`. - -**Revised approach:** Keep `IPlatformMessenger` unchanged. Discord handlers inject `RestClient` directly for message operations. Telegram handlers keep using `ITelegramBotClient`. - -For the deadline service, Discord version will inject `RestClient`. - -This is simpler and avoids over-engineering the shared interface. - -- [ ] **Step 2: Implement DiscordPlatformMessenger.SendGroupMessageAsync** - -```csharp -public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) -{ - var channelId = ulong.Parse(group.ExternalChannelId ?? group.ExternalGroupId); - await restClient.SendMessageAsync(channelId, htmlText); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs -git commit -m "feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger" -``` - ---- - -## Task 8: Discord Reschedule Voting Deadline Service - -**Files:** -- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` -- Modify: `src/GmRelay.DiscordBot/Program.cs` - -- [ ] **Step 1: Write failing test** - -```csharp -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordRescheduleVotingDeadlineServiceTests -{ - [Fact] - public async Task ProcessDueProposals_ShouldFinalize_WhenDeadlinePassed() - { - // Arrange: insert proposal with past deadline - // Act: call service method directly - // Assert: proposal status = Approved/Rejected - } -} -``` - -Run: FAIL. - -- [ ] **Step 2: Implement service** - -```csharp -namespace GmRelay.DiscordBot.Features.Sessions; - -using Dapper; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Features.Sessions.RescheduleSession; -using GmRelay.Shared.Platform; -using GmRelay.Shared.Rendering; -using NetCord.Rest; -using Npgsql; - -public sealed class DiscordRescheduleVotingDeadlineService( - NpgsqlDataSource dataSource, - RescheduleVotingFinalizer finalizer, - RestClient restClient, - ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - await ProcessDueProposals(stoppingToken); - using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - await ProcessDueProposals(stoppingToken); - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } - } - - private async Task ProcessDueProposals(CancellationToken ct) - { - try - { - var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); - foreach (var id in proposalIds) - { - await FinalizeOneAsync(id, ct); - } - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to process Discord reschedule proposals"); - } - } - - private async Task FinalizeOneAsync(Guid proposalId, CancellationToken ct) - { - var result = await finalizer.FinalizeAsync(proposalId, ct); - if (result is null) return; - - // Only process Discord-sourced proposals - if (!await IsDiscordProposalAsync(proposalId, ct)) - return; - - // Update Discord vote message - await TryUpdateDiscordVoteMessage(result, ct); - - // Update batch schedule if approved - if (result.SelectedOption is not null) - { - await TryUpdateBatchScheduleAsync(result, ct); - } - - logger.LogInformation( - "Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", - proposalId, result.SessionId, result.Decision.Outcome); - } - - private async Task IsDiscordProposalAsync(Guid proposalId, CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - var platform = await connection.ExecuteScalarAsync( - "SELECT source_platform FROM reschedule_proposals WHERE id = @Id", - new { Id = proposalId }); - return platform == "Discord"; - } - - private async Task TryUpdateDiscordVoteMessage(FinalizeProposalResult result, CancellationToken ct) - { - try - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - var msgRef = await connection.QuerySingleOrDefaultAsync( - """ - SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId - FROM platform_messages - WHERE session_id = @SessionId AND purpose = 'reschedule_vote' AND platform = 'Discord' - ORDER BY created_at DESC - LIMIT 1 - """, - new { result.SessionId }); - - if (msgRef is null) return; - - var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( - result.Title, result.CurrentScheduledAt, result.Deadline, - result.Options, result.Participants, result.Votes); - - var channelId = ulong.Parse(msgRef.ExternalChannelId); - var messageId = ulong.Parse(msgRef.ExternalMessageId); - - // Disable buttons after finalization - var disabledRow = new ActionRowProperties(); - foreach (var btn in actionRow.OfType()) - { - disabledRow.Add(new ButtonProperties(btn.CustomId, btn.Label, ButtonStyle.Secondary) { Disabled = true }); - } - - var resultText = result.SelectedOption is not null - ? $"✅ Голосование завершено. Победил вариант {result.SelectedOption.DisplayOrder}: **{result.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)." - : $"❌ Голосование завершено. {result.Decision.Reason}"; - - var updatedEmbed = embed.WithDescription($"{embed.Description}\n\n{resultText}"); - - await restClient.ModifyMessageAsync(channelId, messageId, options => - { - options.Embeds = [updatedEmbed]; - options.Components = [disabledRow]; - }); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId); - } - } - - private async Task TryUpdateBatchScheduleAsync(FinalizeProposalResult result, CancellationToken ct) - { - // Reuse existing Discord batch update logic or IPlatformMessenger - // This is simplified — full implementation needs DiscordListSessionsHandler query + renderer - } - - internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId); -} -``` - -- [ ] **Step 3: Register service in Discord Program.cs** - -```csharp -builder.Services.AddSingleton(); // need to add SystemClock to DiscordBot or Shared -builder.Services.AddHostedService(); -builder.Services.AddSingleton(); -``` - -Wait, `SystemClock` is in `GmRelay.Bot`. Need to either move it to Shared or duplicate in DiscordBot. - -Simplest: create `SystemClock` in `GmRelay.DiscordBot.Infrastructure`: - -```csharp -namespace GmRelay.DiscordBot.Infrastructure; -public sealed class SystemClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } -``` - -- [ ] **Step 4: Run tests** - -Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVotingDeadline"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs -git add src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs -git add src/GmRelay.DiscordBot/Program.cs -git commit -m "feat(discord): add reschedule voting deadline service" -``` - ---- - -## Task 9: Update Telegram Handlers for Source Platform - -**Files:** -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs` -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs` - -- [ ] **Step 1: Update InitiateRescheduleHandler** - -Change INSERT to include `source_platform`: -```sql -INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status) -VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime') -``` - -- [ ] **Step 2: Update HandleRescheduleTimeInputHandler** - -Change UPDATE to include `source_platform` backfill if null (or ensure it's set). Actually, `InitiateRescheduleHandler` already sets it. No change needed in time input handler. - -- [ ] **Step 3: Update RescheduleVotingDeadlineService to filter Telegram** - -Add `WHERE rp.source_platform = 'Telegram'` to the proposal query. - -- [ ] **Step 4: Run all Telegram tests** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule" --verbosity normal` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ -git commit -m "fix(telegram): annotate reschedule proposals with source_platform" -``` - ---- - -## Task 10: Version Bump - -**Files:** -- Modify: `Directory.Build.props` -- Modify: `compose.yaml` -- Modify: `.gitea/workflows/deploy.yml` -- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - -- [ ] **Step 1: Bump version 2.5.0 → 2.6.0 in all 4 files** - -- [ ] **Step 2: Commit** - -```bash -git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor -git commit -m "chore(release): bump version to 2.6.0" -``` - ---- - -## Task 11: Build and Test Verification - -- [ ] **Step 1: Full build** - -Run: `dotnet build` -Expected: Success. - -- [ ] **Step 2: Run all tests** - -Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal` -Expected: All pass. - -- [ ] **Step 3: Format check** - -Run: `dotnet format --verify-no-changes --verbosity diagnostic` -Expected: Clean. - -- [ ] **Step 4: Commit fixes if any** - -```bash -git add -A -git commit -m "style: apply dotnet format" -``` - ---- - -## Self-Review - -**1. Spec coverage:** -- Discord UI for 2-3 time options + deadline: covered by `/reschedule` slash command with option1/option2/option3/deadline params ✓ -- Persisted votes via existing model: covered by `DiscordRescheduleVoteHandler` using `reschedule_option_votes` ✓ -- Winner selection and session update: covered by `RescheduleVotingFinalizer` (shared) + `DiscordRescheduleVotingDeadlineService` ✓ -- Update Discord schedule message after voting: covered by `TryUpdateBatchScheduleAsync` in deadline service ✓ -- Telegram flow does not regress: covered by keeping all Telegram handlers intact, adding `source_platform = 'Telegram'` ✓ - -**2. Placeholder scan:** -- No TBD/TODO/fill-in-details found. All code shown explicitly. ✓ - -**3. Type consistency:** -- `RescheduleOptionDto`, `RescheduleVoteDecision`, etc. used consistently across all tasks. ✓ - ---- - -## Execution Handoff - -**Plan complete and saved to `docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md`.** - -**Two execution options:** - -**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. - -**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. - -**Which approach?** diff --git a/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md b/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md deleted file mode 100644 index 46306b7..0000000 --- a/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md +++ /dev/null @@ -1,1144 +0,0 @@ -# Platform Messenger Scheduler Notifications Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Resolve Gitea issue #31 by moving scheduler notifications and reschedule deadline updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord notification support. - -**Architecture:** Move scheduler orchestration and scheduler notification handlers into platform-neutral `GmRelay.Shared` code. Add semantic notification DTOs and messenger methods in `GmRelay.Shared.Platform`; Telegram and Discord implementations render/send platform-specific messages. Register the shared scheduler in both workers with a platform filter so Telegram and Discord do not process each other's sessions. - -**Tech Stack:** .NET 10, C# preview, Npgsql, Dapper.AOT, xUnit, Telegram.Bot only in `GmRelay.Bot`, NetCord only in `GmRelay.DiscordBot`, platform-neutral scheduler and contracts in `GmRelay.Shared`. - ---- - -## Issue Context - -- Issue: #31, `refactor: перевести scheduler и уведомления на IPlatformMessenger` -- Approved design: `docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md` -- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor` -- User clarification: Discord support must be full, not no-op MVP. Discord DM failures are normal; log and continue without a public fallback message. - -## Version Bump - -Current version: `2.6.0`. - -The issue label is `type:refactor`, but the approved scope adds user-visible Discord scheduler notifications. Bump minor: `2.6.0` -> `2.7.0`. - -Synchronize: - -- `Directory.Build.props` -- `compose.yaml` image tags for `bot`, `discord`, and `web` -- `.gitea/workflows/deploy.yml` `VERSION` -- `src/GmRelay.Web/Components/Layout/NavMenu.razor` -- tests that assert synchronized release versions - -## File Structure - -- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs` -- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs` -- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs` -- Create: `src/GmRelay.Shared/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs` -- Create: `src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs` -- Create: `src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs` -- Create: `src/GmRelay.Shared/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs` -- Create: `src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs` -- Create: `src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs` -- Create: `src/GmRelay.Shared/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs` -- Create: `src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs` -- Create: `src/GmRelay.Shared/Features/Notifications/PlatformDirectNotificationSender.cs` -- Modify: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` -- Modify: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` -- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` -- Modify: `src/GmRelay.Shared/packages.lock.json` -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` -- Modify: `src/GmRelay.Bot/Program.cs` -- Delete or leave as forwarding stubs: old scheduler/notification files under `src/GmRelay.Bot/Infrastructure/Scheduling`, `src/GmRelay.Bot/Features/Confirmation`, and `src/GmRelay.Bot/Features/Reminders` -- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` -- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` -- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` -- Modify: `src/GmRelay.DiscordBot/Program.cs` -- Modify: targeted tests under `tests/GmRelay.Bot.Tests/Platform`, `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling`, `tests/GmRelay.Bot.Tests/Infrastructure/Telegram`, `tests/GmRelay.Bot.Tests/Discord`, and `tests/GmRelay.Bot.Tests/Features/Confirmation` - ---- - -## Task 1: RED - Shared Notification Contract Tests - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs` - -- [ ] **Step 1: Add failing tests for semantic notification contracts** - -Append tests like this: - -```csharp -[Fact] -public void PlatformNotificationContracts_ShouldBeSdkAssemblyFree() -{ - var contractTypes = new[] - { - typeof(PlatformSessionParticipant), - typeof(PlatformConfirmationRequest), - typeof(PlatformJoinLinkNotification), - typeof(PlatformDirectSessionNotification), - typeof(PlatformRsvpMessageUpdate), - typeof(PlatformRsvpOutcomeNotification), - typeof(PlatformRescheduleVoteUpdate) - }; - - Assert.All(contractTypes, type => - { - var refs = string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)); - Assert.DoesNotContain("Telegram", refs, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("NetCord", refs, StringComparison.OrdinalIgnoreCase); - }); -} - -[Fact] -public void PlatformMessenger_ShouldExposeSchedulerNotificationOperations() -{ - var methods = typeof(IPlatformMessenger) - .GetMethods() - .Select(method => method.Name) - .ToHashSet(StringComparer.Ordinal); - - Assert.Contains("SendConfirmationRequestAsync", methods); - Assert.Contains("UpdateConfirmationRequestAsync", methods); - Assert.Contains("SendJoinLinkNotificationAsync", methods); - Assert.Contains("SendDirectSessionNotificationAsync", methods); - Assert.Contains("SendRsvpOutcomeAsync", methods); - Assert.Contains("UpdateRescheduleVoteAsync", methods); -} -``` - -- [ ] **Step 2: Run RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests -``` - -Expected: compile failure because the new platform notification types and messenger methods do not exist. - -## Task 2: GREEN - Add Platform Notification Contracts - -**Files:** -- Modify: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` -- Modify: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` - -- [ ] **Step 1: Add notification DTOs** - -Add these records to `PlatformMessageContracts.cs`: - -```csharp -using GmRelay.Shared.Features.Sessions.RescheduleSession; - -public sealed record PlatformSessionParticipant( - PlatformUser User, - string RsvpStatus, - string RegistrationStatus, - bool IsGm = false); - -public sealed record PlatformConfirmationRequest( - PlatformGroup Group, - Guid SessionId, - string Title, - DateTime ScheduledAt, - IReadOnlyList Participants, - PlatformMessageRef? ExistingMessage = null); - -public sealed record PlatformJoinLinkNotification( - PlatformGroup Group, - Guid SessionId, - string Title, - DateTime ScheduledAt, - string JoinLink, - IReadOnlyList ConfirmedPlayers, - PlatformMessageRef? ExistingMessage = null); - -public enum PlatformDirectSessionNotificationKind -{ - ConfirmationRequest = 0, - OneHourReminder = 1, - JoinLink = 2, - RsvpAllConfirmed = 3, - RsvpDeclined = 4, - RescheduleApproved = 5, - RescheduleRejected = 6 -} - -public sealed record PlatformDirectSessionNotification( - PlatformDirectSessionNotificationKind Kind, - PlatformUser Recipient, - Guid SessionId, - string Title, - DateTime ScheduledAt, - string? JoinLink = null, - string? ActorDisplayName = null, - string? Reason = null); - -public sealed record PlatformRsvpMessageUpdate( - PlatformConfirmationRequest Request, - bool DisableActions); - -public enum PlatformRsvpOutcomeKind -{ - GroupAllConfirmed = 0, - GmAllConfirmed = 1, - GmPlayerDeclined = 2 -} - -public sealed record PlatformRsvpOutcomeNotification( - PlatformRsvpOutcomeKind Kind, - PlatformGroup? Group, - IReadOnlyList Recipients, - Guid SessionId, - string Title, - DateTime ScheduledAt, - string? ActorDisplayName = null); - -public sealed record PlatformRescheduleVoteUpdate( - PlatformGroup Group, - PlatformMessageRef ExistingMessage, - Guid ProposalId, - Guid SessionId, - string Title, - DateTime CurrentScheduledAt, - DateTimeOffset VotingDeadlineAt, - RescheduleVoteDecision Decision, - RescheduleOptionDto? SelectedOption, - IReadOnlyList Options, - IReadOnlyList Votes, - IReadOnlyList Participants); -``` - -- [ ] **Step 2: Extend `IPlatformMessenger`** - -Add: - -```csharp -Task SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct); - -Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct); - -Task SendJoinLinkNotificationAsync(PlatformJoinLinkNotification notification, CancellationToken ct); - -Task SendDirectSessionNotificationAsync(PlatformDirectSessionNotification notification, CancellationToken ct); - -Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct); - -Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct); -``` - -- [ ] **Step 3: Run GREEN** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests -``` - -Expected: contract tests pass once implementations compile. Initial compile errors in messenger implementations are expected until Tasks 5 and 6 add method bodies. - -## Task 3: RED - Shared Scheduler And Platform Filter Tests - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs` -- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionTriggerStoreSourceTests.cs` - -- [ ] **Step 1: Update scheduler tests to target shared namespace** - -Change usings from bot scheduling/handler namespaces to: - -```csharp -using GmRelay.Shared.Features.Confirmation.SendConfirmation; -using GmRelay.Shared.Features.Reminders.SendJoinLink; -using GmRelay.Shared.Features.Reminders.SendOneHourReminder; -using GmRelay.Shared.Infrastructure.Scheduling; -``` - -- [ ] **Step 2: Add a source test proving trigger queries filter by platform** - -Create: - -```csharp -namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; - -public sealed class SessionTriggerStoreSourceTests -{ - [Fact] - public async Task DbSessionTriggerStore_ShouldFilterTriggersByConfiguredPlatform() - { - var source = await ReadRepositoryFileAsync( - "src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs"); - - Assert.Contains("PlatformSchedulerOptions", source, StringComparison.Ordinal); - Assert.Contains("JOIN game_groups g ON g.id = s.group_id", source, StringComparison.Ordinal); - Assert.Contains("g.platform = @Platform", source, StringComparison.Ordinal); - } - - private static async Task ReadRepositoryFileAsync(string relativePath) - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - return await File.ReadAllTextAsync(candidate); - - directory = directory.Parent; - } - - throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); - } -} -``` - -- [ ] **Step 3: Run RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "SessionSchedulerServiceTests|SessionTriggerStoreSourceTests" -``` - -Expected: compile/source-test failure because scheduler and trigger store are still under `GmRelay.Bot` and trigger queries do not filter by platform. - -## Task 4: GREEN - Move Scheduler Infrastructure To Shared - -**Files:** -- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs` -- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs` -- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs` -- Delete or convert forwarding stubs: `src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs` -- Delete or convert forwarding stubs: `src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs` -- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` -- Modify: `src/GmRelay.Shared/packages.lock.json` - -- [ ] **Step 1: Add hosting abstractions to Shared** - -Add to `src/GmRelay.Shared/GmRelay.Shared.csproj`: - -```xml - -``` - -Run: - -```powershell -dotnet restore src/GmRelay.Shared/GmRelay.Shared.csproj -``` - -Expected: `src/GmRelay.Shared/packages.lock.json` is updated. - -- [ ] **Step 2: Add platform scheduler options** - -```csharp -namespace GmRelay.Shared.Infrastructure.Scheduling; - -using GmRelay.Shared.Platform; - -public sealed record PlatformSchedulerOptions(PlatformKind Platform); -``` - -- [ ] **Step 3: Move `ISessionTriggerStore` and `DbSessionTriggerStore`** - -Move the existing code into `src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs`, update namespace, inject `PlatformSchedulerOptions`, and update each query to join groups: - -```sql -FROM sessions s -JOIN game_groups g ON g.id = s.group_id -WHERE g.platform = @Platform -``` - -Pass: - -```csharp -new -{ - Platform = options.Platform.ToString(), - Planned = SessionStatus.Planned, - LeadTime = ConfirmationLeadTime, - Now = now.UtcDateTime -} -``` - -Apply the same platform filter to confirmation, one-hour reminder, and join-link queries. - -- [ ] **Step 4: Move `SessionSchedulerService`** - -Move the existing class into `src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs`, update namespace/usings, and keep the public `TickAsync` method unchanged. - -- [ ] **Step 5: Run GREEN** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "SessionSchedulerServiceTests|SessionTriggerStoreSourceTests" -``` - -Expected: tests pass. - -## Task 5: RED - Shared Notification Handler Boundary Tests - -**Files:** -- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SchedulerNotificationSourceTests.cs` - -- [ ] **Step 1: Add source tests that fail until handlers move to Shared and stop using SDK clients** - -```csharp -namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; - -public sealed class SchedulerNotificationSourceTests -{ - [Theory] - [InlineData("src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs")] - [InlineData("src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs")] - [InlineData("src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs")] - [InlineData("src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs")] - public async Task SchedulerNotificationHandlers_ShouldUsePlatformMessengerWithoutSdkClients(string relativePath) - { - var source = await ReadRepositoryFileAsync(relativePath); - - Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); - Assert.DoesNotContain("Telegram.Bot", source, StringComparison.Ordinal); - Assert.DoesNotContain("ITelegramBotClient", source, StringComparison.Ordinal); - Assert.DoesNotContain("NetCord", source, StringComparison.Ordinal); - Assert.DoesNotContain("RestClient", source, StringComparison.Ordinal); - } - - [Fact] - public async Task DiscordProgram_ShouldRegisterSharedSchedulerForDiscordPlatform() - { - var program = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Program.cs"); - - Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program, StringComparison.Ordinal); - Assert.Contains("AddHostedService", program, StringComparison.Ordinal); - Assert.Contains("DbSessionTriggerStore", program, StringComparison.Ordinal); - Assert.Contains("SendConfirmationHandler", program, StringComparison.Ordinal); - Assert.Contains("SendJoinLinkHandler", program, StringComparison.Ordinal); - Assert.Contains("SendOneHourReminderHandler", program, StringComparison.Ordinal); - } - - private static async Task ReadRepositoryFileAsync(string relativePath) - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - return await File.ReadAllTextAsync(candidate); - - directory = directory.Parent; - } - - throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); - } -} -``` - -- [ ] **Step 2: Run RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter SchedulerNotificationSourceTests -``` - -Expected: failures because files are not in Shared and Discord Program does not register scheduler notification handlers. - -## Task 6: GREEN - Move And Refactor Scheduler Notification Handlers - -**Files:** -- Create shared handler files listed in File Structure -- Modify: `src/GmRelay.Bot/Program.cs` -- Modify: `src/GmRelay.DiscordBot/Program.cs` -- Delete or convert old Bot handler files to prevent duplicate type names - -- [ ] **Step 1: Move interfaces and handlers to Shared namespaces** - -Use these namespaces: - -```csharp -namespace GmRelay.Shared.Features.Confirmation.SendConfirmation; -namespace GmRelay.Shared.Features.Confirmation.HandleRsvp; -namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder; -namespace GmRelay.Shared.Features.Reminders.SendJoinLink; -namespace GmRelay.Shared.Features.Notifications; -``` - -- [ ] **Step 2: Replace Telegram DTOs with platform-neutral rows** - -For session/group query DTOs use fields like: - -```csharp -internal sealed record SchedulerSessionRow( - Guid Id, - string Title, - DateTime ScheduledAt, - Guid GroupId, - string Platform, - string ExternalGroupId, - string? ExternalChannelId, - int? ThreadId, - string NotificationMode, - string? ExistingMessageId, - long? LegacyTelegramChatId); -``` - -Create `PlatformGroup` from row: - -```csharp -private static PlatformGroup CreateGroup(SchedulerSessionRow row) => - new( - Enum.Parse(row.Platform), - row.ExternalGroupId, - row.ExternalGroupId, - row.ExternalChannelId, - row.ThreadId?.ToString(CultureInfo.InvariantCulture)); -``` - -For Telegram back-compat, SQL should select: - -```sql -COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId, -COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId -``` - -- [ ] **Step 3: Build `PlatformSessionParticipant` from players** - -Use platform identity first and Telegram fallback second: - -```sql -COALESCE(p.platform, 'Telegram') AS Platform, -COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, -COALESCE(p.external_username, p.telegram_username) AS ExternalUsername -``` - -Map rows to: - -```csharp -new PlatformSessionParticipant( - new PlatformUser( - Enum.Parse(row.Platform), - row.ExternalUserId, - row.DisplayName, - row.ExternalUsername), - row.RsvpStatus, - row.RegistrationStatus, - row.IsGm); -``` - -- [ ] **Step 4: Refactor `SendConfirmationHandler`** - -Replace direct `ITelegramBotClient.SendMessage` with: - -```csharp -var message = await messenger.SendConfirmationRequestAsync( - new PlatformConfirmationRequest( - group, - session.Id, - session.Title, - session.ScheduledAt, - participants), - ct); -``` - -Persist Telegram legacy `confirmation_message_id` only when `message.Platform == PlatformKind.Telegram`: - -```csharp -MessageId = message.Platform == PlatformKind.Telegram - ? int.Parse(message.ExternalMessageId, CultureInfo.InvariantCulture) - : null -``` - -For Discord, insert/update `platform_messages` with purpose `confirmation` and `external_message_id`. - -- [ ] **Step 5: Refactor `SendOneHourReminderHandler`** - -Replace direct sender with: - -```csharp -await directSender.SendAsync( - PlatformDirectSessionNotificationKind.OneHourReminder, - recipients, - session.Id, - session.Title, - session.ScheduledAt, - session.JoinLink, - actorDisplayName: null, - reason: null, - ct); -``` - -- [ ] **Step 6: Refactor `SendJoinLinkHandler`** - -Replace group send with: - -```csharp -var message = await messenger.SendJoinLinkNotificationAsync( - new PlatformJoinLinkNotification( - group, - session.Id, - session.Title, - session.ScheduledAt, - session.JoinLink, - confirmedPlayers), - ct); -``` - -Persist `link_message_id` for Telegram and `platform_messages` purpose `join_link` for Discord. - -- [ ] **Step 7: Refactor `HandleRsvpHandler`** - -Change command to: - -```csharp -public sealed record HandleRsvpCommand( - Guid SessionId, - PlatformUser User, - string Status, - string InteractionId, - PlatformGroup Group, - PlatformMessageRef ConfirmationMessage); -``` - -Query participants by platform identity: - -```sql -AND p.platform = @Platform -AND p.external_user_id = @ExternalUserId -``` - -Replace callback answers and messages with: - -```csharp -await messenger.AnswerInteractionAsync(new PlatformInteractionReply(command.InteractionId, text), ct); -await messenger.UpdateConfirmationRequestAsync(new PlatformRsvpMessageUpdate(request, disableActions), ct); -await messenger.SendRsvpOutcomeAsync(outcome, ct); -``` - -- [ ] **Step 8: Register shared services in both workers** - -Telegram Program: - -```csharp -builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram)); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); -``` - -Discord Program uses the same registrations with `PlatformKind.Discord`. - -- [ ] **Step 9: Run GREEN** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "SchedulerNotificationSourceTests|SessionSchedulerServiceTests|SessionTriggerStoreSourceTests|RsvpFlowRulesTests" -``` - -Expected: tests pass. - -## Task 7: RED - Telegram Messenger Regression Tests - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` -- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs` - -- [ ] **Step 1: Add source assertions for new Telegram notification methods** - -Assert `TelegramPlatformMessenger.cs` contains: - -```csharp -Assert.Contains("SendConfirmationRequestAsync", source, StringComparison.Ordinal); -Assert.Contains("UpdateConfirmationRequestAsync", source, StringComparison.Ordinal); -Assert.Contains("SendJoinLinkNotificationAsync", source, StringComparison.Ordinal); -Assert.Contains("SendDirectSessionNotificationAsync", source, StringComparison.Ordinal); -Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); -Assert.Contains("messageThreadId", source, StringComparison.Ordinal); -Assert.Contains("ParseMode.Html", source, StringComparison.Ordinal); -Assert.Contains("InlineKeyboardButton.WithCallbackData", source, StringComparison.Ordinal); -``` - -- [ ] **Step 2: Update topic smoke tests** - -Expected strings should move from handlers to shared DTO construction and messenger usage. Keep assertions for: - -```csharp -Assert.Contains("ExternalThreadId", confirmationHandler, StringComparison.Ordinal); -Assert.Contains("ThreadId", joinLinkHandler, StringComparison.Ordinal); -Assert.Contains("UpdateConfirmationRequestAsync", rsvpHandler, StringComparison.Ordinal); -Assert.Contains("messageThreadId", telegramMessenger, StringComparison.Ordinal); -``` - -- [ ] **Step 3: Run RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|TelegramTopicIntegrationSmokeTests" -``` - -Expected: failures until Telegram messenger implements new semantic methods and old tests are updated to the new boundary. - -## Task 8: GREEN - Implement Telegram Semantic Notification Methods - -**Files:** -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` - -- [ ] **Step 1: Add Telegram rendering helpers inside `TelegramPlatformMessenger`** - -Use private helpers equivalent to existing handler text builders: - -```csharp -private static string FormatTelegramParticipant(PlatformSessionParticipant participant) => - participant.User.ExternalUsername is not null - ? $"@{participant.User.ExternalUsername}" - : participant.User.DisplayName; -``` - -Build confirmation text, join-link text, direct notification HTML, and RSVP outcome text with the same content currently in handlers. - -- [ ] **Step 2: Implement confirmation send/update** - -`SendConfirmationRequestAsync` calls `bot.SendMessage` with `messageThreadId`, `ParseMode.Html`, and RSVP buttons: - -```csharp -InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{request.SessionId}") -InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{request.SessionId}") -``` - -`UpdateConfirmationRequestAsync` calls `bot.EditMessageText` and removes reply markup when `DisableActions` is true. - -- [ ] **Step 3: Implement join-link and direct methods** - -`SendJoinLinkNotificationAsync` uses `bot.SendMessage` to the group/thread and returns `TelegramPlatformIds.Message`. - -`SendDirectSessionNotificationAsync` uses `bot.SendMessage` to `Recipient.ExternalUserId` with `ParseMode.Html`. - -`SendRsvpOutcomeAsync` sends group or direct messages based on `PlatformRsvpOutcomeNotification.Kind`. - -- [ ] **Step 4: Map Telegram callback queries to platform-neutral RSVP commands** - -In `UpdateRouter`, build: - -```csharp -new HandleRsvpCommand( - parsedSessionId, - TelegramPlatformIds.User(callback.From.Id, callback.From.FirstName, callback.From.Username), - status, - callback.Id, - TelegramPlatformIds.Group(callback.Message.Chat.Id, callback.Message.MessageThreadId), - TelegramPlatformIds.Message(callback.Message.Chat.Id, callback.Message.MessageThreadId, callback.Message.MessageId)) -``` - -- [ ] **Step 5: Run GREEN** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|TelegramTopicIntegrationSmokeTests" -``` - -Expected: pass. - -## Task 9: RED - Discord Messenger And Scheduler Wiring Tests - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs` -- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs` -- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - -- [ ] **Step 1: Add source assertions for Discord messenger notification support** - -```csharp -Assert.Contains("SendConfirmationRequestAsync", source, StringComparison.Ordinal); -Assert.Contains("UpdateConfirmationRequestAsync", source, StringComparison.Ordinal); -Assert.Contains("SendJoinLinkNotificationAsync", source, StringComparison.Ordinal); -Assert.Contains("SendDirectSessionNotificationAsync", source, StringComparison.Ordinal); -Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); -Assert.Contains("DiscordSessionBatchRenderer", source, StringComparison.Ordinal); -Assert.Contains("DiscordRescheduleVotingRenderer", source, StringComparison.Ordinal); -Assert.Contains("GetDMChannelAsync", source, StringComparison.Ordinal); -``` - -- [ ] **Step 2: Add RSVP route assertions** - -In `DiscordSessionInteractionModuleSourceTests`, assert: - -```csharp -Assert.Contains("[ComponentInteraction(\"rsvp\")", source, StringComparison.Ordinal); -Assert.Contains("HandleRsvpHandler", source, StringComparison.Ordinal); -Assert.Contains("PlatformKind.Discord", source, StringComparison.Ordinal); -``` - -- [ ] **Step 3: Add startup assertions** - -In `DiscordStartupTests`, assert: - -```csharp -Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program); -Assert.Contains("AddHostedService", program); -Assert.Contains("HandleRsvpHandler", program); -``` - -- [ ] **Step 4: Run RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "DiscordPlatformMessengerTests|DiscordSessionInteractionModuleSourceTests|DiscordStartupTests" -``` - -Expected: failures until Discord messenger and Program are updated. - -## Task 10: GREEN - Implement Discord Semantic Notification Methods And RSVP Route - -**Files:** -- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` -- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` -- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionMapper.cs` -- Modify: `src/GmRelay.DiscordBot/Program.cs` - -- [ ] **Step 1: Implement channel confirmation messages** - -Use `RestClient.SendMessageAsync(channelId, new MessageProperties().WithEmbeds(...).WithComponents(...))`. - -Buttons use NetCord custom ids that route to one component handler: - -```csharp -new ButtonProperties($"rsvp:confirm:{request.SessionId}", "Буду", ButtonStyle.Success) -new ButtonProperties($"rsvp:decline:{request.SessionId}", "Не смогу", ButtonStyle.Danger) -``` - -Return: - -```csharp -new PlatformMessageRef(PlatformKind.Discord, request.Group.ExternalGroupId, null, sentMessage.Id.ToString(CultureInfo.InvariantCulture)); -``` - -- [ ] **Step 2: Implement confirmation updates** - -Render an updated embed listing confirmed, declined, and pending participants. If `DisableActions` is true, set no components or disabled buttons. - -Use: - -```csharp -await restClient.ModifyMessageAsync(channelId, messageId, options => -{ - options.Embeds = new[] { embed }; - options.Components = disabledOrActiveRows; -}); -``` - -- [ ] **Step 3: Implement join-link notifications** - -Send a channel message containing title, join link, and Discord mentions: - -```csharp -var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(p => $"<@{p.User.ExternalUserId}>")); -``` - -- [ ] **Step 4: Implement direct Discord notifications** - -Use NetCord DM channel creation/opening before sending: - -```csharp -var userId = ulong.Parse(notification.Recipient.ExternalUserId, CultureInfo.InvariantCulture); -var dm = await restClient.GetDMChannelAsync(new DMChannelProperties(userId)); -await restClient.SendMessageAsync(dm.Id, BuildDirectContent(notification)); -``` - -Wrap this method in try/catch at call sites or inside the method so blocked DMs log a warning and do not throw back into scheduler processing. - -- [ ] **Step 5: Implement RSVP route** - -Add component route: - -```csharp -[ComponentInteraction("rsvp")] -public async Task RsvpAsync(string status, string sessionId) -``` - -Map Discord context to `HandleRsvpCommand` with `PlatformKind.Discord`, `Context.User.Id`, guild id, channel id, and message id. Defer ephemerally, invoke `HandleRsvpHandler`, then complete from `DiscordInteractionReplyCache`. - -- [ ] **Step 6: Register shared scheduler services in Discord Program** - -Add: - -```csharp -builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord)); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); -``` - -- [ ] **Step 7: Run GREEN** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "DiscordPlatformMessengerTests|DiscordSessionInteractionModuleSourceTests|DiscordStartupTests|SchedulerNotificationSourceTests" -``` - -Expected: pass. - -## Task 11: RED - Reschedule Deadline Boundary Tests - -**Files:** -- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` -- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleDeadlineBoundaryTests.cs` - -- [ ] **Step 1: Assert deadline services no longer edit messages directly** - -Add source assertions: - -```csharp -Assert.DoesNotContain("ITelegramBotClient", telegramDeadlineService, StringComparison.Ordinal); -Assert.DoesNotContain(".EditMessageText(", telegramDeadlineService, StringComparison.Ordinal); -Assert.Contains("UpdateRescheduleVoteAsync", telegramDeadlineService, StringComparison.Ordinal); -Assert.Contains("IPlatformMessenger", telegramDeadlineService, StringComparison.Ordinal); -``` - -For Discord: - -```csharp -Assert.DoesNotContain("RestClient", source, StringComparison.Ordinal); -Assert.DoesNotContain("ModifyMessageAsync", source, StringComparison.Ordinal); -Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); -Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); -``` - -- [ ] **Step 2: Run RED** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|DiscordRescheduleDeadlineBoundaryTests" -``` - -Expected: failures until deadline services call the platform messenger. - -## Task 12: GREEN - Move Reschedule Deadline Message Updates Behind Messenger - -**Files:** -- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs` -- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` -- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` -- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` - -- [ ] **Step 1: Telegram deadline service** - -Remove `ITelegramBotClient` from constructor. After finalizer returns, load the existing vote message ref and call: - -```csharp -await messenger.UpdateRescheduleVoteAsync(update, ct); -``` - -Keep schedule message update through existing `UpdateScheduleAsync`. Keep direct result notifications through `SendDirectSessionNotificationAsync`. - -- [ ] **Step 2: Discord deadline service** - -Remove `RestClient` from constructor. For vote message updates, load `platform_messages` purpose `reschedule_vote`, build `PlatformRescheduleVoteUpdate`, and call `UpdateRescheduleVoteAsync`. - -For approved schedule changes, keep the existing batch lookup but call: - -```csharp -await messenger.UpdateScheduleAsync(new PlatformScheduleMessage(group, view, existingMessage), ct); -``` - -- [ ] **Step 3: Messenger implementations** - -Telegram `UpdateRescheduleVoteAsync` uses the existing `HandleRescheduleTimeInputHandler.BuildVotingMessage(...)` text and appends the final result text, then calls `bot.EditMessageText`. - -Discord `UpdateRescheduleVoteAsync` uses `DiscordRescheduleVotingRenderer.Render(...)`, appends result text to the embed description, disables buttons, and calls `restClient.ModifyMessageAsync`. - -- [ ] **Step 4: Run GREEN** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|DiscordRescheduleDeadlineBoundaryTests|FullyQualifiedName~Reschedule" -``` - -Expected: pass. - -## Task 13: Version, Docs, And Source Assertion Updates - -**Files:** -- Modify: `Directory.Build.props` -- Modify: `compose.yaml` -- Modify: `.gitea/workflows/deploy.yml` -- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` -- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` -- Review and update if necessary: `README.md` -- Review and update if necessary: `docs/adr/002-platform-neutral-batch-rendering.md` - -- [ ] **Step 1: Bump version to 2.7.0** - -Update all version locations from `2.6.0` to `2.7.0`. - -- [ ] **Step 2: Update version assertions** - -Change `DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease` expected strings to `2.7.0`. - -- [ ] **Step 3: Review documentation** - -Run: - -```powershell -rg -n "scheduler|notification|IPlatformMessenger|Discord|Telegram|2\\.6\\.0|v2\\.6\\.0" README.md docs -``` - -Update stale statements that say scheduler notifications are Telegram-only or that Discord notifications are no-op. - -- [ ] **Step 4: Verify version sync** - -Run: - -```powershell -rg -n "2\\.6\\.0|2\\.7\\.0" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs -``` - -Expected: no `2.6.0` in version-controlled version assertions or deployment files; `2.7.0` appears in every required file. - -## Task 14: Full Verification - -**Files:** no planned code edits unless verification exposes issues. - -- [ ] **Step 1: Restore** - -Run: - -```powershell -dotnet restore -``` - -Expected: exit code 0. - -- [ ] **Step 2: Format** - -Run: - -```powershell -dotnet format --verify-no-changes --verbosity diagnostic -``` - -Expected: exit code 0. If formatting fails, run `dotnet format`, inspect diff, and rerun verify. - -- [ ] **Step 3: Build** - -Run: - -```powershell -dotnet build -``` - -Expected: `Build succeeded` with warnings treated as errors. - -- [ ] **Step 4: Tests** - -Run: - -```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal -``` - -Expected: all tests pass. - -## Task 15: Commit, PR, CI, Review, Merge, Deploy, Release - -**Files:** stage only files changed for issue #31. Do not stage `pr87.diff`. - -- [ ] **Step 1: Inspect diff** - -Run: - -```powershell -git status --short -git diff --stat -``` - -Expected: only issue #31 implementation, tests, docs, version files, and lock files are changed; `pr87.diff` remains untracked and unstaged. - -- [ ] **Step 2: Commit** - -Use a conventional commit: - -```powershell -git add -git commit -m "feat(platform): route scheduler notifications through platform messenger" -``` - -- [ ] **Step 3: Push branch** - -Run: - -```powershell -git push -u origin codex/issue-31-platform-messenger-scheduler -``` - -- [ ] **Step 4: Create Gitea PR** - -Create PR from `codex/issue-31-platform-messenger-scheduler` to `main` with: - -```markdown -## Summary -- Moves scheduler notifications and RSVP handling behind platform-neutral messenger contracts. -- Preserves Telegram notification behavior. -- Adds full Discord scheduler notifications, RSVP buttons, DMs, join-link notifications, and reschedule deadline updates. -- Bumps version to 2.7.0. - -## Test plan -- dotnet restore -- dotnet format --verify-no-changes --verbosity diagnostic -- dotnet build -- dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal - -Closes #31. -``` - -- [ ] **Step 5: CI and review** - -Watch PR checks. If CI fails, read job logs, fix on the same branch, rerun verification, and push. - -- [ ] **Step 6: Merge, deploy, release** - -After CI and review pass, merge the PR, monitor deploy workflow, create release `v2.7.0` with Russian release notes, and close issue #31 with links to PR and release. - ---- - -## Self-Review - -- Spec coverage: scheduler trigger platform filtering, shared scheduler handlers, Telegram preservation, full Discord notifications, RSVP buttons, DM failure behavior, reschedule deadline updates, tests, version bump, and Gitea workflow are covered. -- Placeholder scan: no TBD/TODO/fill-later placeholders remain. -- Type consistency: all planned contracts live under `GmRelay.Shared.Platform`; scheduler infrastructure lives under `GmRelay.Shared.Infrastructure.Scheduling`; Telegram/Discord SDK usage stays in platform implementations. -- Scope check: full scheduler move is included because DiscordBot cannot reference `GmRelay.Bot` without violating the existing no-Telegram-coupling tests. - -## Execution Handoff - -Plan complete and saved to `docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md`. - -Two execution options: - -1. Subagent-Driven (recommended): implement task groups with isolated workers and review between groups. -2. Inline Execution: execute this plan in the current session with TDD checkpoints. - -Because this environment only allows subagents when explicitly requested, use Inline Execution unless the user explicitly asks for subagents. diff --git a/docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md b/docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md deleted file mode 100644 index d9d3ad8..0000000 --- a/docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md +++ /dev/null @@ -1,44 +0,0 @@ -# Telegram Mini App Dashboard Design - -## Goal - -Issue #17 adds a Telegram Mini App dashboard as the mobile entry point for the existing Web Dashboard. Owner and co-GM users must be able to open the dashboard from Telegram, pass server-side Telegram WebApp `initData` validation, and manage only their own groups. - -## Scope - -- Add Mini App authentication using Telegram WebApp `initData`. -- Add a `/miniapp` entry page that signs the user into the existing cookie auth flow, then opens the regular dashboard UI in mobile-first mode. -- Reuse `AuthorizedSessionService`, `SessionService`, and existing Blazor pages for groups, sessions, templates, waitlist promotion, edit forms, and bulk batch operations. -- Add bot entry points: a Mini App button in `/start` and a configurable default menu button when `Telegram:MiniAppUrl` is set. -- Update README, wiki, deployment config, and visible version strings to `1.9.0`. - -## Architecture - -The Mini App is not a second dashboard implementation. It is a Telegram-authenticated entrance into the existing Blazor dashboard. This keeps authorization, domain operations, Telegram message synchronization, and Web Dashboard behavior in one place. - -`TelegramAuthService` gains a second verification method for WebApp `initData`. The server accepts the raw URL-encoded init payload at `/auth/telegram-webapp`, verifies the Telegram HMAC with the bot token, extracts the user id/name from the embedded `user` JSON, and issues the same auth cookie as the login widget endpoint. - -`/miniapp` loads `telegram-web-app.js`, posts `window.Telegram.WebApp.initData` to the server endpoint, expands the WebApp viewport, and redirects to `/`. If a user opens `/miniapp` outside Telegram, the page shows the regular login fallback. - -## Data Flow - -1. User opens the Mini App from the bot menu button or `/start` inline button. -2. Telegram injects `initData` into the WebApp JavaScript API. -3. `/miniapp` posts `{ initData }` to `/auth/telegram-webapp`. -4. The server verifies the WebApp signature and expiry. -5. The server creates the same claims used by Telegram Login Widget. -6. Existing Blazor pages load groups through `AuthorizedSessionService`. -7. Any edit, waitlist, template, or batch action still goes through existing services and keeps Telegram messages synchronized. - -## Error Handling - -- Missing or invalid init data returns `401` and leaves the user on the Mini App page. -- Expired auth data is rejected with the same 24-hour window used by the Login Widget. -- A verified Telegram user with no owner/co-GM groups sees the existing empty dashboard state. -- Direct navigation to a foreign group/session still redirects to `/access-denied` through existing authorization checks. - -## Testing - -- Unit tests cover valid and invalid WebApp `initData`. -- File-level regression tests ensure `/miniapp`, `/auth/telegram-webapp`, Telegram WebApp script loading, bot Mini App button, menu button setup, and mobile Mini App CSS hooks remain present. -- Existing `AuthorizedSessionServiceTests` continue covering owner/co-GM access behavior. diff --git a/docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md b/docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md deleted file mode 100644 index 820d9a7..0000000 --- a/docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md +++ /dev/null @@ -1,140 +0,0 @@ -# Platform Messenger Scheduler Notifications Design - -## Goal - -Issue #31 moves scheduler-driven notifications and reschedule deadline message updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord support instead of no-op placeholders. - -## Scope - -- `SessionSchedulerService` remains the trigger orchestrator, but scheduler handlers stop depending on Telegram API types for outbound notification work. -- Confirmation requests, one-hour reminders, join-link notifications, RSVP follow-up messages, and reschedule deadline updates use platform-neutral contracts. -- Telegram keeps the current user-visible behavior: same message content, RSVP buttons, direct messages, topic/thread targeting, and stored legacy message ids. -- Discord receives full channel and direct notifications: - - confirmation requests are sent to the Discord channel with RSVP buttons; - - Discord RSVP button clicks update participant RSVP state, refresh the confirmation message, and send the same group/GM outcome notifications where applicable; - - one-hour reminders and join-link notifications are sent as Discord DMs when direct notifications are enabled; - - join-link notifications also post the channel message with participant mentions; - - reschedule deadline processing updates Discord vote and schedule messages through the same messenger boundary. -- Discord DM failures are non-fatal: log a warning and continue without posting a public fallback message. - -## Architecture - -The platform boundary should be semantic, not Telegram-shaped. `GmRelay.Shared.Platform` already owns `PlatformKind`, `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `IPlatformMessenger`; issue #31 extends that layer with notification-specific DTOs and messenger methods. - -The scheduler handlers own database queries and notification eligibility. They load platform-neutral groups, users, message refs, and session data, then ask the platform messenger to send or update the platform message. Platform implementations own rendering details: Telegram renders HTML and inline keyboards; Discord renders embeds, components, channel messages, mentions, and DMs. - -RSVP handling should become platform-neutral enough for both Telegram and Discord. The current `HandleRsvpHandler` logic is not duplicated. Its command changes from Telegram ids to `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `InteractionId`. Telegram update routing maps callback queries into that command; Discord component routing maps RSVP button interactions into the same command. - -Reschedule finalization already has shared database logic in `RescheduleVotingFinalizer`. The remaining platform-specific deadline services should stop editing messages through `ITelegramBotClient` or Discord `RestClient` directly. They should load message refs and call `IPlatformMessenger` to update vote messages, schedule messages, and direct result notifications. - -## Platform Contracts - -Add semantic notification records in `GmRelay.Shared.Platform`, with names finalized during implementation planning: - -- `PlatformSessionParticipant`: a `PlatformUser` plus RSVP, registration, and display metadata needed by notification renderers. -- `PlatformSessionNotification`: common session title, time, join link, notification mode, group, optional existing message, and participants. -- `PlatformConfirmationRequest`: confirmation-specific session notification with RSVP actions. -- `PlatformJoinLinkNotification`: join-link group/direct notification data. -- `PlatformOneHourReminder`: one-hour direct reminder data. -- `PlatformRsvpMessageUpdate`: refreshed confirmation message state after a participant responds. -- `PlatformRescheduleVoteUpdate`: finalized reschedule vote message state, including selected option or rejection reason. - -Extend `IPlatformMessenger` with methods for these semantic operations while keeping existing schedule, group, private, interaction, and calendar methods intact for current flows: - -- send and update confirmation request messages; -- send one-hour reminder direct notifications; -- send join-link channel and direct notifications; -- update finalized reschedule vote messages; -- send RSVP outcome messages to the group and GM recipients. - -The exact method names should be chosen in the implementation plan after tests define the desired API, but each method should accept platform-neutral DTOs and return `PlatformMessageRef` when the caller must persist a sent message id. - -## Telegram Behavior - -Telegram implementation lives in `GmRelay.Bot.Infrastructure.Telegram.TelegramPlatformMessenger`. - -It must preserve: - -- `messageThreadId` handling for forum topics; -- HTML parse mode where the existing flow uses HTML; -- current confirmation and RSVP button callback payloads; -- `confirmation_message_id` and `link_message_id` storage in `sessions`; -- direct notification behavior controlled by `SessionNotificationMode`; -- warning-and-continue behavior for failed direct messages; -- existing schedule rendering through `TelegramSessionBatchRenderer` and `BatchMessageEditor`. - -Telegram-specific inbound parsing remains at the Telegram boundary. `UpdateRouter` can still use `Telegram.Bot.Types`, but the command it passes into the RSVP handler should be platform-neutral. - -## Discord Behavior - -Discord implementation lives in `GmRelay.DiscordBot.Infrastructure.Discord.DiscordPlatformMessenger`. - -It must support: - -- channel messages through the configured channel id in `PlatformGroup.ExternalChannelId`; -- interactive RSVP buttons routed by `DiscordSessionInteractionModule`; -- ephemeral interaction replies via the existing `DiscordInteractionReplyCache` pattern; -- DMs through Discord user ids in `PlatformUser.ExternalUserId`; -- non-fatal DM failures with warning logs; -- Discord-friendly rendering, not raw Telegram HTML; -- persistence of Discord schedule and notification message refs in `platform_messages` where later updates need them. - -The current Discord reschedule deadline service directly uses `RestClient` for vote and schedule message edits. This should be folded into `DiscordPlatformMessenger` so deadline services and future platform handlers do not need to know Discord API details. - -## Data Flow - -1. `SessionSchedulerService.TickAsync` asks `ISessionTriggerStore` for due confirmation, one-hour reminder, and join-link session ids. -2. Each handler loads the session, group platform identity, message refs, participants, RSVP state, and notification mode. -3. The handler builds a semantic platform notification DTO and calls `IPlatformMessenger`. -4. The messenger renders and sends/updates platform messages. -5. The handler persists sent message ids where required, using legacy `sessions.confirmation_message_id` and `sessions.link_message_id` for Telegram and `platform_messages` for Discord refs that need later updates. -6. Telegram callback queries and Discord component interactions both call the same platform-neutral RSVP handler. -7. Reschedule deadline services use `RescheduleVotingFinalizer`, then call `IPlatformMessenger` for vote message updates, schedule updates, and direct result notifications. - -## Error Handling - -- A failed trigger query still logs and lets the scheduler continue to the next trigger category. -- A failed send/update for one session logs and does not stop other sessions in the same tick. -- DM failures are warning-level and non-fatal for Telegram and Discord. -- A missing platform message ref logs a warning and skips only the update that needs the ref. -- Unsupported platform values throw at the messenger boundary, not inside scheduler orchestration. -- If Discord cannot parse a stored channel, message, or user id, it logs the bad external id and skips that platform send/update. - -## Testing - -Use TDD for implementation. - -Focused tests should cover: - -- `IPlatformMessenger` exposes semantic notification methods without referencing Telegram or Discord assemblies from `GmRelay.Shared`. -- `SendConfirmationHandler`, `SendOneHourReminderHandler`, `SendJoinLinkHandler`, `HandleRsvpHandler`, and reschedule deadline services do not call `ITelegramBotClient`, `BatchMessageEditor`, or Discord `RestClient` directly for notification output. -- Telegram source/regression tests preserve thread ids, callback payloads, message id persistence, and direct notification mode behavior. -- Discord source tests verify registration of scheduler handlers, RSVP component routes, and messenger methods. -- RSVP flow tests run through platform-neutral `PlatformUser` identity, including Discord users without Telegram ids. -- Discord messenger tests verify DMs are attempted, DM failures are swallowed after logging, channel notifications include buttons or mentions as appropriate, and message refs are returned. -- Full regression: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`, `dotnet build`, and `dotnet format --verify-no-changes --verbosity diagnostic`. - -## Versioning - -Current repository version is `2.6.0`. Although the Gitea issue is labeled `type:refactor`, the approved scope adds full Discord notification behavior. Proposed bump: `2.6.0` to `2.7.0`. - -Synchronize: - -- `Directory.Build.props` -- `compose.yaml` image tags for bot, discord, and web -- `.gitea/workflows/deploy.yml` `VERSION` -- `src/GmRelay.Web/Components/Layout/NavMenu.razor` - -## Out Of Scope - -- Moving the entire scheduler hosted service into `GmRelay.Shared`. -- Removing legacy Telegram columns such as `telegram_chat_id`, `confirmation_message_id`, or `link_message_id`. -- Reworking Web dashboard Telegram behavior. -- Public fallback messages when a Discord DM is blocked. - -## Self-Review - -- Spec coverage: every issue acceptance criterion is represented by scheduler handler boundaries, messenger contracts, Telegram behavior preservation, and Discord implementation requirements. -- Placeholder scan: no TBD/TODO/fill-in-later sections remain. -- Internal consistency: the design uses semantic platform DTOs consistently and keeps SDK-specific rendering in platform implementations. -- Scope check: the work is large but still one coherent platform-notification refactor; moving the whole scheduler to shared remains explicitly out of scope. diff --git a/docs/superpowers/specs/2026-05-21-documentation-sync-mvp2-design.md b/docs/superpowers/specs/2026-05-21-documentation-sync-mvp2-design.md deleted file mode 100644 index 99e1ce9..0000000 --- a/docs/superpowers/specs/2026-05-21-documentation-sync-mvp2-design.md +++ /dev/null @@ -1,166 +0,0 @@ -# Дизайн: Синхронизация документации после MVP2 (Discord + кросс-платформенность) - -**Дата:** 2026-05-21 -**Версия проекта:** v2.7.2 -**Статус:** Approved - ---- - -## 1. Цель - -Привести всю проектную документацию в актуальное состояние после завершения MVP2: -- Discord-интеграция (slash-команды, кнопки, RSVP, reschedule voting, DM-уведомления). -- Кросс-платформенная архитектура (`IPlatformMessenger`, `SessionBatchViewBuilder`, platform-specific renderers). -- Новые env-переменные (`DISCORD_BOT_TOKEN`), healthcheck на 8082, Docker Compose сервис `discord`. -- Регрессионные тесты, обновлённый CI/CD. - ---- - -## 2. Аудитории и каналы - -| Аудитория | Канал | Фокус | -|---|---|---| -| ГМы и игроки | Gitea Wiki | Как пользоваться ботом: команды, кнопки, уведомления, FAQ | -| Разработчики и хостеры | `README.md` + `docs/` | Архитектура, сборка, деплой, env-переменные, ADR | - -**Принцип:** Wiki — только пользовательская документация. Технические детали (архитектура, БД, разработка) удаляются из Wiki и консолидируются в репозитории. - ---- - -## 3. Wiki (пользовательская документация) - -### Новая структура страниц - -1. **Home** - - Общее описание GM-Relay (Telegram + Discord). - - Текущая версия v2.7.2. - - Ссылки: Быстрый старт, Руководство ГМа, Руководство игрока. - - Убираем: технический стек, ссылки на Архитектуру/БД/Разработка. - -2. **Быстрый старт** - - Шаг 1: Добавление Telegram-бота в группу. - - Шаг 2: Приглашение Discord-приложения на сервер (scopes: bot, applications.commands). - - Шаг 3: Создание первой группы (`/newgroup` в Telegram или через Web). - - Шаг 4: Создание первого batch (`/newsession`). - - Шаг 5: Публикация расписания (`/listsessions`). - -3. **Руководство ГМа** - - Telegram-команды: `/newgroup`, `/newsession`, `/listsessions`, `/exportcalendar`. - - Discord slash-команды: `/newsession`, `/listsessions`. - - Создание и управление batch: картинки, повторы, bulk-операции (Web). - - Co-GM и делегирование. - - Переносы (reschedule): как инициировать голосование, как работает дедлайн. - - Шаблоны кампаний. - - Статистика посещаемости (Web). - - Управление очередью (waitlist, promote). - -4. **Руководство игрока** - - Telegram: запись через inline-кнопки, отмена. - - Discord: кнопки Join/Leave в schedule message, RSVP (Confirm/Decline). - - Уведомления: за 24ч, за 1ч, ссылка перед игрой, DM vs группа. - - Лист ожидания: как попасть, как автоматически продвинуться. - -5. **FAQ / Устранение неполадок** - - Бот не отвечает: проверить права, перезапустить. - - Кнопки не работают: проверить права Manage Messages / Embed Links. - - Mini App не открывается: HTTPS, domain в BotFather. - - Discord DM не приходят: privacy settings, бот не может писать first. - - Reschedule голосование не завершилось: дедлайн, минимум голосов. - -### Удаляемые Wiki-страницы (контент переходит в README/docs) - -- `Архитектура` → `docs/c4-system-context.md` + `docs/adr/` -- `База данных` → `docs/adr/` (описание схемы) -- `Разработка` → `README.md` (раздел для контрибьюторов) -- `Развёртывание` → `README.md` (Docker Compose quick start) - ---- - -## 4. README.md (разработчики и хостеры) - -### Что обновить - -- **Версия:** с `v2.7.0` → `v2.7.2`. -- **Key Features — Discord:** - - Slash-команды `/newsession`, `/listsessions`. - - Кнопки Join/Leave/RSVP с ephemeral-ответами. - - DM-напоминания и ссылки (с fallback-логированием). - - Reschedule voting с дедлайном. - - Waitlist и auto-promote. -- **Технологический стек:** - - Добавить NetCord Gateway для Discord. - - Уточнить: `GmRelay.DiscordBot` — это NetCord Gateway worker (не отдельный проект в solution, а runtime-роль внутри Bot/Web). - - Добавить `IPlatformMessenger` в архитектурное описание. -- **Структура репозитория:** - - Убрать `GmRelay.DiscordBot` как отдельный проект (согласно CLAUDE.md, его нет; Discord-логика внутри `GmRelay.Bot`). - - Добавить `GmRelay.ServiceDefaults`. -- **Переменные окружения:** - - Добавить `DISCORD_BOT_TOKEN`. - - Добавить `DISCORD_BOT_CLIENT_ID` (для регистрации slash-команд). -- **Docker Compose:** - - Добавить сервис `discord` с healthcheck на `:8082`. - - Уточнить multi-arch (AMD64/ARM64 для Raspberry Pi). -- **Quick Start:** - - Добавить шаг приглашения Discord-бота. - - Добавить настройку домена для Mini App. - -### Новый раздел (опционально) - -- **Для разработчиков:** - - Краткое описание Vertical Slice + Native AOT. - - Ссылка на `docs/adr/0001-...` и `docs/adr/002-...`. - - Как добавить handler и зарегистрировать в Program.cs. - - Как написать миграцию (DbUp). - ---- - -## 5. `docs/` (архитектурная и техническая документация) - -### `docs/c4-system-context.md` - -- **Level 1 (System Context):** Добавить Discord Gateway and REST API как external system. Добавить игрокам Discord-взаимодействие. -- **Level 2 (Container):** Уточнить, что `GmRelay.Bot` содержит **оба** runtime-роли: Telegram long polling **и** Discord Gateway worker (или уточнить, что Discord worker — отдельный контейнер внутри той же сборки). Проверить текущую C4-диаграмму — она уже содержит `discordBot`, так что нужно только убедиться, что он соответствует `GmRelay.Bot` (а не `GmRelay.DiscordBot`). -- **Level 3 (Component):** Уже содержит Discord-компоненты. Проверить актуальность: `DiscordSessionInteractionModule`, `DiscordPlatformMessenger`. Добавить `RescheduleVotingFinalizer` (shared). Добавить `DiscordHealthCheckHostedService`. - -### `docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md` - -- Добавить Discord-аспект: NetCord Gateway worker, slash-команды. -- Уточнить, что Aspire оркестрирует **три** сервиса: Bot (Telegram + Discord), Web, PostgreSQL. - -### `docs/adr/002-platform-neutral-batch-rendering.md` - -- Уже содержит Discord renderer. Дополнить: - - Issue #30 (reschedule voting) использует `IPlatformMessenger`. - - Issue #31 (scheduler notifications) тоже использует `IPlatformMessenger`. - - Issue #32 (compose wiring) добавил Discord healthcheck. - - Issue #33 (регрессионные тесты) покрывает оба renderer'а. - -### Новый ADR (опционально, если есть время) - -- **ADR-003: Discord Integration Architecture** — почему NetCord (а не DSharpPlus), как Gateway events маршрутизируются в vertical slice handlers, как ephemeral-ответы работают. -- Это необязательно, но полезно для будущих разработчиков. - ---- - -## 6. Порядок выполнения - -1. **Wiki Home** — обновить описание, версию, ссылки. -2. **Wiki Быстрый старт** — переписать с учётом Discord. -3. **Wiki Руководство ГМа** — добавить Discord-команды, reschedule voting, статистику. -4. **Wiki Руководство игрока** — новая страница (или раздел в Руководстве ГМа). -5. **Wiki FAQ** — новая страница. -6. **README.md** — версия, features, env, Docker, quick start. -7. **`docs/c4-system-context.md`** — Discord-компоненты, healthcheck. -8. **`docs/adr/0001-...`** — Discord-аспекты. -9. **Удалить устаревшие Wiki-страницы** (Архитектура, База данных, Разработка, Развёртывание) или заменить их редиректами на README. - ---- - -## 7. Критерии готовности - -- [ ] Все wiki-страницы отражают текущую версию v2.7.2. -- [ ] Все Discord-фичи задокументированы для пользователей. -- [ ] README содержит актуальную версию, env-переменные, структуру репозитория. -- [ ] C4-диаграмма и ADR'ы отражают Discord-архитектуру и `IPlatformMessenger`. -- [ ] Нет противоречий между Wiki и README (например, версия, команды). -- [ ] Устаревшие wiki-страницы удалены или содержат редирект.