Files
GmRelayBot/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md
T

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

[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 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:

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 PlatformSessionParticipant from 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 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.