# 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.