Compare commits

...

8 Commits

Author SHA1 Message Date
Toutsu cb515b0e05 Merge pull request #81: feat: refresh dashboard design with fantasy RPG aesthetic
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / scan-images (push) Successful in 1m4s
Deploy Telegram Bot / deploy (push) Successful in 11s
🎨 Dashboard design refresh
- Complete fantasy RPG aesthetic overhaul
- Glass-morphism cards with gradient borders
- Cinzel + Jura typography
- Atmospheric backgrounds with noise texture

🧹 Chore: migrated k8s manifests to gmrelay-k8s repo

Bump version → 2.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:29:16 +03:00
Toutsu cea6ec801a chore: bump version to 2.1.0
PR Checks / test-and-build (pull_request) Successful in 5m11s
Synchronize version across all 4 files:
- Directory.Build.props
- compose.yaml (bot + web images)
- .gitea/workflows/deploy.yml
- NavMenu.razor

Bump version → 2.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:22:00 +03:00
Toutsu 8e57f8b07a chore: migrate k8s manifests to dedicated repo
PR Checks / test-and-build (pull_request) Successful in 5m11s
All Kubernetes manifests moved to git.codeanddice.ru/Toutsu/gmrelay-k8s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:04:22 +03:00
Toutsu e837e191c2 feat: refresh dashboard design with fantasy RPG aesthetic
- Replace Inter font with Cinzel (headings) + Jura (body)
- Deepen dark background palette with atmospheric gradient orbs
- Add subtle noise texture overlay for depth
- Refine glass cards with animated gradient border glow on hover
- Sharpen accent colors: violet #8b5cf6 + cyan #22d3ee
- Improve button tactile feedback and shadow system
- Add k8s manifests for minikube local deployment

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:54:56 +03:00
Toutsu df01aa9f3e Merge pull request #80: refactor: add platform messenger contracts
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m37s
Deploy Telegram Bot / deploy (push) Successful in 19s
2026-05-15 18:46:30 +03:00
Toutsu 18e702cd04 fix: validate platform schedule update target
PR Checks / test-and-build (pull_request) Successful in 13m7s
2026-05-15 18:31:17 +03:00
Toutsu 5931099c14 style: remove reschedule prompt trailing whitespace
PR Checks / test-and-build (pull_request) Successful in 12m16s
2026-05-15 12:46:50 +03:00
Toutsu 8bcd16fbc9 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
2026-05-15 12:30:37 +03:00
32 changed files with 1754 additions and 301 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 2.0.0
VERSION: 2.1.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>2.0.0</Version>
<Version>2.1.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+1 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.15.0`.
**Текущая версия:** `v2.0.1`.
---
+2 -2
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.0.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.0.0
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.0
restart: always
depends_on:
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 Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender(
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<DirectSessionNotificationSender> logger)
{
public async Task SendAsync(
@@ -20,11 +20,11 @@ public sealed class DirectSessionNotificationSender(
{
try
{
await bot.SendMessage(
chatId: recipient.TelegramId,
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
await messenger.SendPrivateMessageAsync(
new PlatformPrivateMessage(
TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
htmlText),
ct);
}
catch (Exception ex)
{
@@ -1,10 +1,9 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -22,7 +21,7 @@ internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? Bat
public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger)
{
@@ -52,13 +51,13 @@ public sealed class CancelSessionHandler(
if (session == null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
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;
}
@@ -105,27 +104,24 @@ public sealed class CancelSessionHandler(
// 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
try
{
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
view,
TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия отменена!", ct);
// Опционально: написать отдельное сообщение в чат
await bot.SendMessage(
chatId: command.ChatId,
messageThreadId: command.MessageThreadId,
text: $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
$"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
@@ -141,7 +137,10 @@ public sealed class CancelSessionHandler(
catch (Exception ex)
{
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 Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram;
@@ -22,7 +21,7 @@ internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxP
public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<JoinSessionHandler> logger)
{
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
@@ -59,7 +58,7 @@ public sealed class JoinSessionHandler(
if (batchInfo is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
@@ -80,7 +79,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!"
: "Вы уже записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, alreadyText, ct);
return;
}
@@ -114,7 +113,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct);
return;
}
@@ -143,20 +142,17 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
}
catch (Exception ex)
{
@@ -169,7 +165,10 @@ public sealed class JoinSessionHandler(
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 GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -20,7 +20,7 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<LeaveSessionHandler> logger)
{
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
@@ -47,14 +47,14 @@ public sealed class LeaveSessionHandler(
if (session is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (SessionStatus.IsCancelled(session.Status))
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct);
return;
}
@@ -76,7 +76,7 @@ public sealed class LeaveSessionHandler(
if (participant is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct);
return;
}
@@ -185,14 +185,11 @@ public sealed class LeaveSessionHandler(
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
@@ -201,7 +198,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
}
catch (Exception ex)
{
@@ -214,7 +211,10 @@ public sealed class LeaveSessionHandler(
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 GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -19,7 +19,7 @@ internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string Di
public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<PromoteWaitlistedPlayerHandler> logger)
{
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
@@ -53,14 +53,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (session is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
if (!session.CanManage)
{
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;
}
@@ -89,14 +89,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (waitlistedParticipants == 0)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
return;
}
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
return;
}
@@ -165,17 +165,15 @@ public sealed class PromoteWaitlistedPlayerHandler(
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, messageId)),
ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
}
catch (Exception ex)
{
@@ -188,7 +186,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
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 Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -13,7 +13,7 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
IPlatformMessenger messenger,
IConfiguration configuration)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
@@ -34,10 +34,10 @@ public sealed class ExportCalendarHandler(
if (sessionsList.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
@@ -63,9 +63,7 @@ public sealed class ExportCalendarHandler(
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
using var stream = new MemoryStream(bytes);
var inputFile = InputFile.FromStream(stream, "schedule.ics");
// Create calendar subscription
string? subscriptionUrl = null;
@@ -93,20 +91,23 @@ public sealed class ExportCalendarHandler(
}
}
var replyMarkup = subscriptionUrl is not null
? new InlineKeyboardMarkup(new[]
var actions = subscriptionUrl is not null
? new[]
{
new[] { InlineKeyboardButton.WithUrl("🔗 Подписаться на календарь", subscriptionUrl) }
})
: null;
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await botClient.SendDocument(
chatId: message.Chat.Id,
document: inputFile,
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: replyMarkup,
messageThreadId: message.MessageThreadId,
cancellationToken: cancellationToken);
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
}
}
@@ -1,6 +1,7 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
@@ -33,6 +34,7 @@ internal sealed record VoteParticipantDto(
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
@@ -83,12 +85,10 @@ public sealed class HandleRescheduleTimeInputHandler(
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
await bot.SendMessage(
chatId: chatId,
messageThreadId: proposal.ThreadId,
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
ct);
return true;
}
@@ -243,12 +243,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await transaction.CommitAsync(ct);
await bot.SendMessage(
chatId: chatId,
messageThreadId: proposal.ThreadId,
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>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, 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>",
ct);
// Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct);
@@ -383,14 +381,12 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
ct);
}
else
@@ -1,5 +1,6 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -22,6 +23,7 @@ internal sealed record VoteProposalDto(
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger)
{
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
@@ -46,20 +48,13 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal is null)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
return;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
showAlert: true,
cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
return;
}
@@ -78,10 +73,7 @@ public sealed class HandleRescheduleVoteHandler(
if (playerId is null)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
return;
}
@@ -169,9 +161,9 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
}
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,7 +1,8 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -28,7 +29,7 @@ internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
/// </summary>
public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<InitiateRescheduleHandler> logger)
{
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
@@ -53,14 +54,13 @@ public sealed class InitiateRescheduleHandler(
if (session is null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
return;
}
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;
}
@@ -76,8 +76,7 @@ public sealed class InitiateRescheduleHandler(
if (hasActive)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
return;
}
@@ -92,23 +91,28 @@ public sealed class InitiateRescheduleHandler(
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
// 4. Prompt GM in chat
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
await bot.SendMessage(
chatId: command.ChatId,
messageThreadId: command.MessageThreadId,
text: $"""
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
var prompt = string.Join(
"\n",
new[]
{
$"⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.",
"",
"Формат:",
"<code>25.04.2026 19:30",
"26.04.2026 18:00",
"Дедлайн: 25.04.2026 12:00</code>",
"",
"Дедлайн должен быть в будущем и раньше первого предложенного времени."
});
Формат:
<code>25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00</code>
Дедлайн должен быть в будущем и раньше первого предложенного времени.
""",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
prompt,
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.Infrastructure.Scheduling;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
@@ -26,6 +27,7 @@ internal sealed record DueRescheduleProposalDto(
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ISystemClock clock,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
@@ -312,24 +314,20 @@ public sealed class RescheduleVotingDeadlineService(
if (proposal.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
ct);
}
else
{
await bot.SendMessage(
chatId: proposal.TelegramChatId,
messageThreadId: proposal.ThreadId,
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
parseMode: ParseMode.Html,
cancellationToken: ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
ct);
}
}
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,196 @@
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);
var existingMessage = message.ExistingMessage;
if (existingMessage is null)
{
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
}
EnsureTelegram(existingMessage.Platform);
if (!string.Equals(message.Group.ExternalGroupId, existingMessage.ExternalGroupId, StringComparison.Ordinal) ||
!string.Equals(message.Group.ExternalThreadId, existingMessage.ExternalThreadId, StringComparison.Ordinal))
{
throw new ArgumentException("Existing schedule message reference must match the schedule group.", nameof(message));
}
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(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.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -50,6 +51,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
return new TelegramBotClient(token);
});
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
// ── Feature handlers (explicit registration — AOT safe) ──────────────
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);
+1 -1
View File
@@ -10,7 +10,7 @@
<ResourcePreloader />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=Cinzel:wght@400;600;700&family=Jura:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
@@ -30,8 +30,9 @@
/* === Error UI === */
#blazor-error-ui {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
background: var(--status-danger-bg);
color: var(--status-danger);
border-top: 1px solid rgba(239, 68, 68, 0.15);
bottom: 0;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3);
box-sizing: border-box;
@@ -41,8 +42,9 @@
position: fixed;
width: 100%;
z-index: 1000;
color: var(--text-secondary);
font-size: 0.875rem;
font-family: 'Jura', sans-serif;
font-weight: 500;
}
#blazor-error-ui .reload {
@@ -56,7 +56,7 @@
</button>
</form>
<div class="nav-version">v2.0.0</div>
<div class="nav-version">v2.1.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -23,12 +23,11 @@
}
.nav-brand-text {
font-family: 'Cinzel Decorative', 'Cinzel', serif;
font-size: 1.125rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.04em;
color: var(--text-primary);
}
.nav-toggle {
@@ -87,9 +86,10 @@
}
.nav-section ::deep .nav-item.active {
background: rgba(124, 58, 237, 0.15);
color: var(--accent-primary);
border: 1px solid rgba(124, 58, 237, 0.2);
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(34, 211, 238, 0.08) 100%);
color: var(--text-accent);
border: 1px solid rgba(139, 92, 246, 0.25);
box-shadow: 0 0 12px rgba(139, 92, 246, 0.1);
}
.nav-icon {
@@ -145,7 +145,7 @@
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-family: 'Inter', sans-serif;
font-family: 'Jura', sans-serif;
font-size: 0.8125rem;
cursor: pointer;
transition: all var(--transition-normal);
File diff suppressed because it is too large Load Diff
@@ -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}'.");
}
}
@@ -0,0 +1,44 @@
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging.Abstractions;
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
public sealed class TelegramPlatformMessengerTests
{
[Fact]
public async Task UpdateScheduleAsync_ShouldRejectNonTelegramExistingMessageReference()
{
var messenger = CreateMessenger();
var message = new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Telegram, "100", "Telegram group"),
CreateView(),
new PlatformMessageRef(PlatformKind.Discord, "100", null, "200"));
await Assert.ThrowsAsync<NotSupportedException>(
() => messenger.UpdateScheduleAsync(message, CancellationToken.None));
}
[Fact]
public async Task UpdateScheduleAsync_ShouldRejectMismatchedGroupAndExistingMessageReference()
{
var messenger = CreateMessenger();
var message = new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Telegram, "100", "Telegram group", ExternalThreadId: "7"),
CreateView(),
new PlatformMessageRef(PlatformKind.Telegram, "101", "7", "200"));
var exception = await Assert.ThrowsAsync<ArgumentException>(
() => messenger.UpdateScheduleAsync(message, CancellationToken.None));
Assert.Equal("message", exception.ParamName);
Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message);
}
private static TelegramPlatformMessenger CreateMessenger() =>
new(null!, NullLogger<TelegramPlatformMessenger>.Instance);
private static SessionBatchViewModel CreateView() =>
new("Test batch", []);
}
@@ -43,18 +43,18 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, 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("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("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("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)
@@ -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);
}
}