Introduce platform-neutral PlatformKind, PlatformUser, PlatformGroup, and IPlatformMessenger contracts in GmRelay.Shared. Route Telegram session schedule updates, direct notifications, interaction replies, and calendar export through TelegramPlatformMessenger while preserving existing Telegram behavior. Bump version -> 2.0.1
20 KiB
Platform Messenger Contracts Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement issue #24 by adding platform-neutral platform identity and messaging contracts, then routing the Telegram session flows through a Telegram adapter without changing Telegram behavior.
Architecture: Keep update routing and Telegram update parsing at the GmRelay.Bot.Infrastructure.Telegram boundary, but move outbound messaging decisions behind GmRelay.Shared.Platform.IPlatformMessenger. GmRelay.Shared owns platform-neutral DTOs and contracts; GmRelay.Bot owns TelegramPlatformMessenger, which translates neutral requests into Telegram.Bot calls and reuses the existing Telegram renderers/editing rules.
Tech Stack: .NET 10, C# preview, xUnit, Dapper.AOT constraints, Telegram.Bot in GmRelay.Bot only, platform-neutral shared contracts in GmRelay.Shared.
Issue Context
- Gitea issue: #24,
refactor: ввести PlatformKind, PlatformUser, PlatformGroup и IPlatformMessenger - Labels:
area:bot,area:platform,area:shared,platform:multi,type:refactor,pending-approval - Acceptance criteria:
- New contracts live in a platform-neutral layer.
- Telegram flow goes through the adapter without behavior changes.
- A future DiscordBot can reference the contract without depending on Telegram assemblies.
Proposed Version Bump
Current version is 2.0.0 in:
Directory.Build.propscompose.yaml.gitea/workflows/deploy.ymlsrc/GmRelay.Web/Components/Layout/NavMenu.razor
Issue label is type:refactor; per workflow rules this is not a major bump and has no user-facing feature label. Proposed bump: 2.0.0 -> 2.0.1.
Files
- Create:
src/GmRelay.Shared/Platform/PlatformKind.cs - Create:
src/GmRelay.Shared/Platform/PlatformUser.cs - Create:
src/GmRelay.Shared/Platform/PlatformGroup.cs - Create:
src/GmRelay.Shared/Platform/PlatformMessageContracts.cs - Create:
src/GmRelay.Shared/Platform/IPlatformMessenger.cs - Create:
src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs - Create:
tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs - Create:
tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs - Modify:
src/GmRelay.Bot/Program.cs - Modify:
src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs - Modify:
src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs - Modify:
src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs - Modify:
src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs - Modify:
src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs - Modify:
src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs - Modify:
src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs - Modify:
src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs - Modify:
src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs - Modify:
src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs - Modify: version files listed above
Design
Shared Contracts
PlatformKind is a sentinel enum where Max is not a sendable platform:
namespace GmRelay.Shared.Platform;
public enum PlatformKind
{
Telegram = 0,
Discord = 1,
Max = 2
}
PlatformUser and PlatformGroup carry external platform identity while keeping current Telegram IDs representable as strings:
namespace GmRelay.Shared.Platform;
public sealed record PlatformUser(
PlatformKind Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
public sealed record PlatformGroup(
PlatformKind Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId = null,
string? ExternalThreadId = null);
Outbound message contracts stay independent of Telegram/Discord SDK types:
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Platform;
public sealed record PlatformMessageRef(
PlatformKind Platform,
string ExternalGroupId,
string? ExternalThreadId,
string ExternalMessageId);
public sealed record PlatformMessageAction(
string Key,
string Label,
string Payload);
public sealed record PlatformScheduleMessage(
PlatformGroup Group,
SessionBatchViewModel View,
PlatformMessageRef? ExistingMessage,
string? ImageReference = null);
public sealed record PlatformPrivateMessage(
PlatformUser Recipient,
string HtmlText);
public sealed record PlatformInteractionReply(
string InteractionId,
string Text,
bool ShowAlert = false);
public sealed record PlatformCalendarFile(
PlatformGroup Group,
string FileName,
byte[] Content,
string CaptionHtml,
IReadOnlyList<PlatformMessageAction> Actions);
IPlatformMessenger exposes the required outward operations:
namespace GmRelay.Shared.Platform;
public interface IPlatformMessenger
{
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct);
Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct);
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
}
Telegram Adapter
TelegramPlatformMessenger lives in GmRelay.Bot.Infrastructure.Telegram, depends on ITelegramBotClient, and translates neutral DTOs to existing Telegram calls:
SendScheduleAsyncrendersSessionBatchViewModelwithTelegramSessionBatchRenderer.Render.UpdateScheduleAsynccallsBatchMessageEditor.EditBatchMessageAsync.SendGroupMessageAsynccallsSendMessagewithParseMode.Htmland optionalmessageThreadId.SendPrivateMessageAsynccallsSendMessagetoPlatformUser.ExternalUserId.AnswerInteractionAsynccallsAnswerCallbackQuery.SendCalendarFileAsynccallsSendDocumentand maps URL actions to inline keyboard buttons.
Handler Scope
Refactor outbound Telegram calls in these flows to IPlatformMessenger:
- Join/leave/promote waitlist schedule updates and callback replies.
- Cancel schedule update, group cancellation message, direct notification and callback reply.
- Reschedule initiation, voting message updates, immediate reschedule schedule update, direct notifications and callback replies.
- Export calendar file sending.
Keep Telegram inbound DTOs at the boundary for now:
UpdateRouterstill receivesTelegram.Bot.Types.Update.- Text message parsing in reschedule input still receives
Telegram.Bot.Types.Message. CreateSessionHandlercan keep photo/topic creation viaITelegramBotClientbecause issue #24 targets outbound schedule/interaction/private/calendar contract, not replacing all Telegram update primitives in one PR.
Tasks
Task 1: RED - Shared Contract Tests
Files:
-
Create:
tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs -
Step 1: Write failing tests for neutral contracts
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Platform;
public sealed class PlatformContractsTests
{
[Fact]
public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel()
{
Assert.Equal(0, (int)PlatformKind.Telegram);
Assert.Equal(1, (int)PlatformKind.Discord);
Assert.Equal(2, (int)PlatformKind.Max);
}
[Fact]
public void PlatformContracts_ShouldBeTelegramAssemblyFree()
{
var contractTypes = new[]
{
typeof(PlatformUser),
typeof(PlatformGroup),
typeof(PlatformMessageRef),
typeof(PlatformMessageAction),
typeof(PlatformScheduleMessage),
typeof(PlatformPrivateMessage),
typeof(PlatformInteractionReply),
typeof(PlatformCalendarFile),
typeof(IPlatformMessenger)
};
Assert.All(contractTypes, type =>
Assert.DoesNotContain(
"Telegram",
string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)),
StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes()
{
var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var view = SessionBatchViewBuilder.Build(
"Campaign",
[new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")],
[]);
var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1");
var message = new PlatformScheduleMessage(group, view, ExistingMessage: null);
Assert.Equal(PlatformKind.Discord, message.Group.Platform);
Assert.Same(view, message.View);
}
}
- Step 2: Run tests and verify RED
Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
Expected: compile failure because GmRelay.Shared.Platform types do not exist.
Task 2: GREEN - Add Shared Contracts
Files:
-
Create:
src/GmRelay.Shared/Platform/PlatformKind.cs -
Create:
src/GmRelay.Shared/Platform/PlatformUser.cs -
Create:
src/GmRelay.Shared/Platform/PlatformGroup.cs -
Create:
src/GmRelay.Shared/Platform/PlatformMessageContracts.cs -
Create:
src/GmRelay.Shared/Platform/IPlatformMessenger.cs -
Step 1: Add the contract files exactly as described in the Design section
-
Step 2: Run PlatformContractsTests and verify GREEN
Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
Expected: Passed.
Task 3: RED - Adapter and Flow Source Tests
Files:
-
Create:
tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs -
Step 1: Write source tests for adapter wiring and target flows
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
public sealed class TelegramPlatformMessengerSourceTests
{
[Fact]
public async Task Program_ShouldRegisterTelegramPlatformMessenger()
{
var program = await ReadRepositoryFileAsync("src/GmRelay.Bot/Program.cs");
Assert.Contains("IPlatformMessenger", program, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformMessenger", program, StringComparison.Ordinal);
}
[Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath)
{
var source = await ReadRepositoryFileAsync(relativePath);
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal);
Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
}
[Fact]
public async Task TelegramPlatformMessenger_ShouldOwnTelegramBotClientCalls()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs");
Assert.Contains("ITelegramBotClient", source, StringComparison.Ordinal);
Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal);
Assert.Contains("SendDocument", source, StringComparison.Ordinal);
}
private static async Task<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 tests and verify RED
Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
Expected: failures because TelegramPlatformMessenger is missing and handlers still call Telegram APIs directly.
Task 4: GREEN - Implement TelegramPlatformMessenger and Registration
Files:
-
Create:
src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs -
Modify:
src/GmRelay.Bot/Program.cs -
Step 1: Implement adapter
Implementation notes:
-
Parse Telegram chat/thread/message IDs from neutral string IDs with
long.Parseandint.Parse. -
Use
ParseMode.Htmlfor HTML text. -
Map
PlatformMessageActionURLs toInlineKeyboardButton.WithUrl. -
Return a
PlatformMessageRefwith message IDs converted to strings. -
Step 2: Register adapter
Add using GmRelay.Shared.Platform; and register:
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
- Step 3: Run adapter source tests
Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
Expected: some handler source tests still fail until Task 5.
Task 5: GREEN - Refactor Session Flows Through Adapter
Files:
-
Modify target handler files listed in Task 3
-
Modify:
src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs -
Step 1: Replace constructor dependencies
Use IPlatformMessenger messenger in target handlers for outbound operations. Keep ITelegramBotClient only where the handler still performs inbound Telegram-specific work that is out of scope, such as message deletion or forum topic creation.
- Step 2: Convert Telegram IDs to neutral platform objects
Use helper code equivalent to:
private static PlatformGroup TelegramGroup(long chatId, string? title, int? threadId = null)
=> new(
PlatformKind.Telegram,
chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
title ?? "Telegram chat",
ExternalChannelId: chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
ExternalThreadId: threadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
private static PlatformUser TelegramUser(long telegramId, string displayName, string? username = null)
=> new(
PlatformKind.Telegram,
telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
displayName,
username);
- Step 3: Replace schedule updates
Build SessionBatchViewModel as before, then call:
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
group,
view,
new PlatformMessageRef(PlatformKind.Telegram, group.ExternalGroupId, group.ExternalThreadId, messageId.ToString(System.Globalization.CultureInfo.InvariantCulture))),
ct);
- Step 4: Replace interaction replies
Use:
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, text, showAlert: false),
ct);
- Step 5: Replace direct notifications
DirectSessionNotificationSender should become a small compatibility service over IPlatformMessenger:
await messenger.SendPrivateMessageAsync(
new PlatformPrivateMessage(
new PlatformUser(PlatformKind.Telegram, recipient.TelegramId.ToString(CultureInfo.InvariantCulture), recipient.DisplayName, null),
htmlText),
ct);
- Step 6: Replace calendar file sending
ExportCalendarHandler builds the same ICS bytes and calls SendCalendarFileAsync, preserving the subscription URL button as a PlatformMessageAction.
- Step 7: Run target source tests
Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
Expected: Passed.
Task 6: Regression Tests
Files:
-
Existing tests only unless a compiler failure exposes a missing using or changed behavior.
-
Step 1: Run rendering and routing tests
Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Rendering|FullyQualifiedName~Telegram|FullyQualifiedName~RescheduleSession"
Expected: Passed.
- Step 2: Run all tests
Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj
Expected: Passed.
- Step 3: Build solution
Run:
dotnet build GM-Relay.slnx
Expected: Build succeeded with warnings treated as errors.
Task 7: Version Bump
Files:
-
Modify:
Directory.Build.props -
Modify:
compose.yaml -
Modify:
.gitea/workflows/deploy.yml -
Modify:
src/GmRelay.Web/Components/Layout/NavMenu.razor -
Step 1: Update all four version locations to
2.0.1 -
Step 2: Verify sync
Run:
rg -n "2\\.0\\.0|2\\.0\\.1" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
Expected: no 2.0.0 matches in these files and 2.0.1 appears in all required locations.
Task 8: Documentation Review
Files:
-
Review:
README.md -
Review:
docs/adr/002-platform-neutral-batch-rendering.md -
Step 1: Check README and ADR for platform contract accuracy
-
Step 2: Update docs if they now misrepresent platform-neutral responsibilities
Expected likely doc change: README currently lists current version as v1.15.0, which is already inconsistent with repo version 2.0.0. If this PR bumps to 2.0.1, update that line to v2.0.1.
Task 9: Commit, PR, CI, Review, Merge, Deploy, Release
Files:
-
Stage only files intentionally changed for issue #24.
-
Step 1: Create branch
git checkout -b codex/refactor/issue-24-platform-messenger
- Step 2: Commit
git add src/GmRelay.Shared/Platform src/GmRelay.Bot tests/GmRelay.Bot.Tests Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md docs/adr/002-platform-neutral-batch-rendering.md
git commit -m "refactor: add platform messenger contracts"
- Step 3: Push and create PR via Gitea
- Step 4: Wait for PR CI and fix failures if any
- Step 5: Run code review subagent and address findings
- Step 6: Merge PR after CI and review
- Step 7: Monitor deploy workflow
- Step 8: Create release
v2.0.1with Russian release notes - Step 9: Close issue #24 with PR and release links
Self-Review
- Spec coverage: all issue acceptance criteria map to Shared contracts, Telegram adapter, handler source tests, and build/test verification.
- Placeholder scan: no
TBD,TODO, or "fill later" placeholders are left in this plan. - Type consistency: all snippets use
GmRelay.Shared.Platform,PlatformKind.Telegram,PlatformMessageRef, andIPlatformMessengerconsistently. - Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope.