From 5dbec1a0a4ce537415df49fdc7044744113d76a8 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 14:53:41 +0300 Subject: [PATCH] docs: add issue 31 implementation plan --- ...tform-messenger-scheduler-notifications.md | 1144 +++++++++++++++++ 1 file changed, 1144 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md 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 new file mode 100644 index 0000000..46306b7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md @@ -0,0 +1,1144 @@ +# 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.