Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cea6ec801a | |||
| 8e57f8b07a | |||
| e837e191c2 | |||
| df01aa9f3e | |||
| 18e702cd04 | |||
| 5931099c14 | |||
| 8bcd16fbc9 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 2.0.0
|
||||
VERSION: 2.1.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v1.15.0`.
|
||||
**Текущая версия:** `v2.0.1`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-19
@@ -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);
|
||||
}
|
||||
|
||||
+11
-13
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
+551
-110
File diff suppressed because it is too large
Load Diff
+60
@@ -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", []);
|
||||
}
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user