refactor: add platform messenger contracts
PR Checks / test-and-build (pull_request) Successful in 12m35s
PR Checks / test-and-build (pull_request) Successful in 12m35s
Introduce platform-neutral PlatformKind, PlatformUser, PlatformGroup, and IPlatformMessenger contracts in GmRelay.Shared. Route Telegram session schedule updates, direct notifications, interaction replies, and calendar export through TelegramPlatformMessenger while preserving existing Telegram behavior. Bump version -> 2.0.1
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 2.0.0
|
VERSION: 2.0.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>2.0.0</Version>
|
<Version>2.0.1</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v1.15.0`.
|
**Текущая версия:** `v2.0.1`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.0.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.0.1
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.0.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:2.0.1
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -0,0 +1,560 @@
|
|||||||
|
# Platform Messenger Contracts Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implement issue #24 by adding platform-neutral platform identity and messaging contracts, then routing the Telegram session flows through a Telegram adapter without changing Telegram behavior.
|
||||||
|
|
||||||
|
**Architecture:** Keep update routing and Telegram update parsing at the `GmRelay.Bot.Infrastructure.Telegram` boundary, but move outbound messaging decisions behind `GmRelay.Shared.Platform.IPlatformMessenger`. `GmRelay.Shared` owns platform-neutral DTOs and contracts; `GmRelay.Bot` owns `TelegramPlatformMessenger`, which translates neutral requests into `Telegram.Bot` calls and reuses the existing Telegram renderers/editing rules.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, C# preview, xUnit, Dapper.AOT constraints, Telegram.Bot in `GmRelay.Bot` only, platform-neutral shared contracts in `GmRelay.Shared`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue Context
|
||||||
|
|
||||||
|
- Gitea issue: #24, `refactor: ввести PlatformKind, PlatformUser, PlatformGroup и IPlatformMessenger`
|
||||||
|
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`, `pending-approval`
|
||||||
|
- Acceptance criteria:
|
||||||
|
- New contracts live in a platform-neutral layer.
|
||||||
|
- Telegram flow goes through the adapter without behavior changes.
|
||||||
|
- A future DiscordBot can reference the contract without depending on Telegram assemblies.
|
||||||
|
|
||||||
|
## Proposed Version Bump
|
||||||
|
|
||||||
|
Current version is `2.0.0` in:
|
||||||
|
|
||||||
|
- `Directory.Build.props`
|
||||||
|
- `compose.yaml`
|
||||||
|
- `.gitea/workflows/deploy.yml`
|
||||||
|
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||||
|
|
||||||
|
Issue label is `type:refactor`; per workflow rules this is not a major bump and has no user-facing feature label. Proposed bump: `2.0.0` -> `2.0.1`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
|
||||||
|
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
|
||||||
|
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
|
||||||
|
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Program.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs`
|
||||||
|
- Modify: version files listed above
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Shared Contracts
|
||||||
|
|
||||||
|
`PlatformKind` is a sentinel enum where `Max` is not a sendable platform:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public enum PlatformKind
|
||||||
|
{
|
||||||
|
Telegram = 0,
|
||||||
|
Discord = 1,
|
||||||
|
Max = 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PlatformUser` and `PlatformGroup` carry external platform identity while keeping current Telegram IDs representable as strings:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public sealed record PlatformUser(
|
||||||
|
PlatformKind Platform,
|
||||||
|
string ExternalUserId,
|
||||||
|
string DisplayName,
|
||||||
|
string? ExternalUsername);
|
||||||
|
|
||||||
|
public sealed record PlatformGroup(
|
||||||
|
PlatformKind Platform,
|
||||||
|
string ExternalGroupId,
|
||||||
|
string DisplayName,
|
||||||
|
string? ExternalChannelId = null,
|
||||||
|
string? ExternalThreadId = null);
|
||||||
|
```
|
||||||
|
|
||||||
|
Outbound message contracts stay independent of Telegram/Discord SDK types:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public sealed record PlatformMessageRef(
|
||||||
|
PlatformKind Platform,
|
||||||
|
string ExternalGroupId,
|
||||||
|
string? ExternalThreadId,
|
||||||
|
string ExternalMessageId);
|
||||||
|
|
||||||
|
public sealed record PlatformMessageAction(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string Payload);
|
||||||
|
|
||||||
|
public sealed record PlatformScheduleMessage(
|
||||||
|
PlatformGroup Group,
|
||||||
|
SessionBatchViewModel View,
|
||||||
|
PlatformMessageRef? ExistingMessage,
|
||||||
|
string? ImageReference = null);
|
||||||
|
|
||||||
|
public sealed record PlatformPrivateMessage(
|
||||||
|
PlatformUser Recipient,
|
||||||
|
string HtmlText);
|
||||||
|
|
||||||
|
public sealed record PlatformInteractionReply(
|
||||||
|
string InteractionId,
|
||||||
|
string Text,
|
||||||
|
bool ShowAlert = false);
|
||||||
|
|
||||||
|
public sealed record PlatformCalendarFile(
|
||||||
|
PlatformGroup Group,
|
||||||
|
string FileName,
|
||||||
|
byte[] Content,
|
||||||
|
string CaptionHtml,
|
||||||
|
IReadOnlyList<PlatformMessageAction> Actions);
|
||||||
|
```
|
||||||
|
|
||||||
|
`IPlatformMessenger` exposes the required outward operations:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public interface IPlatformMessenger
|
||||||
|
{
|
||||||
|
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||||
|
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||||
|
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct);
|
||||||
|
Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct);
|
||||||
|
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
|
||||||
|
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telegram Adapter
|
||||||
|
|
||||||
|
`TelegramPlatformMessenger` lives in `GmRelay.Bot.Infrastructure.Telegram`, depends on `ITelegramBotClient`, and translates neutral DTOs to existing Telegram calls:
|
||||||
|
|
||||||
|
- `SendScheduleAsync` renders `SessionBatchViewModel` with `TelegramSessionBatchRenderer.Render`.
|
||||||
|
- `UpdateScheduleAsync` calls `BatchMessageEditor.EditBatchMessageAsync`.
|
||||||
|
- `SendGroupMessageAsync` calls `SendMessage` with `ParseMode.Html` and optional `messageThreadId`.
|
||||||
|
- `SendPrivateMessageAsync` calls `SendMessage` to `PlatformUser.ExternalUserId`.
|
||||||
|
- `AnswerInteractionAsync` calls `AnswerCallbackQuery`.
|
||||||
|
- `SendCalendarFileAsync` calls `SendDocument` and maps URL actions to inline keyboard buttons.
|
||||||
|
|
||||||
|
### Handler Scope
|
||||||
|
|
||||||
|
Refactor outbound Telegram calls in these flows to `IPlatformMessenger`:
|
||||||
|
|
||||||
|
- Join/leave/promote waitlist schedule updates and callback replies.
|
||||||
|
- Cancel schedule update, group cancellation message, direct notification and callback reply.
|
||||||
|
- Reschedule initiation, voting message updates, immediate reschedule schedule update, direct notifications and callback replies.
|
||||||
|
- Export calendar file sending.
|
||||||
|
|
||||||
|
Keep Telegram inbound DTOs at the boundary for now:
|
||||||
|
|
||||||
|
- `UpdateRouter` still receives `Telegram.Bot.Types.Update`.
|
||||||
|
- Text message parsing in reschedule input still receives `Telegram.Bot.Types.Message`.
|
||||||
|
- `CreateSessionHandler` can keep photo/topic creation via `ITelegramBotClient` because issue #24 targets outbound schedule/interaction/private/calendar contract, not replacing all Telegram update primitives in one PR.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: RED - Shared Contract Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for neutral contracts**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Platform;
|
||||||
|
|
||||||
|
public sealed class PlatformContractsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, (int)PlatformKind.Telegram);
|
||||||
|
Assert.Equal(1, (int)PlatformKind.Discord);
|
||||||
|
Assert.Equal(2, (int)PlatformKind.Max);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlatformContracts_ShouldBeTelegramAssemblyFree()
|
||||||
|
{
|
||||||
|
var contractTypes = new[]
|
||||||
|
{
|
||||||
|
typeof(PlatformUser),
|
||||||
|
typeof(PlatformGroup),
|
||||||
|
typeof(PlatformMessageRef),
|
||||||
|
typeof(PlatformMessageAction),
|
||||||
|
typeof(PlatformScheduleMessage),
|
||||||
|
typeof(PlatformPrivateMessage),
|
||||||
|
typeof(PlatformInteractionReply),
|
||||||
|
typeof(PlatformCalendarFile),
|
||||||
|
typeof(IPlatformMessenger)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.All(contractTypes, type =>
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"Telegram",
|
||||||
|
string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)),
|
||||||
|
StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
var view = SessionBatchViewBuilder.Build(
|
||||||
|
"Campaign",
|
||||||
|
[new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")],
|
||||||
|
[]);
|
||||||
|
var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1");
|
||||||
|
|
||||||
|
var message = new PlatformScheduleMessage(group, view, ExistingMessage: null);
|
||||||
|
|
||||||
|
Assert.Equal(PlatformKind.Discord, message.Group.Platform);
|
||||||
|
Assert.Same(view, message.View);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile failure because `GmRelay.Shared.Platform` types do not exist.
|
||||||
|
|
||||||
|
### Task 2: GREEN - Add Shared Contracts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
|
||||||
|
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the contract files exactly as described in the Design section**
|
||||||
|
- [ ] **Step 2: Run PlatformContractsTests and verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Passed`.
|
||||||
|
|
||||||
|
### Task 3: RED - Adapter and Flow Source Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write source tests for adapter wiring and target flows**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public sealed class TelegramPlatformMessengerSourceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Program_ShouldRegisterTelegramPlatformMessenger()
|
||||||
|
{
|
||||||
|
var program = await ReadRepositoryFileAsync("src/GmRelay.Bot/Program.cs");
|
||||||
|
|
||||||
|
Assert.Contains("IPlatformMessenger", program, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("TelegramPlatformMessenger", program, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
|
||||||
|
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath)
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync(relativePath);
|
||||||
|
|
||||||
|
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TelegramPlatformMessenger_ShouldOwnTelegramBotClientCalls()
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs");
|
||||||
|
|
||||||
|
Assert.Contains("ITelegramBotClient", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("SendDocument", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: failures because `TelegramPlatformMessenger` is missing and handlers still call Telegram APIs directly.
|
||||||
|
|
||||||
|
### Task 4: GREEN - Implement TelegramPlatformMessenger and Registration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
|
||||||
|
- Modify: `src/GmRelay.Bot/Program.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement adapter**
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Parse Telegram chat/thread/message IDs from neutral string IDs with `long.Parse` and `int.Parse`.
|
||||||
|
- Use `ParseMode.Html` for HTML text.
|
||||||
|
- Map `PlatformMessageAction` URLs to `InlineKeyboardButton.WithUrl`.
|
||||||
|
- Return a `PlatformMessageRef` with message IDs converted to strings.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register adapter**
|
||||||
|
|
||||||
|
Add `using GmRelay.Shared.Platform;` and register:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run adapter source tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: some handler source tests still fail until Task 5.
|
||||||
|
|
||||||
|
### Task 5: GREEN - Refactor Session Flows Through Adapter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify target handler files listed in Task 3
|
||||||
|
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace constructor dependencies**
|
||||||
|
|
||||||
|
Use `IPlatformMessenger messenger` in target handlers for outbound operations. Keep `ITelegramBotClient` only where the handler still performs inbound Telegram-specific work that is out of scope, such as message deletion or forum topic creation.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Convert Telegram IDs to neutral platform objects**
|
||||||
|
|
||||||
|
Use helper code equivalent to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static PlatformGroup TelegramGroup(long chatId, string? title, int? threadId = null)
|
||||||
|
=> new(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
title ?? "Telegram chat",
|
||||||
|
ExternalChannelId: chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
ExternalThreadId: threadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
private static PlatformUser TelegramUser(long telegramId, string displayName, string? username = null)
|
||||||
|
=> new(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
displayName,
|
||||||
|
username);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace schedule updates**
|
||||||
|
|
||||||
|
Build `SessionBatchViewModel` as before, then call:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await messenger.UpdateScheduleAsync(
|
||||||
|
new PlatformScheduleMessage(
|
||||||
|
group,
|
||||||
|
view,
|
||||||
|
new PlatformMessageRef(PlatformKind.Telegram, group.ExternalGroupId, group.ExternalThreadId, messageId.ToString(System.Globalization.CultureInfo.InvariantCulture))),
|
||||||
|
ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace interaction replies**
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await messenger.AnswerInteractionAsync(
|
||||||
|
new PlatformInteractionReply(command.CallbackQueryId, text, showAlert: false),
|
||||||
|
ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace direct notifications**
|
||||||
|
|
||||||
|
`DirectSessionNotificationSender` should become a small compatibility service over `IPlatformMessenger`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await messenger.SendPrivateMessageAsync(
|
||||||
|
new PlatformPrivateMessage(
|
||||||
|
new PlatformUser(PlatformKind.Telegram, recipient.TelegramId.ToString(CultureInfo.InvariantCulture), recipient.DisplayName, null),
|
||||||
|
htmlText),
|
||||||
|
ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Replace calendar file sending**
|
||||||
|
|
||||||
|
`ExportCalendarHandler` builds the same ICS bytes and calls `SendCalendarFileAsync`, preserving the subscription URL button as a `PlatformMessageAction`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run target source tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Passed`.
|
||||||
|
|
||||||
|
### Task 6: Regression Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Existing tests only unless a compiler failure exposes a missing using or changed behavior.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run rendering and routing tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Rendering|FullyQualifiedName~Telegram|FullyQualifiedName~RescheduleSession"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Passed`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Passed`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build solution**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build GM-Relay.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Build succeeded` with warnings treated as errors.
|
||||||
|
|
||||||
|
### Task 7: Version Bump
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Directory.Build.props`
|
||||||
|
- Modify: `compose.yaml`
|
||||||
|
- Modify: `.gitea/workflows/deploy.yml`
|
||||||
|
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update all four version locations to `2.0.1`**
|
||||||
|
- [ ] **Step 2: Verify sync**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n "2\\.0\\.0|2\\.0\\.1" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no `2.0.0` matches in these files and `2.0.1` appears in all required locations.
|
||||||
|
|
||||||
|
### Task 8: Documentation Review
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Review: `README.md`
|
||||||
|
- Review: `docs/adr/002-platform-neutral-batch-rendering.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check README and ADR for platform contract accuracy**
|
||||||
|
- [ ] **Step 2: Update docs if they now misrepresent platform-neutral responsibilities**
|
||||||
|
|
||||||
|
Expected likely doc change: README currently lists current version as `v1.15.0`, which is already inconsistent with repo version `2.0.0`. If this PR bumps to `2.0.1`, update that line to `v2.0.1`.
|
||||||
|
|
||||||
|
### Task 9: Commit, PR, CI, Review, Merge, Deploy, Release
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Stage only files intentionally changed for issue #24.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create branch**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git checkout -b codex/refactor/issue-24-platform-messenger
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add src/GmRelay.Shared/Platform src/GmRelay.Bot tests/GmRelay.Bot.Tests Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md docs/adr/002-platform-neutral-batch-rendering.md
|
||||||
|
git commit -m "refactor: add platform messenger contracts"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push and create PR via Gitea**
|
||||||
|
- [ ] **Step 4: Wait for PR CI and fix failures if any**
|
||||||
|
- [ ] **Step 5: Run code review subagent and address findings**
|
||||||
|
- [ ] **Step 6: Merge PR after CI and review**
|
||||||
|
- [ ] **Step 7: Monitor deploy workflow**
|
||||||
|
- [ ] **Step 8: Create release `v2.0.1` with Russian release notes**
|
||||||
|
- [ ] **Step 9: Close issue #24 with PR and release links**
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: all issue acceptance criteria map to Shared contracts, Telegram adapter, handler source tests, and build/test verification.
|
||||||
|
- Placeholder scan: no `TBD`, `TODO`, or "fill later" placeholders are left in this plan.
|
||||||
|
- Type consistency: all snippets use `GmRelay.Shared.Platform`, `PlatformKind.Telegram`, `PlatformMessageRef`, and `IPlatformMessenger` consistently.
|
||||||
|
- Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using Telegram.Bot;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using Telegram.Bot.Types.Enums;
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Notifications;
|
namespace GmRelay.Bot.Features.Notifications;
|
||||||
|
|
||||||
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
|
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
|
||||||
|
|
||||||
public sealed class DirectSessionNotificationSender(
|
public sealed class DirectSessionNotificationSender(
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<DirectSessionNotificationSender> logger)
|
ILogger<DirectSessionNotificationSender> logger)
|
||||||
{
|
{
|
||||||
public async Task SendAsync(
|
public async Task SendAsync(
|
||||||
@@ -20,11 +20,11 @@ public sealed class DirectSessionNotificationSender(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await messenger.SendPrivateMessageAsync(
|
||||||
chatId: recipient.TelegramId,
|
new PlatformPrivateMessage(
|
||||||
text: htmlText,
|
TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
|
||||||
parseMode: ParseMode.Html,
|
htmlText),
|
||||||
cancellationToken: ct);
|
ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
@@ -22,7 +21,7 @@ internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? Bat
|
|||||||
|
|
||||||
public sealed class CancelSessionHandler(
|
public sealed class CancelSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<CancelSessionHandler> logger)
|
ILogger<CancelSessionHandler> logger)
|
||||||
{
|
{
|
||||||
@@ -52,13 +51,13 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
if (session == null)
|
if (session == null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.CanManage)
|
if (!session.CanManage)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", ct, showAlert: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,27 +104,24 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await BatchMessageEditor.EditBatchMessageAsync(
|
var messageId = session.BatchMessageId ?? command.MessageId;
|
||||||
bot,
|
await messenger.UpdateScheduleAsync(
|
||||||
chatId: command.ChatId,
|
new PlatformScheduleMessage(
|
||||||
messageId: session.BatchMessageId ?? command.MessageId,
|
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
|
||||||
text: renderResult.Text,
|
view,
|
||||||
replyMarkup: renderResult.Markup,
|
TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия отменена!", ct);
|
||||||
|
|
||||||
// Опционально: написать отдельное сообщение в чат
|
// Опционально: написать отдельное сообщение в чат
|
||||||
await bot.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: command.ChatId,
|
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
|
||||||
messageThreadId: command.MessageThreadId,
|
$"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
|
||||||
text: $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
|
ct);
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
@@ -141,7 +137,10 @@ public sealed class CancelSessionHandler(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
|
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Ошибка при обновлении сообщения.", ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||||
|
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxP
|
|||||||
|
|
||||||
public sealed class JoinSessionHandler(
|
public sealed class JoinSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<JoinSessionHandler> logger)
|
ILogger<JoinSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||||
@@ -59,7 +58,7 @@ public sealed class JoinSessionHandler(
|
|||||||
if (batchInfo is null)
|
if (batchInfo is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ public sealed class JoinSessionHandler(
|
|||||||
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Вы уже в листе ожидания!"
|
? "Вы уже в листе ожидания!"
|
||||||
: "Вы уже записаны!";
|
: "Вы уже записаны!";
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, alreadyText, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +113,7 @@ public sealed class JoinSessionHandler(
|
|||||||
if (inserted == 0)
|
if (inserted == 0)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,20 +142,17 @@ public sealed class JoinSessionHandler(
|
|||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
await messenger.UpdateScheduleAsync(
|
||||||
|
new PlatformScheduleMessage(
|
||||||
await BatchMessageEditor.EditBatchMessageAsync(
|
TelegramPlatformIds.Group(command.ChatId),
|
||||||
bot,
|
view,
|
||||||
chatId: command.ChatId,
|
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
|
||||||
messageId: command.MessageId,
|
|
||||||
text: renderResult.Text,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||||
: "Вы успешно записаны!";
|
: "Вы успешно записаны!";
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -169,7 +165,10 @@ public sealed class JoinSessionHandler(
|
|||||||
var errorText = transactionCommitted
|
var errorText = transactionCommitted
|
||||||
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||||
: "Произошла ошибка при регистрации.";
|
: "Произошла ошибка при регистрации.";
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, errorText, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
|
||||||
|
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
@@ -20,7 +20,7 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
|
|||||||
|
|
||||||
public sealed class LeaveSessionHandler(
|
public sealed class LeaveSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<LeaveSessionHandler> logger)
|
ILogger<LeaveSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||||
@@ -47,14 +47,14 @@ public sealed class LeaveSessionHandler(
|
|||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SessionStatus.IsCancelled(session.Status))
|
if (SessionStatus.IsCancelled(session.Status))
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
if (participant is null)
|
if (participant is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,14 +185,11 @@ public sealed class LeaveSessionHandler(
|
|||||||
transactionCommitted = true;
|
transactionCommitted = true;
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
await messenger.UpdateScheduleAsync(
|
||||||
|
new PlatformScheduleMessage(
|
||||||
await BatchMessageEditor.EditBatchMessageAsync(
|
TelegramPlatformIds.Group(command.ChatId),
|
||||||
bot,
|
view,
|
||||||
chatId: command.ChatId,
|
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
|
||||||
messageId: command.MessageId,
|
|
||||||
text: renderResult.Text,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
@@ -201,7 +198,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
? "Вы отписались от сессии."
|
? "Вы отписались от сессии."
|
||||||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -214,7 +211,10 @@ public sealed class LeaveSessionHandler(
|
|||||||
var errorText = transactionCommitted
|
var errorText = transactionCommitted
|
||||||
? "Запись снята, но не удалось обновить сообщение расписания."
|
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||||
: "Произошла ошибка при отмене записи.";
|
: "Произошла ошибка при отмене записи.";
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, errorText, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
|
||||||
|
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
@@ -19,7 +19,7 @@ internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string Di
|
|||||||
|
|
||||||
public sealed class PromoteWaitlistedPlayerHandler(
|
public sealed class PromoteWaitlistedPlayerHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<PromoteWaitlistedPlayerHandler> logger)
|
ILogger<PromoteWaitlistedPlayerHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
|
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
|
||||||
@@ -53,14 +53,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.CanManage)
|
if (!session.CanManage)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", ct, showAlert: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,14 +89,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
if (waitlistedParticipants == 0)
|
if (waitlistedParticipants == 0)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,17 +165,15 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
transactionCommitted = true;
|
transactionCommitted = true;
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
var messageId = session.BatchMessageId ?? command.MessageId;
|
||||||
|
await messenger.UpdateScheduleAsync(
|
||||||
await BatchMessageEditor.EditBatchMessageAsync(
|
new PlatformScheduleMessage(
|
||||||
bot,
|
TelegramPlatformIds.Group(command.ChatId),
|
||||||
chatId: command.ChatId,
|
view,
|
||||||
messageId: session.BatchMessageId ?? command.MessageId,
|
TelegramPlatformIds.Message(command.ChatId, threadId: null, messageId)),
|
||||||
text: renderResult.Text,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -188,7 +186,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
var errorText = transactionCommitted
|
var errorText = transactionCommitted
|
||||||
? "Игрок повышен, но не удалось обновить сообщение расписания."
|
? "Игрок повышен, но не удалось обновить сообщение расписания."
|
||||||
: "Ошибка при обновлении листа ожидания.";
|
: "Ошибка при обновлении листа ожидания.";
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, errorText, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||||
|
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
|
|||||||
|
|
||||||
public sealed class ExportCalendarHandler(
|
public sealed class ExportCalendarHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient botClient,
|
IPlatformMessenger messenger,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
@@ -34,10 +34,10 @@ public sealed class ExportCalendarHandler(
|
|||||||
|
|
||||||
if (sessionsList.Count == 0)
|
if (sessionsList.Count == 0)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
||||||
text: "📭 У этой группы нет запланированных сессий для экспорта.",
|
"📭 У этой группы нет запланированных сессий для экспорта.",
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +63,7 @@ public sealed class ExportCalendarHandler(
|
|||||||
sb.AppendLine("END:VCALENDAR");
|
sb.AppendLine("END:VCALENDAR");
|
||||||
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
using var stream = new MemoryStream(bytes);
|
|
||||||
|
|
||||||
var inputFile = InputFile.FromStream(stream, "schedule.ics");
|
|
||||||
|
|
||||||
// Create calendar subscription
|
// Create calendar subscription
|
||||||
string? subscriptionUrl = null;
|
string? subscriptionUrl = null;
|
||||||
@@ -93,20 +91,23 @@ public sealed class ExportCalendarHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var replyMarkup = subscriptionUrl is not null
|
var actions = subscriptionUrl is not null
|
||||||
? new InlineKeyboardMarkup(new[]
|
? new[]
|
||||||
{
|
{
|
||||||
new[] { InlineKeyboardButton.WithUrl("🔗 Подписаться на календарь", subscriptionUrl) }
|
new PlatformMessageAction(
|
||||||
})
|
"calendar-subscription",
|
||||||
: null;
|
"🔗 Подписаться на календарь",
|
||||||
|
subscriptionUrl)
|
||||||
|
}
|
||||||
|
: Array.Empty<PlatformMessageAction>();
|
||||||
|
|
||||||
await botClient.SendDocument(
|
await messenger.SendCalendarFileAsync(
|
||||||
chatId: message.Chat.Id,
|
new PlatformCalendarFile(
|
||||||
document: inputFile,
|
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
||||||
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
"schedule.ics",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
bytes,
|
||||||
replyMarkup: replyMarkup,
|
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||||
messageThreadId: message.MessageThreadId,
|
actions),
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-19
@@ -1,6 +1,7 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -33,6 +34,7 @@ internal sealed record VoteParticipantDto(
|
|||||||
public sealed class HandleRescheduleTimeInputHandler(
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
ILogger<HandleRescheduleTimeInputHandler> logger)
|
||||||
{
|
{
|
||||||
@@ -83,12 +85,10 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
// 2. Parse voting input
|
// 2. Parse voting input
|
||||||
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: chatId,
|
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
||||||
messageThreadId: proposal.ThreadId,
|
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
||||||
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
ct);
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,12 +243,10 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
await bot.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: chatId,
|
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
||||||
messageThreadId: proposal.ThreadId,
|
$"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
||||||
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
ct);
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
|
|
||||||
// Re-render batch message with updated time
|
// Re-render batch message with updated time
|
||||||
await TryUpdateBatchMessage(proposal, ct);
|
await TryUpdateBatchMessage(proposal, ct);
|
||||||
@@ -383,14 +381,12 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
if (proposal.BatchMessageId.HasValue)
|
if (proposal.BatchMessageId.HasValue)
|
||||||
{
|
{
|
||||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
await BatchMessageEditor.EditBatchMessageAsync(
|
await messenger.UpdateScheduleAsync(
|
||||||
bot,
|
new PlatformScheduleMessage(
|
||||||
chatId: proposal.TelegramChatId,
|
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
||||||
messageId: proposal.BatchMessageId.Value,
|
view,
|
||||||
text: renderResult.Text,
|
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ internal sealed record VoteProposalDto(
|
|||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
ILogger<HandleRescheduleVoteHandler> logger)
|
ILogger<HandleRescheduleVoteHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
@@ -46,20 +48,13 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
if (proposal is null)
|
if (proposal is null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(
|
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
|
||||||
command.CallbackQueryId,
|
|
||||||
"Голосование уже завершено или не найдено.",
|
|
||||||
cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(
|
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
|
||||||
command.CallbackQueryId,
|
|
||||||
"Дедлайн уже прошёл. Результаты скоро будут применены.",
|
|
||||||
showAlert: true,
|
|
||||||
cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +73,7 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
if (playerId is null)
|
if (playerId is null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(
|
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
|
||||||
command.CallbackQueryId,
|
|
||||||
"Вы не являетесь участником этой сессии.",
|
|
||||||
cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +161,9 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(
|
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
|
||||||
command.CallbackQueryId,
|
|
||||||
"Ваш голос учтён. До дедлайна его можно изменить.",
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||||
|
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InitiateRescheduleHandler(
|
public sealed class InitiateRescheduleHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<InitiateRescheduleHandler> logger)
|
ILogger<InitiateRescheduleHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
|
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
|
||||||
@@ -53,14 +54,13 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.CanManage)
|
if (!session.CanManage)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
|
||||||
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +76,7 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
|
|
||||||
if (hasActive)
|
if (hasActive)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
|
||||||
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,23 +91,23 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||||
|
|
||||||
// 4. Prompt GM in chat
|
// 4. Prompt GM in chat
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
|
||||||
"Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
|
|
||||||
|
|
||||||
await bot.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: command.ChatId,
|
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
|
||||||
messageThreadId: command.MessageThreadId,
|
$"""
|
||||||
text: $"""
|
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
|
||||||
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
|
|
||||||
|
Формат:
|
||||||
Формат:
|
<code>25.04.2026 19:30
|
||||||
<code>25.04.2026 19:30
|
26.04.2026 18:00
|
||||||
26.04.2026 18:00
|
Дедлайн: 25.04.2026 12:00</code>
|
||||||
Дедлайн: 25.04.2026 12:00</code>
|
|
||||||
|
Дедлайн должен быть в будущем и раньше первого предложенного времени.
|
||||||
Дедлайн должен быть в будущем и раньше первого предложенного времени.
|
""",
|
||||||
""",
|
ct);
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||||
|
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-13
@@ -2,6 +2,7 @@ using Dapper;
|
|||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -26,6 +27,7 @@ internal sealed record DueRescheduleProposalDto(
|
|||||||
public sealed class RescheduleVotingDeadlineService(
|
public sealed class RescheduleVotingDeadlineService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ISystemClock clock,
|
ISystemClock clock,
|
||||||
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
||||||
@@ -312,24 +314,20 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
if (proposal.BatchMessageId.HasValue)
|
if (proposal.BatchMessageId.HasValue)
|
||||||
{
|
{
|
||||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
await BatchMessageEditor.EditBatchMessageAsync(
|
await messenger.UpdateScheduleAsync(
|
||||||
bot,
|
new PlatformScheduleMessage(
|
||||||
chatId: proposal.TelegramChatId,
|
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
||||||
messageId: proposal.BatchMessageId.Value,
|
view,
|
||||||
text: renderResult.Text,
|
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: proposal.TelegramChatId,
|
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
||||||
messageThreadId: proposal.ThreadId,
|
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
|
||||||
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
|
ct);
|
||||||
parseMode: ParseMode.Html,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
internal static class TelegramPlatformIds
|
||||||
|
{
|
||||||
|
public static PlatformGroup Group(long chatId, int? threadId = null, string? displayName = null) =>
|
||||||
|
new(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
chatId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
displayName ?? "Telegram chat",
|
||||||
|
ExternalChannelId: chatId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
ExternalThreadId: threadId?.ToString(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
public static PlatformUser User(long telegramId, string displayName, string? username = null) =>
|
||||||
|
new(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
telegramId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
displayName,
|
||||||
|
username);
|
||||||
|
|
||||||
|
public static PlatformMessageRef Message(long chatId, int? threadId, int messageId) =>
|
||||||
|
new(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
chatId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
threadId?.ToString(CultureInfo.InvariantCulture),
|
||||||
|
messageId.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public sealed class TelegramPlatformMessenger(
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<TelegramPlatformMessenger> logger) : IPlatformMessenger
|
||||||
|
{
|
||||||
|
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(message.Group.Platform);
|
||||||
|
|
||||||
|
var chatId = ParseLong(message.Group.ExternalGroupId);
|
||||||
|
var threadId = ParseNullableInt(message.Group.ExternalThreadId);
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
|
||||||
|
Message sentMessage;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.ImageReference) && renderResult.Text.Length <= 1024)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sentMessage = await bot.SendPhoto(
|
||||||
|
chatId: chatId,
|
||||||
|
messageThreadId: threadId,
|
||||||
|
photo: InputFile.FromString(message.ImageReference),
|
||||||
|
caption: renderResult.Text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to send Telegram schedule image for group {ExternalGroupId}", message.Group.ExternalGroupId);
|
||||||
|
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.ImageReference))
|
||||||
|
{
|
||||||
|
await TrySendScheduleImageOnly(chatId, threadId, message.View.Title, message.ImageReference, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PlatformMessageRef(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
message.Group.ExternalGroupId,
|
||||||
|
message.Group.ExternalThreadId,
|
||||||
|
sentMessage.MessageId.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(message.Group.Platform);
|
||||||
|
if (message.ExistingMessage is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
|
||||||
|
await BatchMessageEditor.EditBatchMessageAsync(
|
||||||
|
bot,
|
||||||
|
chatId: ParseLong(message.Group.ExternalGroupId),
|
||||||
|
messageId: ParseInt(message.ExistingMessage.ExternalMessageId),
|
||||||
|
text: renderResult.Text,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(group.Platform);
|
||||||
|
return bot.SendMessage(
|
||||||
|
chatId: ParseLong(group.ExternalGroupId),
|
||||||
|
messageThreadId: ParseNullableInt(group.ExternalThreadId),
|
||||||
|
text: htmlText,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(message.Recipient.Platform);
|
||||||
|
return bot.SendMessage(
|
||||||
|
chatId: ParseLong(message.Recipient.ExternalUserId),
|
||||||
|
text: message.HtmlText,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) =>
|
||||||
|
bot.AnswerCallbackQuery(
|
||||||
|
callbackQueryId: reply.InteractionId,
|
||||||
|
text: reply.Text,
|
||||||
|
showAlert: reply.ShowAlert,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
public async Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(file.Group.Platform);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(file.Content);
|
||||||
|
await bot.SendDocument(
|
||||||
|
chatId: ParseLong(file.Group.ExternalGroupId),
|
||||||
|
messageThreadId: ParseNullableInt(file.Group.ExternalThreadId),
|
||||||
|
document: InputFile.FromStream(stream, file.FileName),
|
||||||
|
caption: file.CaptionHtml,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: BuildActionsMarkup(file.Actions),
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Message> SendScheduleTextMessage(
|
||||||
|
long chatId,
|
||||||
|
int? threadId,
|
||||||
|
string text,
|
||||||
|
InlineKeyboardMarkup markup,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
messageThreadId: threadId,
|
||||||
|
text: text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: markup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
private async Task TrySendScheduleImageOnly(
|
||||||
|
long chatId,
|
||||||
|
int? threadId,
|
||||||
|
string title,
|
||||||
|
string imageReference,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.SendPhoto(
|
||||||
|
chatId: chatId,
|
||||||
|
messageThreadId: threadId,
|
||||||
|
photo: InputFile.FromString(imageReference),
|
||||||
|
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to send Telegram schedule image for chat {ChatId}", chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InlineKeyboardMarkup? BuildActionsMarkup(IReadOnlyList<PlatformMessageAction> actions)
|
||||||
|
{
|
||||||
|
if (actions.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InlineKeyboardMarkup(
|
||||||
|
actions.Select(action => new[]
|
||||||
|
{
|
||||||
|
Uri.TryCreate(action.Payload, UriKind.Absolute, out var uri) &&
|
||||||
|
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
|
||||||
|
? InlineKeyboardButton.WithUrl(action.Label, action.Payload)
|
||||||
|
: InlineKeyboardButton.WithCallbackData(action.Label, action.Payload)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureTelegram(PlatformKind platform)
|
||||||
|
{
|
||||||
|
if (platform != PlatformKind.Telegram)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException($"Telegram messenger cannot send messages for platform {platform}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ParseLong(string value) => long.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static int ParseInt(string value) => int.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static int? ParseNullableInt(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ using GmRelay.Bot.Infrastructure.Health;
|
|||||||
using GmRelay.Bot.Infrastructure.Logging;
|
using GmRelay.Bot.Infrastructure.Logging;
|
||||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
|||||||
return new TelegramBotClient(token);
|
return new TelegramBotClient(token);
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||||
|
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
|
||||||
|
|
||||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public interface IPlatformMessenger
|
||||||
|
{
|
||||||
|
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||||
|
|
||||||
|
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||||
|
|
||||||
|
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct);
|
||||||
|
|
||||||
|
Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct);
|
||||||
|
|
||||||
|
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
|
||||||
|
|
||||||
|
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public sealed record PlatformGroup(
|
||||||
|
PlatformKind Platform,
|
||||||
|
string ExternalGroupId,
|
||||||
|
string DisplayName,
|
||||||
|
string? ExternalChannelId = null,
|
||||||
|
string? ExternalThreadId = null);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public enum PlatformKind
|
||||||
|
{
|
||||||
|
Telegram = 0,
|
||||||
|
Discord = 1,
|
||||||
|
Max = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public sealed record PlatformMessageRef(
|
||||||
|
PlatformKind Platform,
|
||||||
|
string ExternalGroupId,
|
||||||
|
string? ExternalThreadId,
|
||||||
|
string ExternalMessageId);
|
||||||
|
|
||||||
|
public sealed record PlatformMessageAction(
|
||||||
|
string Key,
|
||||||
|
string Label,
|
||||||
|
string Payload);
|
||||||
|
|
||||||
|
public sealed record PlatformScheduleMessage(
|
||||||
|
PlatformGroup Group,
|
||||||
|
SessionBatchViewModel View,
|
||||||
|
PlatformMessageRef? ExistingMessage,
|
||||||
|
string? ImageReference = null);
|
||||||
|
|
||||||
|
public sealed record PlatformPrivateMessage(
|
||||||
|
PlatformUser Recipient,
|
||||||
|
string HtmlText);
|
||||||
|
|
||||||
|
public sealed record PlatformInteractionReply(
|
||||||
|
string InteractionId,
|
||||||
|
string Text,
|
||||||
|
bool ShowAlert = false);
|
||||||
|
|
||||||
|
public sealed record PlatformCalendarFile(
|
||||||
|
PlatformGroup Group,
|
||||||
|
string FileName,
|
||||||
|
byte[] Content,
|
||||||
|
string CaptionHtml,
|
||||||
|
IReadOnlyList<PlatformMessageAction> Actions);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
public sealed record PlatformUser(
|
||||||
|
PlatformKind Platform,
|
||||||
|
string ExternalUserId,
|
||||||
|
string DisplayName,
|
||||||
|
string? ExternalUsername);
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v2.0.0</div>
|
<div class="nav-version">v2.0.1</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
+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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -43,18 +43,18 @@ public sealed class TelegramTopicIntegrationSmokeTests
|
|||||||
Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, StringComparison.Ordinal);
|
Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Contains("int? MessageThreadId", cancelHandler, StringComparison.Ordinal);
|
Assert.Contains("int? MessageThreadId", cancelHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("messageThreadId: command.MessageThreadId", cancelHandler, StringComparison.Ordinal);
|
Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", cancelHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
|
Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("messageThreadId: command.MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
|
Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Platform;
|
||||||
|
|
||||||
|
public sealed class PlatformContractsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, (int)PlatformKind.Telegram);
|
||||||
|
Assert.Equal(1, (int)PlatformKind.Discord);
|
||||||
|
Assert.Equal(2, (int)PlatformKind.Max);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlatformContracts_ShouldBeTelegramAssemblyFree()
|
||||||
|
{
|
||||||
|
var contractTypes = new[]
|
||||||
|
{
|
||||||
|
typeof(PlatformUser),
|
||||||
|
typeof(PlatformGroup),
|
||||||
|
typeof(PlatformMessageRef),
|
||||||
|
typeof(PlatformMessageAction),
|
||||||
|
typeof(PlatformScheduleMessage),
|
||||||
|
typeof(PlatformPrivateMessage),
|
||||||
|
typeof(PlatformInteractionReply),
|
||||||
|
typeof(PlatformCalendarFile),
|
||||||
|
typeof(IPlatformMessenger)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.All(contractTypes, type =>
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"Telegram",
|
||||||
|
string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)),
|
||||||
|
StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
var view = SessionBatchViewBuilder.Build(
|
||||||
|
"Campaign",
|
||||||
|
[new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")],
|
||||||
|
[]);
|
||||||
|
var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1");
|
||||||
|
|
||||||
|
var message = new PlatformScheduleMessage(group, view, ExistingMessage: null);
|
||||||
|
|
||||||
|
Assert.Equal(PlatformKind.Discord, message.Group.Platform);
|
||||||
|
Assert.Same(view, message.View);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user