40 KiB
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.propscompose.yamlimage tags forbot,discord, andweb.gitea/workflows/deploy.ymlVERSIONsrc/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, andsrc/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, andtests/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:
[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:
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:
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<PlatformSessionParticipant> Participants,
PlatformMessageRef? ExistingMessage = null);
public sealed record PlatformJoinLinkNotification(
PlatformGroup Group,
Guid SessionId,
string Title,
DateTime ScheduledAt,
string JoinLink,
IReadOnlyList<PlatformSessionParticipant> 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<PlatformUser> 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<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
IReadOnlyList<VoteParticipantDto> Participants);
- Step 2: Extend
IPlatformMessenger
Add:
Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct);
Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct);
Task<PlatformMessageRef> 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:
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:
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:
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<string> 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:
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:
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
Run:
dotnet restore src/GmRelay.Shared/GmRelay.Shared.csproj
Expected: src/GmRelay.Shared/packages.lock.json is updated.
- Step 2: Add platform scheduler options
namespace GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
public sealed record PlatformSchedulerOptions(PlatformKind Platform);
- Step 3: Move
ISessionTriggerStoreandDbSessionTriggerStore
Move the existing code into src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs, update namespace, inject PlatformSchedulerOptions, and update each query to join groups:
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE g.platform = @Platform
Pass:
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:
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
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<SessionSchedulerService>", 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<string> 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:
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:
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:
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:
private static PlatformGroup CreateGroup(SchedulerSessionRow row) =>
new(
Enum.Parse<PlatformKind>(row.Platform),
row.ExternalGroupId,
row.ExternalGroupId,
row.ExternalChannelId,
row.ThreadId?.ToString(CultureInfo.InvariantCulture));
For Telegram back-compat, SQL should select:
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
PlatformSessionParticipantfrom players
Use platform identity first and Telegram fallback second:
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:
new PlatformSessionParticipant(
new PlatformUser(
Enum.Parse<PlatformKind>(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername),
row.RsvpStatus,
row.RegistrationStatus,
row.IsGm);
- Step 4: Refactor
SendConfirmationHandler
Replace direct ITelegramBotClient.SendMessage with:
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:
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:
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:
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:
public sealed record HandleRsvpCommand(
Guid SessionId,
PlatformUser User,
string Status,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ConfirmationMessage);
Query participants by platform identity:
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
Replace callback answers and messages with:
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:
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddHostedService<SessionSchedulerService>();
Discord Program uses the same registrations with PlatformKind.Discord.
- Step 9: Run GREEN
Run:
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:
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:
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:
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:
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:
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:
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:
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
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:
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:
Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program);
Assert.Contains("AddHostedService<SessionSchedulerService>", program);
Assert.Contains("HandleRsvpHandler", program);
- Step 4: Run RED
Run:
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:
new ButtonProperties($"rsvp:confirm:{request.SessionId}", "Буду", ButtonStyle.Success)
new ButtonProperties($"rsvp:decline:{request.SessionId}", "Не смогу", ButtonStyle.Danger)
Return:
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:
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:
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:
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:
[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:
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord));
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddHostedService<SessionSchedulerService>();
- Step 7: Run GREEN
Run:
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:
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:
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:
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:
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:
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:
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:
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:
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:
dotnet restore
Expected: exit code 0.
- Step 2: Format
Run:
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:
dotnet build
Expected: Build succeeded with warnings treated as errors.
- Step 4: Tests
Run:
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:
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:
git add <specific issue-31 files>
git commit -m "feat(platform): route scheduler notifications through platform messenger"
- Step 3: Push branch
Run:
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:
## 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 underGmRelay.Shared.Infrastructure.Scheduling; Telegram/Discord SDK usage stays in platform implementations. - Scope check: full scheduler move is included because DiscordBot cannot reference
GmRelay.Botwithout 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:
- Subagent-Driven (recommended): implement task groups with isolated workers and review between groups.
- 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.