refactor: add platform messenger contracts
PR Checks / test-and-build (pull_request) Successful in 12m35s

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
This commit is contained in:
2026-05-15 12:30:37 +03:00
parent 7cecb722d8
commit 8bcd16fbc9
27 changed files with 1133 additions and 180 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 2.0.0 VERSION: 2.0.1
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.0.0</Version> <Version>2.0.1</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+1 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.15.0`. **Текущая версия:** `v2.0.1`.
--- ---
+2 -2
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.0.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:2.0.1
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.0.0 image: git.codeanddice.ru/toutsu/gmrelay-web:2.0.1
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -0,0 +1,560 @@
# 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.props`
- `compose.yaml`
- `.gitea/workflows/deploy.yml`
- `src/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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
- `SendScheduleAsync` renders `SessionBatchViewModel` with `TelegramSessionBatchRenderer.Render`.
- `UpdateScheduleAsync` calls `BatchMessageEditor.EditBatchMessageAsync`.
- `SendGroupMessageAsync` calls `SendMessage` with `ParseMode.Html` and optional `messageThreadId`.
- `SendPrivateMessageAsync` calls `SendMessage` to `PlatformUser.ExternalUserId`.
- `AnswerInteractionAsync` calls `AnswerCallbackQuery`.
- `SendCalendarFileAsync` calls `SendDocument` and 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:
- `UpdateRouter` still receives `Telegram.Bot.Types.Update`.
- Text message parsing in reschedule input still receives `Telegram.Bot.Types.Message`.
- `CreateSessionHandler` can keep photo/topic creation via `ITelegramBotClient` because 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**
```csharp
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:
```powershell
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:
```powershell
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**
```csharp
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:
```powershell
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.Parse` and `int.Parse`.
- Use `ParseMode.Html` for HTML text.
- Map `PlatformMessageAction` URLs to `InlineKeyboardButton.WithUrl`.
- Return a `PlatformMessageRef` with message IDs converted to strings.
- [ ] **Step 2: Register adapter**
Add `using GmRelay.Shared.Platform;` and register:
```csharp
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
```
- [ ] **Step 3: Run adapter source tests**
Run:
```powershell
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:
```csharp
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:
```csharp
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:
```csharp
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`:
```csharp
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:
```powershell
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:
```powershell
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:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj
```
Expected: `Passed`.
- [ ] **Step 3: Build solution**
Run:
```powershell
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:
```powershell
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**
```powershell
git checkout -b codex/refactor/issue-24-platform-messenger
```
- [ ] **Step 2: Commit**
```powershell
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.1` with 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`, and `IPlatformMessenger` consistently.
- Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope.
@@ -1,12 +1,12 @@
using Telegram.Bot; using GmRelay.Bot.Infrastructure.Telegram;
using Telegram.Bot.Types.Enums; using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Notifications; namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName); public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender( public sealed class DirectSessionNotificationSender(
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<DirectSessionNotificationSender> logger) ILogger<DirectSessionNotificationSender> logger)
{ {
public async Task SendAsync( public async Task SendAsync(
@@ -20,11 +20,11 @@ public sealed class DirectSessionNotificationSender(
{ {
try try
{ {
await bot.SendMessage( await messenger.SendPrivateMessageAsync(
chatId: recipient.TelegramId, new PlatformPrivateMessage(
text: htmlText, TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
parseMode: ParseMode.Html, htmlText),
cancellationToken: ct); ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1,10 +1,9 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -22,7 +21,7 @@ internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? Bat
public sealed class CancelSessionHandler( public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger) ILogger<CancelSessionHandler> logger)
{ {
@@ -52,13 +51,13 @@ public sealed class CancelSessionHandler(
if (session == null) if (session == null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (!session.CanManage) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", ct, showAlert: true);
return; return;
} }
@@ -105,27 +104,24 @@ public sealed class CancelSessionHandler(
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
try try
{ {
await BatchMessageEditor.EditBatchMessageAsync( var messageId = session.BatchMessageId ?? command.MessageId;
bot, await messenger.UpdateScheduleAsync(
chatId: command.ChatId, new PlatformScheduleMessage(
messageId: session.BatchMessageId ?? command.MessageId, TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
text: renderResult.Text, view,
replyMarkup: renderResult.Markup, TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
ct); ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия отменена!", ct);
// Опционально: написать отдельное сообщение в чат // Опционально: написать отдельное сообщение в чат
await bot.SendMessage( await messenger.SendGroupMessageAsync(
chatId: command.ChatId, TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
messageThreadId: command.MessageThreadId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
text: $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", ct);
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
@@ -141,7 +137,10 @@ public sealed class CancelSessionHandler(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId); logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Ошибка при обновлении сообщения.", ct);
} }
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
} }
@@ -1,8 +1,7 @@
using Dapper; using Dapper;
using Npgsql; using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
@@ -22,7 +21,7 @@ internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxP
public sealed class JoinSessionHandler( public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<JoinSessionHandler> logger) ILogger<JoinSessionHandler> logger)
{ {
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct) public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
@@ -59,7 +58,7 @@ public sealed class JoinSessionHandler(
if (batchInfo is null) if (batchInfo is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
@@ -80,7 +79,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!" ? "Вы уже в листе ожидания!"
: "Вы уже записаны!"; : "Вы уже записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, alreadyText, ct);
return; return;
} }
@@ -114,7 +113,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0) if (inserted == 0)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct);
return; return;
} }
@@ -143,20 +142,17 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view); await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
await BatchMessageEditor.EditBatchMessageAsync( TelegramPlatformIds.Group(command.ChatId),
bot, view,
chatId: command.ChatId, TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
messageId: command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct); ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания." ? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!"; : "Вы успешно записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, callbackText, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -169,7 +165,10 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания." ? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации."; : "Произошла ошибка при регистрации.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, errorText, ct);
} }
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
} }
@@ -1,8 +1,8 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -20,7 +20,7 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler( public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<LeaveSessionHandler> logger) ILogger<LeaveSessionHandler> logger)
{ {
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
@@ -47,14 +47,14 @@ public sealed class LeaveSessionHandler(
if (session is null) if (session is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (SessionStatus.IsCancelled(session.Status)) if (SessionStatus.IsCancelled(session.Status))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct);
return; return;
} }
@@ -76,7 +76,7 @@ public sealed class LeaveSessionHandler(
if (participant is null) if (participant is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct);
return; return;
} }
@@ -185,14 +185,11 @@ public sealed class LeaveSessionHandler(
transactionCommitted = true; transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view); await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
await BatchMessageEditor.EditBatchMessageAsync( TelegramPlatformIds.Group(command.ChatId),
bot, view,
chatId: command.ChatId, TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
messageId: command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct); ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
@@ -201,7 +198,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии." ? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, callbackText, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -214,7 +211,10 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания." ? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи."; : "Произошла ошибка при отмене записи.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, errorText, ct);
} }
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
} }
@@ -1,8 +1,8 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -19,7 +19,7 @@ internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string Di
public sealed class PromoteWaitlistedPlayerHandler( public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<PromoteWaitlistedPlayerHandler> logger) ILogger<PromoteWaitlistedPlayerHandler> logger)
{ {
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct) public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
@@ -53,14 +53,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (session is null) if (session is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (!session.CanManage) if (!session.CanManage)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", ct, showAlert: true);
return; return;
} }
@@ -89,14 +89,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (waitlistedParticipants == 0) if (waitlistedParticipants == 0)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
return; return;
} }
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants)) if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
return; return;
} }
@@ -165,17 +165,15 @@ public sealed class PromoteWaitlistedPlayerHandler(
transactionCommitted = true; transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view); var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
await BatchMessageEditor.EditBatchMessageAsync( new PlatformScheduleMessage(
bot, TelegramPlatformIds.Group(command.ChatId),
chatId: command.ChatId, view,
messageId: session.BatchMessageId ?? command.MessageId, TelegramPlatformIds.Message(command.ChatId, threadId: null, messageId)),
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct); ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -188,7 +186,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Игрок повышен, но не удалось обновить сообщение расписания." ? "Игрок повышен, но не удалось обновить сообщение расписания."
: "Ошибка при обновлении листа ожидания."; : "Ошибка при обновлении листа ожидания.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, errorText, ct);
} }
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
} }
@@ -1,11 +1,11 @@
using System.Text; using System.Text;
using Dapper; using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Npgsql; using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar; namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -13,7 +13,7 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
public sealed class ExportCalendarHandler( public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient botClient, IPlatformMessenger messenger,
IConfiguration configuration) IConfiguration configuration)
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public async Task HandleAsync(Message message, CancellationToken cancellationToken)
@@ -34,10 +34,10 @@ public sealed class ExportCalendarHandler(
if (sessionsList.Count == 0) if (sessionsList.Count == 0)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(
chatId: message.Chat.Id, TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
text: "📭 У этой группы нет запланированных сессий для экспорта.", "📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken: cancellationToken); cancellationToken);
return; return;
} }
@@ -63,9 +63,7 @@ public sealed class ExportCalendarHandler(
sb.AppendLine("END:VCALENDAR"); sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString()); var bytes = Encoding.UTF8.GetBytes(sb.ToString());
using var stream = new MemoryStream(bytes);
var inputFile = InputFile.FromStream(stream, "schedule.ics");
// Create calendar subscription // Create calendar subscription
string? subscriptionUrl = null; string? subscriptionUrl = null;
@@ -93,20 +91,23 @@ public sealed class ExportCalendarHandler(
} }
} }
var replyMarkup = subscriptionUrl is not null var actions = subscriptionUrl is not null
? new InlineKeyboardMarkup(new[] ? new[]
{ {
new[] { InlineKeyboardButton.WithUrl("🔗 Подписаться на календарь", subscriptionUrl) } new PlatformMessageAction(
}) "calendar-subscription",
: null; "🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await botClient.SendDocument( await messenger.SendCalendarFileAsync(
chatId: message.Chat.Id, new PlatformCalendarFile(
document: inputFile, TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", "schedule.ics",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, bytes,
replyMarkup: replyMarkup, "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
messageThreadId: message.MessageThreadId, actions),
cancellationToken: cancellationToken); cancellationToken);
} }
} }
@@ -1,6 +1,7 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -33,6 +34,7 @@ internal sealed record VoteParticipantDto(
public sealed class HandleRescheduleTimeInputHandler( public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger) ILogger<HandleRescheduleTimeInputHandler> logger)
{ {
@@ -83,12 +85,10 @@ public sealed class HandleRescheduleTimeInputHandler(
// 2. Parse voting input // 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError)) if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{ {
await bot.SendMessage( await messenger.SendGroupMessageAsync(
chatId: chatId, TelegramPlatformIds.Group(chatId, proposal.ThreadId),
messageThreadId: proposal.ThreadId, $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>", ct);
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
return true; return true;
} }
@@ -243,12 +243,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
await bot.SendMessage( await messenger.SendGroupMessageAsync(
chatId: chatId, TelegramPlatformIds.Group(chatId, proposal.ThreadId),
messageThreadId: proposal.ThreadId, $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>", ct);
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
// Re-render batch message with updated time // Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct); await TryUpdateBatchMessage(proposal, ct);
@@ -383,14 +381,12 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await messenger.UpdateScheduleAsync(
bot, new PlatformScheduleMessage(
chatId: proposal.TelegramChatId, TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
messageId: proposal.BatchMessageId.Value, view,
text: renderResult.Text, TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
replyMarkup: renderResult.Markup,
ct); ct);
} }
else else
@@ -1,5 +1,6 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -22,6 +23,7 @@ internal sealed record VoteProposalDto(
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger) ILogger<HandleRescheduleVoteHandler> logger)
{ {
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
@@ -46,20 +48,13 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal is null) if (proposal is null)
{ {
await bot.AnswerCallbackQuery( await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
return; return;
} }
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{ {
await bot.AnswerCallbackQuery( await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
command.CallbackQueryId,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
showAlert: true,
cancellationToken: ct);
return; return;
} }
@@ -78,10 +73,7 @@ public sealed class HandleRescheduleVoteHandler(
if (playerId is null) if (playerId is null)
{ {
await bot.AnswerCallbackQuery( await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return; return;
} }
@@ -169,9 +161,9 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id); logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
} }
await bot.AnswerCallbackQuery( await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
command.CallbackQueryId,
"Ваш голос учтён. До дедлайна его можно изменить.",
cancellationToken: ct);
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
} }
@@ -1,7 +1,8 @@
using Dapper; using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -28,7 +29,7 @@ internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
/// </summary> /// </summary>
public sealed class InitiateRescheduleHandler( public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, IPlatformMessenger messenger,
ILogger<InitiateRescheduleHandler> logger) ILogger<InitiateRescheduleHandler> logger)
{ {
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct) public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
@@ -53,14 +54,13 @@ public sealed class InitiateRescheduleHandler(
if (session is null) if (session is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return; return;
} }
if (!session.CanManage) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -76,8 +76,7 @@ public sealed class InitiateRescheduleHandler(
if (hasActive) if (hasActive)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -92,23 +91,23 @@ public sealed class InitiateRescheduleHandler(
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId); logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
// 4. Prompt GM in chat // 4. Prompt GM in chat
await bot.AnswerCallbackQuery(command.CallbackQueryId, await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
"Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
await bot.SendMessage( await messenger.SendGroupMessageAsync(
chatId: command.ChatId, TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
messageThreadId: command.MessageThreadId, $"""
text: $""" ⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
Формат:
Формат: <code>25.04.2026 19:30
<code>25.04.2026 19:30 26.04.2026 18:00
26.04.2026 18:00 Дедлайн: 25.04.2026 12:00</code>
Дедлайн: 25.04.2026 12:00</code>
Дедлайн должен быть в будущем и раньше первого предложенного времени.
Дедлайн должен быть в будущем и раньше первого предложенного времени. """,
""", ct);
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
} }
@@ -2,6 +2,7 @@ using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -26,6 +27,7 @@ internal sealed record DueRescheduleProposalDto(
public sealed class RescheduleVotingDeadlineService( public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ISystemClock clock, ISystemClock clock,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
@@ -312,24 +314,20 @@ public sealed class RescheduleVotingDeadlineService(
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await messenger.UpdateScheduleAsync(
bot, new PlatformScheduleMessage(
chatId: proposal.TelegramChatId, TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
messageId: proposal.BatchMessageId.Value, view,
text: renderResult.Text, TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
replyMarkup: renderResult.Markup,
ct); ct);
} }
else else
{ {
await bot.SendMessage( await messenger.SendGroupMessageAsync(
chatId: proposal.TelegramChatId, TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
messageThreadId: proposal.ThreadId, $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».", ct);
parseMode: ParseMode.Html,
cancellationToken: ct);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -0,0 +1,29 @@
using System.Globalization;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Telegram;
internal static class TelegramPlatformIds
{
public static PlatformGroup Group(long chatId, int? threadId = null, string? displayName = null) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
displayName ?? "Telegram chat",
ExternalChannelId: chatId.ToString(CultureInfo.InvariantCulture),
ExternalThreadId: threadId?.ToString(CultureInfo.InvariantCulture));
public static PlatformUser User(long telegramId, string displayName, string? username = null) =>
new(
PlatformKind.Telegram,
telegramId.ToString(CultureInfo.InvariantCulture),
displayName,
username);
public static PlatformMessageRef Message(long chatId, int? threadId, int messageId) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
threadId?.ToString(CultureInfo.InvariantCulture),
messageId.ToString(CultureInfo.InvariantCulture));
}
@@ -0,0 +1,188 @@
using System.Globalization;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramPlatformMessenger(
ITelegramBotClient bot,
ILogger<TelegramPlatformMessenger> logger) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var chatId = ParseLong(message.Group.ExternalGroupId);
var threadId = ParseNullableInt(message.Group.ExternalThreadId);
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
Message sentMessage;
if (!string.IsNullOrWhiteSpace(message.ImageReference) && renderResult.Text.Length <= 1024)
{
try
{
sentMessage = await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(message.ImageReference),
caption: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for group {ExternalGroupId}", message.Group.ExternalGroupId);
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
}
else
{
if (!string.IsNullOrWhiteSpace(message.ImageReference))
{
await TrySendScheduleImageOnly(chatId, threadId, message.View.Title, message.ImageReference, ct);
}
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
return new PlatformMessageRef(
PlatformKind.Telegram,
message.Group.ExternalGroupId,
message.Group.ExternalThreadId,
sentMessage.MessageId.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
if (message.ExistingMessage is null)
{
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
}
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: ParseLong(message.Group.ExternalGroupId),
messageId: ParseInt(message.ExistingMessage.ExternalMessageId),
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
EnsureTelegram(group.Platform);
return bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(message.Recipient.ExternalUserId),
text: message.HtmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) =>
bot.AnswerCallbackQuery(
callbackQueryId: reply.InteractionId,
text: reply.Text,
showAlert: reply.ShowAlert,
cancellationToken: ct);
public async Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
EnsureTelegram(file.Group.Platform);
using var stream = new MemoryStream(file.Content);
await bot.SendDocument(
chatId: ParseLong(file.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(file.Group.ExternalThreadId),
document: InputFile.FromStream(stream, file.FileName),
caption: file.CaptionHtml,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(file.Actions),
cancellationToken: ct);
}
private async Task<Message> SendScheduleTextMessage(
long chatId,
int? threadId,
string text,
InlineKeyboardMarkup markup,
CancellationToken ct) =>
await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: markup,
cancellationToken: ct);
private async Task TrySendScheduleImageOnly(
long chatId,
int? threadId,
string title,
string imageReference,
CancellationToken ct)
{
try
{
await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for chat {ChatId}", chatId);
}
}
private static InlineKeyboardMarkup? BuildActionsMarkup(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return null;
}
return new InlineKeyboardMarkup(
actions.Select(action => new[]
{
Uri.TryCreate(action.Payload, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? InlineKeyboardButton.WithUrl(action.Label, action.Payload)
: InlineKeyboardButton.WithCallbackData(action.Label, action.Payload)
}));
}
private static void EnsureTelegram(PlatformKind platform)
{
if (platform != PlatformKind.Telegram)
{
throw new NotSupportedException($"Telegram messenger cannot send messages for platform {platform}.");
}
}
private static long ParseLong(string value) => long.Parse(value, CultureInfo.InvariantCulture);
private static int ParseInt(string value) => int.Parse(value, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, CultureInfo.InvariantCulture);
}
+2
View File
@@ -10,6 +10,7 @@ using GmRelay.Bot.Infrastructure.Health;
using GmRelay.Bot.Infrastructure.Logging; using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -50,6 +51,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
return new TelegramBotClient(token); return new TelegramBotClient(token);
}); });
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>(); builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
// ── Feature handlers (explicit registration — AOT safe) ────────────── // ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>(); builder.Services.AddSingleton<SendConfirmationHandler>();
@@ -0,0 +1,16 @@
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);
}
@@ -0,0 +1,8 @@
namespace GmRelay.Shared.Platform;
public sealed record PlatformGroup(
PlatformKind Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId = null,
string? ExternalThreadId = null);
@@ -0,0 +1,8 @@
namespace GmRelay.Shared.Platform;
public enum PlatformKind
{
Telegram = 0,
Discord = 1,
Max = 2
}
@@ -0,0 +1,36 @@
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);
@@ -0,0 +1,7 @@
namespace GmRelay.Shared.Platform;
public sealed record PlatformUser(
PlatformKind Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v2.0.0</div> <div class="nav-version">v2.0.1</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -0,0 +1,60 @@
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}'.");
}
}
@@ -43,18 +43,18 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, StringComparison.Ordinal); Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, StringComparison.Ordinal);
Assert.Contains("int? MessageThreadId", cancelHandler, StringComparison.Ordinal); Assert.Contains("int? MessageThreadId", cancelHandler, StringComparison.Ordinal);
Assert.Contains("messageThreadId: command.MessageThreadId", cancelHandler, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", cancelHandler, StringComparison.Ordinal);
Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal); Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
Assert.Contains("messageThreadId: command.MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal);
Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal); Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleInputHandler, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal);
} }
private static async Task<string> ReadRepositoryFileAsync(string relativePath) private static async Task<string> ReadRepositoryFileAsync(string relativePath)
@@ -0,0 +1,54 @@
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);
}
}