diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 217ebab..79733de 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.0.0 + VERSION: 2.0.1 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 660744d..e0f3902 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.0.0 + 2.0.1 net10.0 preview enable diff --git a/README.md b/README.md index 40892c3..4fac48f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.15.0`. +**Текущая версия:** `v2.0.1`. --- diff --git a/compose.yaml b/compose.yaml index 6492075..710a96a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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.0.1 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.0.1 restart: always depends_on: db: diff --git a/docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md b/docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md new file mode 100644 index 0000000..5aef89a --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-platform-messenger-contracts.md @@ -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 Actions); +``` + +`IPlatformMessenger` exposes the required outward operations: + +```csharp +namespace GmRelay.Shared.Platform; + +public interface IPlatformMessenger +{ + Task 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 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(); +``` + +- [ ] **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. diff --git a/src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs b/src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs index a547790..007b28f 100644 --- a/src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs +++ b/src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs @@ -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 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) { diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 16af575..d963c59 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -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 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: $"❌ Внимание! Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - cancellationToken: ct); + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId), + $"❌ Внимание! Сессия \"{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); } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs index 6eea0b1..1e23645 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -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 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); } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs index e97024c..230e82c 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs @@ -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 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); } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs index f4f24b0..2e6dd6c 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -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 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); } diff --git a/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs index 57524e7..2bdea66 100644 --- a/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs @@ -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(); - await botClient.SendDocument( - chatId: message.Chat.Id, - document: inputFile, - caption: "📅 Ваш календарь игр!\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, + "📅 Ваш календарь игр!\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", + actions), + cancellationToken); } } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index e1569e6..940df70 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -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 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Используйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00", - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - cancellationToken: ct); + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(chatId, proposal.ThreadId), + $"⚠️ {parseError}\n\nИспользуйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00", + 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📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется.", - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - cancellationToken: ct); + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(chatId, proposal.ThreadId), + $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется.", + 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 diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index 5e0b5fe..6915afe 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -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 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); } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs index fd813fc..1f8e54f 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs @@ -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); /// public sealed class InitiateRescheduleHandler( NpgsqlDataSource dataSource, - ITelegramBotClient bot, + IPlatformMessenger messenger, ILogger 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,23 @@ 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}» и дедлайн голосования. - - Формат: - 25.04.2026 19:30 - 26.04.2026 18:00 - Дедлайн: 25.04.2026 12:00 - - Дедлайн должен быть в будущем и раньше первого предложенного времени. - """, - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - cancellationToken: ct); + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId), + $""" + ⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования. + + Формат: + 25.04.2026 19:30 + 26.04.2026 18:00 + Дедлайн: 25.04.2026 12:00 + + Дедлайн должен быть в будущем и раньше первого предложенного времени. + """, + ct); } + + private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) => + messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct); } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index 012f4a1..840591e 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -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 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) diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformIds.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformIds.cs new file mode 100644 index 0000000..965cd56 --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformIds.cs @@ -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)); +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs new file mode 100644 index 0000000..067d0d5 --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs @@ -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 logger) : IPlatformMessenger +{ + public async Task 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 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 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); +} diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index f22691e..9db67ef 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -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(sp => return new TelegramBotClient(token); }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // ── Feature handlers (explicit registration — AOT safe) ────────────── builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Platform/IPlatformMessenger.cs b/src/GmRelay.Shared/Platform/IPlatformMessenger.cs new file mode 100644 index 0000000..e3b33a4 --- /dev/null +++ b/src/GmRelay.Shared/Platform/IPlatformMessenger.cs @@ -0,0 +1,16 @@ +namespace GmRelay.Shared.Platform; + +public interface IPlatformMessenger +{ + Task 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); +} diff --git a/src/GmRelay.Shared/Platform/PlatformGroup.cs b/src/GmRelay.Shared/Platform/PlatformGroup.cs new file mode 100644 index 0000000..e5fda12 --- /dev/null +++ b/src/GmRelay.Shared/Platform/PlatformGroup.cs @@ -0,0 +1,8 @@ +namespace GmRelay.Shared.Platform; + +public sealed record PlatformGroup( + PlatformKind Platform, + string ExternalGroupId, + string DisplayName, + string? ExternalChannelId = null, + string? ExternalThreadId = null); diff --git a/src/GmRelay.Shared/Platform/PlatformKind.cs b/src/GmRelay.Shared/Platform/PlatformKind.cs new file mode 100644 index 0000000..a768c5e --- /dev/null +++ b/src/GmRelay.Shared/Platform/PlatformKind.cs @@ -0,0 +1,8 @@ +namespace GmRelay.Shared.Platform; + +public enum PlatformKind +{ + Telegram = 0, + Discord = 1, + Max = 2 +} diff --git a/src/GmRelay.Shared/Platform/PlatformMessageContracts.cs b/src/GmRelay.Shared/Platform/PlatformMessageContracts.cs new file mode 100644 index 0000000..98bd5ba --- /dev/null +++ b/src/GmRelay.Shared/Platform/PlatformMessageContracts.cs @@ -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 Actions); diff --git a/src/GmRelay.Shared/Platform/PlatformUser.cs b/src/GmRelay.Shared/Platform/PlatformUser.cs new file mode 100644 index 0000000..968a4f4 --- /dev/null +++ b/src/GmRelay.Shared/Platform/PlatformUser.cs @@ -0,0 +1,7 @@ +namespace GmRelay.Shared.Platform; + +public sealed record PlatformUser( + PlatformKind Platform, + string ExternalUserId, + string DisplayName, + string? ExternalUsername); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index cdc0a94..e4c7049 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs new file mode 100644 index 0000000..e3787e0 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs @@ -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 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}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs index 0e33432..7d0a3ed 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -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 ReadRepositoryFileAsync(string relativePath) diff --git a/tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs b/tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs new file mode 100644 index 0000000..673655a --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs @@ -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); + } +}