From 742600093744c22c27b88229c4cbab9186827ba9 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 14:38:27 +0300 Subject: [PATCH 1/3] docs: add issue 31 platform notification design --- ...essenger-scheduler-notifications-design.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md diff --git a/docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md b/docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md new file mode 100644 index 0000000..820d9a7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md @@ -0,0 +1,140 @@ +# Platform Messenger Scheduler Notifications Design + +## Goal + +Issue #31 moves scheduler-driven notifications and reschedule deadline message updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord support instead of no-op placeholders. + +## Scope + +- `SessionSchedulerService` remains the trigger orchestrator, but scheduler handlers stop depending on Telegram API types for outbound notification work. +- Confirmation requests, one-hour reminders, join-link notifications, RSVP follow-up messages, and reschedule deadline updates use platform-neutral contracts. +- Telegram keeps the current user-visible behavior: same message content, RSVP buttons, direct messages, topic/thread targeting, and stored legacy message ids. +- Discord receives full channel and direct notifications: + - confirmation requests are sent to the Discord channel with RSVP buttons; + - Discord RSVP button clicks update participant RSVP state, refresh the confirmation message, and send the same group/GM outcome notifications where applicable; + - one-hour reminders and join-link notifications are sent as Discord DMs when direct notifications are enabled; + - join-link notifications also post the channel message with participant mentions; + - reschedule deadline processing updates Discord vote and schedule messages through the same messenger boundary. +- Discord DM failures are non-fatal: log a warning and continue without posting a public fallback message. + +## Architecture + +The platform boundary should be semantic, not Telegram-shaped. `GmRelay.Shared.Platform` already owns `PlatformKind`, `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `IPlatformMessenger`; issue #31 extends that layer with notification-specific DTOs and messenger methods. + +The scheduler handlers own database queries and notification eligibility. They load platform-neutral groups, users, message refs, and session data, then ask the platform messenger to send or update the platform message. Platform implementations own rendering details: Telegram renders HTML and inline keyboards; Discord renders embeds, components, channel messages, mentions, and DMs. + +RSVP handling should become platform-neutral enough for both Telegram and Discord. The current `HandleRsvpHandler` logic is not duplicated. Its command changes from Telegram ids to `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `InteractionId`. Telegram update routing maps callback queries into that command; Discord component routing maps RSVP button interactions into the same command. + +Reschedule finalization already has shared database logic in `RescheduleVotingFinalizer`. The remaining platform-specific deadline services should stop editing messages through `ITelegramBotClient` or Discord `RestClient` directly. They should load message refs and call `IPlatformMessenger` to update vote messages, schedule messages, and direct result notifications. + +## Platform Contracts + +Add semantic notification records in `GmRelay.Shared.Platform`, with names finalized during implementation planning: + +- `PlatformSessionParticipant`: a `PlatformUser` plus RSVP, registration, and display metadata needed by notification renderers. +- `PlatformSessionNotification`: common session title, time, join link, notification mode, group, optional existing message, and participants. +- `PlatformConfirmationRequest`: confirmation-specific session notification with RSVP actions. +- `PlatformJoinLinkNotification`: join-link group/direct notification data. +- `PlatformOneHourReminder`: one-hour direct reminder data. +- `PlatformRsvpMessageUpdate`: refreshed confirmation message state after a participant responds. +- `PlatformRescheduleVoteUpdate`: finalized reschedule vote message state, including selected option or rejection reason. + +Extend `IPlatformMessenger` with methods for these semantic operations while keeping existing schedule, group, private, interaction, and calendar methods intact for current flows: + +- send and update confirmation request messages; +- send one-hour reminder direct notifications; +- send join-link channel and direct notifications; +- update finalized reschedule vote messages; +- send RSVP outcome messages to the group and GM recipients. + +The exact method names should be chosen in the implementation plan after tests define the desired API, but each method should accept platform-neutral DTOs and return `PlatformMessageRef` when the caller must persist a sent message id. + +## Telegram Behavior + +Telegram implementation lives in `GmRelay.Bot.Infrastructure.Telegram.TelegramPlatformMessenger`. + +It must preserve: + +- `messageThreadId` handling for forum topics; +- HTML parse mode where the existing flow uses HTML; +- current confirmation and RSVP button callback payloads; +- `confirmation_message_id` and `link_message_id` storage in `sessions`; +- direct notification behavior controlled by `SessionNotificationMode`; +- warning-and-continue behavior for failed direct messages; +- existing schedule rendering through `TelegramSessionBatchRenderer` and `BatchMessageEditor`. + +Telegram-specific inbound parsing remains at the Telegram boundary. `UpdateRouter` can still use `Telegram.Bot.Types`, but the command it passes into the RSVP handler should be platform-neutral. + +## Discord Behavior + +Discord implementation lives in `GmRelay.DiscordBot.Infrastructure.Discord.DiscordPlatformMessenger`. + +It must support: + +- channel messages through the configured channel id in `PlatformGroup.ExternalChannelId`; +- interactive RSVP buttons routed by `DiscordSessionInteractionModule`; +- ephemeral interaction replies via the existing `DiscordInteractionReplyCache` pattern; +- DMs through Discord user ids in `PlatformUser.ExternalUserId`; +- non-fatal DM failures with warning logs; +- Discord-friendly rendering, not raw Telegram HTML; +- persistence of Discord schedule and notification message refs in `platform_messages` where later updates need them. + +The current Discord reschedule deadline service directly uses `RestClient` for vote and schedule message edits. This should be folded into `DiscordPlatformMessenger` so deadline services and future platform handlers do not need to know Discord API details. + +## Data Flow + +1. `SessionSchedulerService.TickAsync` asks `ISessionTriggerStore` for due confirmation, one-hour reminder, and join-link session ids. +2. Each handler loads the session, group platform identity, message refs, participants, RSVP state, and notification mode. +3. The handler builds a semantic platform notification DTO and calls `IPlatformMessenger`. +4. The messenger renders and sends/updates platform messages. +5. The handler persists sent message ids where required, using legacy `sessions.confirmation_message_id` and `sessions.link_message_id` for Telegram and `platform_messages` for Discord refs that need later updates. +6. Telegram callback queries and Discord component interactions both call the same platform-neutral RSVP handler. +7. Reschedule deadline services use `RescheduleVotingFinalizer`, then call `IPlatformMessenger` for vote message updates, schedule updates, and direct result notifications. + +## Error Handling + +- A failed trigger query still logs and lets the scheduler continue to the next trigger category. +- A failed send/update for one session logs and does not stop other sessions in the same tick. +- DM failures are warning-level and non-fatal for Telegram and Discord. +- A missing platform message ref logs a warning and skips only the update that needs the ref. +- Unsupported platform values throw at the messenger boundary, not inside scheduler orchestration. +- If Discord cannot parse a stored channel, message, or user id, it logs the bad external id and skips that platform send/update. + +## Testing + +Use TDD for implementation. + +Focused tests should cover: + +- `IPlatformMessenger` exposes semantic notification methods without referencing Telegram or Discord assemblies from `GmRelay.Shared`. +- `SendConfirmationHandler`, `SendOneHourReminderHandler`, `SendJoinLinkHandler`, `HandleRsvpHandler`, and reschedule deadline services do not call `ITelegramBotClient`, `BatchMessageEditor`, or Discord `RestClient` directly for notification output. +- Telegram source/regression tests preserve thread ids, callback payloads, message id persistence, and direct notification mode behavior. +- Discord source tests verify registration of scheduler handlers, RSVP component routes, and messenger methods. +- RSVP flow tests run through platform-neutral `PlatformUser` identity, including Discord users without Telegram ids. +- Discord messenger tests verify DMs are attempted, DM failures are swallowed after logging, channel notifications include buttons or mentions as appropriate, and message refs are returned. +- Full regression: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`, `dotnet build`, and `dotnet format --verify-no-changes --verbosity diagnostic`. + +## Versioning + +Current repository version is `2.6.0`. Although the Gitea issue is labeled `type:refactor`, the approved scope adds full Discord notification behavior. Proposed bump: `2.6.0` to `2.7.0`. + +Synchronize: + +- `Directory.Build.props` +- `compose.yaml` image tags for bot, discord, and web +- `.gitea/workflows/deploy.yml` `VERSION` +- `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +## Out Of Scope + +- Moving the entire scheduler hosted service into `GmRelay.Shared`. +- Removing legacy Telegram columns such as `telegram_chat_id`, `confirmation_message_id`, or `link_message_id`. +- Reworking Web dashboard Telegram behavior. +- Public fallback messages when a Discord DM is blocked. + +## Self-Review + +- Spec coverage: every issue acceptance criterion is represented by scheduler handler boundaries, messenger contracts, Telegram behavior preservation, and Discord implementation requirements. +- Placeholder scan: no TBD/TODO/fill-in-later sections remain. +- Internal consistency: the design uses semantic platform DTOs consistently and keeps SDK-specific rendering in platform implementations. +- Scope check: the work is large but still one coherent platform-notification refactor; moving the whole scheduler to shared remains explicitly out of scope. From 5dbec1a0a4ce537415df49fdc7044744113d76a8 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 14:53:41 +0300 Subject: [PATCH 2/3] docs: add issue 31 implementation plan --- ...tform-messenger-scheduler-notifications.md | 1144 +++++++++++++++++ 1 file changed, 1144 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md diff --git a/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md b/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md new file mode 100644 index 0000000..46306b7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md @@ -0,0 +1,1144 @@ +# Platform Messenger Scheduler Notifications 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:** Resolve Gitea issue #31 by moving scheduler notifications and reschedule deadline updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord notification support. + +**Architecture:** Move scheduler orchestration and scheduler notification handlers into platform-neutral `GmRelay.Shared` code. Add semantic notification DTOs and messenger methods in `GmRelay.Shared.Platform`; Telegram and Discord implementations render/send platform-specific messages. Register the shared scheduler in both workers with a platform filter so Telegram and Discord do not process each other's sessions. + +**Tech Stack:** .NET 10, C# preview, Npgsql, Dapper.AOT, xUnit, Telegram.Bot only in `GmRelay.Bot`, NetCord only in `GmRelay.DiscordBot`, platform-neutral scheduler and contracts in `GmRelay.Shared`. + +--- + +## Issue Context + +- Issue: #31, `refactor: перевести scheduler и уведомления на IPlatformMessenger` +- Approved design: `docs/superpowers/specs/2026-05-20-platform-messenger-scheduler-notifications-design.md` +- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor` +- User clarification: Discord support must be full, not no-op MVP. Discord DM failures are normal; log and continue without a public fallback message. + +## Version Bump + +Current version: `2.6.0`. + +The issue label is `type:refactor`, but the approved scope adds user-visible Discord scheduler notifications. Bump minor: `2.6.0` -> `2.7.0`. + +Synchronize: + +- `Directory.Build.props` +- `compose.yaml` image tags for `bot`, `discord`, and `web` +- `.gitea/workflows/deploy.yml` `VERSION` +- `src/GmRelay.Web/Components/Layout/NavMenu.razor` +- tests that assert synchronized release versions + +## File Structure + +- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs` +- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs` +- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs` +- Create: `src/GmRelay.Shared/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs` +- Create: `src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs` +- Create: `src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs` +- Create: `src/GmRelay.Shared/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs` +- Create: `src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs` +- Create: `src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs` +- Create: `src/GmRelay.Shared/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs` +- Create: `src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs` +- Create: `src/GmRelay.Shared/Features/Notifications/PlatformDirectNotificationSender.cs` +- Modify: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` +- Modify: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` +- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` +- Modify: `src/GmRelay.Shared/packages.lock.json` +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` +- Modify: `src/GmRelay.Bot/Program.cs` +- Delete or leave as forwarding stubs: old scheduler/notification files under `src/GmRelay.Bot/Infrastructure/Scheduling`, `src/GmRelay.Bot/Features/Confirmation`, and `src/GmRelay.Bot/Features/Reminders` +- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` +- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` +- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` +- Modify: `src/GmRelay.DiscordBot/Program.cs` +- Modify: targeted tests under `tests/GmRelay.Bot.Tests/Platform`, `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling`, `tests/GmRelay.Bot.Tests/Infrastructure/Telegram`, `tests/GmRelay.Bot.Tests/Discord`, and `tests/GmRelay.Bot.Tests/Features/Confirmation` + +--- + +## Task 1: RED - Shared Notification Contract Tests + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs` + +- [ ] **Step 1: Add failing tests for semantic notification contracts** + +Append tests like this: + +```csharp +[Fact] +public void PlatformNotificationContracts_ShouldBeSdkAssemblyFree() +{ + var contractTypes = new[] + { + typeof(PlatformSessionParticipant), + typeof(PlatformConfirmationRequest), + typeof(PlatformJoinLinkNotification), + typeof(PlatformDirectSessionNotification), + typeof(PlatformRsvpMessageUpdate), + typeof(PlatformRsvpOutcomeNotification), + typeof(PlatformRescheduleVoteUpdate) + }; + + Assert.All(contractTypes, type => + { + var refs = string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)); + Assert.DoesNotContain("Telegram", refs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("NetCord", refs, StringComparison.OrdinalIgnoreCase); + }); +} + +[Fact] +public void PlatformMessenger_ShouldExposeSchedulerNotificationOperations() +{ + var methods = typeof(IPlatformMessenger) + .GetMethods() + .Select(method => method.Name) + .ToHashSet(StringComparer.Ordinal); + + Assert.Contains("SendConfirmationRequestAsync", methods); + Assert.Contains("UpdateConfirmationRequestAsync", methods); + Assert.Contains("SendJoinLinkNotificationAsync", methods); + Assert.Contains("SendDirectSessionNotificationAsync", methods); + Assert.Contains("SendRsvpOutcomeAsync", methods); + Assert.Contains("UpdateRescheduleVoteAsync", methods); +} +``` + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests +``` + +Expected: compile failure because the new platform notification types and messenger methods do not exist. + +## Task 2: GREEN - Add Platform Notification Contracts + +**Files:** +- Modify: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs` +- Modify: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` + +- [ ] **Step 1: Add notification DTOs** + +Add these records to `PlatformMessageContracts.cs`: + +```csharp +using GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed record PlatformSessionParticipant( + PlatformUser User, + string RsvpStatus, + string RegistrationStatus, + bool IsGm = false); + +public sealed record PlatformConfirmationRequest( + PlatformGroup Group, + Guid SessionId, + string Title, + DateTime ScheduledAt, + IReadOnlyList Participants, + PlatformMessageRef? ExistingMessage = null); + +public sealed record PlatformJoinLinkNotification( + PlatformGroup Group, + Guid SessionId, + string Title, + DateTime ScheduledAt, + string JoinLink, + IReadOnlyList ConfirmedPlayers, + PlatformMessageRef? ExistingMessage = null); + +public enum PlatformDirectSessionNotificationKind +{ + ConfirmationRequest = 0, + OneHourReminder = 1, + JoinLink = 2, + RsvpAllConfirmed = 3, + RsvpDeclined = 4, + RescheduleApproved = 5, + RescheduleRejected = 6 +} + +public sealed record PlatformDirectSessionNotification( + PlatformDirectSessionNotificationKind Kind, + PlatformUser Recipient, + Guid SessionId, + string Title, + DateTime ScheduledAt, + string? JoinLink = null, + string? ActorDisplayName = null, + string? Reason = null); + +public sealed record PlatformRsvpMessageUpdate( + PlatformConfirmationRequest Request, + bool DisableActions); + +public enum PlatformRsvpOutcomeKind +{ + GroupAllConfirmed = 0, + GmAllConfirmed = 1, + GmPlayerDeclined = 2 +} + +public sealed record PlatformRsvpOutcomeNotification( + PlatformRsvpOutcomeKind Kind, + PlatformGroup? Group, + IReadOnlyList Recipients, + Guid SessionId, + string Title, + DateTime ScheduledAt, + string? ActorDisplayName = null); + +public sealed record PlatformRescheduleVoteUpdate( + PlatformGroup Group, + PlatformMessageRef ExistingMessage, + Guid ProposalId, + Guid SessionId, + string Title, + DateTime CurrentScheduledAt, + DateTimeOffset VotingDeadlineAt, + RescheduleVoteDecision Decision, + RescheduleOptionDto? SelectedOption, + IReadOnlyList Options, + IReadOnlyList Votes, + IReadOnlyList Participants); +``` + +- [ ] **Step 2: Extend `IPlatformMessenger`** + +Add: + +```csharp +Task SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct); + +Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct); + +Task SendJoinLinkNotificationAsync(PlatformJoinLinkNotification notification, CancellationToken ct); + +Task SendDirectSessionNotificationAsync(PlatformDirectSessionNotification notification, CancellationToken ct); + +Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct); + +Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct); +``` + +- [ ] **Step 3: Run GREEN** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests +``` + +Expected: contract tests pass once implementations compile. Initial compile errors in messenger implementations are expected until Tasks 5 and 6 add method bodies. + +## Task 3: RED - Shared Scheduler And Platform Filter Tests + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionTriggerStoreSourceTests.cs` + +- [ ] **Step 1: Update scheduler tests to target shared namespace** + +Change usings from bot scheduling/handler namespaces to: + +```csharp +using GmRelay.Shared.Features.Confirmation.SendConfirmation; +using GmRelay.Shared.Features.Reminders.SendJoinLink; +using GmRelay.Shared.Features.Reminders.SendOneHourReminder; +using GmRelay.Shared.Infrastructure.Scheduling; +``` + +- [ ] **Step 2: Add a source test proving trigger queries filter by platform** + +Create: + +```csharp +namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; + +public sealed class SessionTriggerStoreSourceTests +{ + [Fact] + public async Task DbSessionTriggerStore_ShouldFilterTriggersByConfiguredPlatform() + { + var source = await ReadRepositoryFileAsync( + "src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs"); + + Assert.Contains("PlatformSchedulerOptions", source, StringComparison.Ordinal); + Assert.Contains("JOIN game_groups g ON g.id = s.group_id", source, StringComparison.Ordinal); + Assert.Contains("g.platform = @Platform", 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 3: Run RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "SessionSchedulerServiceTests|SessionTriggerStoreSourceTests" +``` + +Expected: compile/source-test failure because scheduler and trigger store are still under `GmRelay.Bot` and trigger queries do not filter by platform. + +## Task 4: GREEN - Move Scheduler Infrastructure To Shared + +**Files:** +- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs` +- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs` +- Create: `src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs` +- Delete or convert forwarding stubs: `src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs` +- Delete or convert forwarding stubs: `src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs` +- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` +- Modify: `src/GmRelay.Shared/packages.lock.json` + +- [ ] **Step 1: Add hosting abstractions to Shared** + +Add to `src/GmRelay.Shared/GmRelay.Shared.csproj`: + +```xml + +``` + +Run: + +```powershell +dotnet restore src/GmRelay.Shared/GmRelay.Shared.csproj +``` + +Expected: `src/GmRelay.Shared/packages.lock.json` is updated. + +- [ ] **Step 2: Add platform scheduler options** + +```csharp +namespace GmRelay.Shared.Infrastructure.Scheduling; + +using GmRelay.Shared.Platform; + +public sealed record PlatformSchedulerOptions(PlatformKind Platform); +``` + +- [ ] **Step 3: Move `ISessionTriggerStore` and `DbSessionTriggerStore`** + +Move the existing code into `src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs`, update namespace, inject `PlatformSchedulerOptions`, and update each query to join groups: + +```sql +FROM sessions s +JOIN game_groups g ON g.id = s.group_id +WHERE g.platform = @Platform +``` + +Pass: + +```csharp +new +{ + Platform = options.Platform.ToString(), + Planned = SessionStatus.Planned, + LeadTime = ConfirmationLeadTime, + Now = now.UtcDateTime +} +``` + +Apply the same platform filter to confirmation, one-hour reminder, and join-link queries. + +- [ ] **Step 4: Move `SessionSchedulerService`** + +Move the existing class into `src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs`, update namespace/usings, and keep the public `TickAsync` method unchanged. + +- [ ] **Step 5: Run GREEN** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "SessionSchedulerServiceTests|SessionTriggerStoreSourceTests" +``` + +Expected: tests pass. + +## Task 5: RED - Shared Notification Handler Boundary Tests + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SchedulerNotificationSourceTests.cs` + +- [ ] **Step 1: Add source tests that fail until handlers move to Shared and stop using SDK clients** + +```csharp +namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; + +public sealed class SchedulerNotificationSourceTests +{ + [Theory] + [InlineData("src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs")] + [InlineData("src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs")] + [InlineData("src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs")] + [InlineData("src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs")] + public async Task SchedulerNotificationHandlers_ShouldUsePlatformMessengerWithoutSdkClients(string relativePath) + { + var source = await ReadRepositoryFileAsync(relativePath); + + Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); + Assert.DoesNotContain("Telegram.Bot", source, StringComparison.Ordinal); + Assert.DoesNotContain("ITelegramBotClient", source, StringComparison.Ordinal); + Assert.DoesNotContain("NetCord", source, StringComparison.Ordinal); + Assert.DoesNotContain("RestClient", source, StringComparison.Ordinal); + } + + [Fact] + public async Task DiscordProgram_ShouldRegisterSharedSchedulerForDiscordPlatform() + { + var program = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Program.cs"); + + Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program, StringComparison.Ordinal); + Assert.Contains("AddHostedService", program, StringComparison.Ordinal); + Assert.Contains("DbSessionTriggerStore", program, StringComparison.Ordinal); + Assert.Contains("SendConfirmationHandler", program, StringComparison.Ordinal); + Assert.Contains("SendJoinLinkHandler", program, StringComparison.Ordinal); + Assert.Contains("SendOneHourReminderHandler", program, 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 RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter SchedulerNotificationSourceTests +``` + +Expected: failures because files are not in Shared and Discord Program does not register scheduler notification handlers. + +## Task 6: GREEN - Move And Refactor Scheduler Notification Handlers + +**Files:** +- Create shared handler files listed in File Structure +- Modify: `src/GmRelay.Bot/Program.cs` +- Modify: `src/GmRelay.DiscordBot/Program.cs` +- Delete or convert old Bot handler files to prevent duplicate type names + +- [ ] **Step 1: Move interfaces and handlers to Shared namespaces** + +Use these namespaces: + +```csharp +namespace GmRelay.Shared.Features.Confirmation.SendConfirmation; +namespace GmRelay.Shared.Features.Confirmation.HandleRsvp; +namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder; +namespace GmRelay.Shared.Features.Reminders.SendJoinLink; +namespace GmRelay.Shared.Features.Notifications; +``` + +- [ ] **Step 2: Replace Telegram DTOs with platform-neutral rows** + +For session/group query DTOs use fields like: + +```csharp +internal sealed record SchedulerSessionRow( + Guid Id, + string Title, + DateTime ScheduledAt, + Guid GroupId, + string Platform, + string ExternalGroupId, + string? ExternalChannelId, + int? ThreadId, + string NotificationMode, + string? ExistingMessageId, + long? LegacyTelegramChatId); +``` + +Create `PlatformGroup` from row: + +```csharp +private static PlatformGroup CreateGroup(SchedulerSessionRow row) => + new( + Enum.Parse(row.Platform), + row.ExternalGroupId, + row.ExternalGroupId, + row.ExternalChannelId, + row.ThreadId?.ToString(CultureInfo.InvariantCulture)); +``` + +For Telegram back-compat, SQL should select: + +```sql +COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId, +COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId +``` + +- [ ] **Step 3: Build `PlatformSessionParticipant` from players** + +Use platform identity first and Telegram fallback second: + +```sql +COALESCE(p.platform, 'Telegram') AS Platform, +COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, +COALESCE(p.external_username, p.telegram_username) AS ExternalUsername +``` + +Map rows to: + +```csharp +new PlatformSessionParticipant( + new PlatformUser( + Enum.Parse(row.Platform), + row.ExternalUserId, + row.DisplayName, + row.ExternalUsername), + row.RsvpStatus, + row.RegistrationStatus, + row.IsGm); +``` + +- [ ] **Step 4: Refactor `SendConfirmationHandler`** + +Replace direct `ITelegramBotClient.SendMessage` with: + +```csharp +var message = await messenger.SendConfirmationRequestAsync( + new PlatformConfirmationRequest( + group, + session.Id, + session.Title, + session.ScheduledAt, + participants), + ct); +``` + +Persist Telegram legacy `confirmation_message_id` only when `message.Platform == PlatformKind.Telegram`: + +```csharp +MessageId = message.Platform == PlatformKind.Telegram + ? int.Parse(message.ExternalMessageId, CultureInfo.InvariantCulture) + : null +``` + +For Discord, insert/update `platform_messages` with purpose `confirmation` and `external_message_id`. + +- [ ] **Step 5: Refactor `SendOneHourReminderHandler`** + +Replace direct sender with: + +```csharp +await directSender.SendAsync( + PlatformDirectSessionNotificationKind.OneHourReminder, + recipients, + session.Id, + session.Title, + session.ScheduledAt, + session.JoinLink, + actorDisplayName: null, + reason: null, + ct); +``` + +- [ ] **Step 6: Refactor `SendJoinLinkHandler`** + +Replace group send with: + +```csharp +var message = await messenger.SendJoinLinkNotificationAsync( + new PlatformJoinLinkNotification( + group, + session.Id, + session.Title, + session.ScheduledAt, + session.JoinLink, + confirmedPlayers), + ct); +``` + +Persist `link_message_id` for Telegram and `platform_messages` purpose `join_link` for Discord. + +- [ ] **Step 7: Refactor `HandleRsvpHandler`** + +Change command to: + +```csharp +public sealed record HandleRsvpCommand( + Guid SessionId, + PlatformUser User, + string Status, + string InteractionId, + PlatformGroup Group, + PlatformMessageRef ConfirmationMessage); +``` + +Query participants by platform identity: + +```sql +AND p.platform = @Platform +AND p.external_user_id = @ExternalUserId +``` + +Replace callback answers and messages with: + +```csharp +await messenger.AnswerInteractionAsync(new PlatformInteractionReply(command.InteractionId, text), ct); +await messenger.UpdateConfirmationRequestAsync(new PlatformRsvpMessageUpdate(request, disableActions), ct); +await messenger.SendRsvpOutcomeAsync(outcome, ct); +``` + +- [ ] **Step 8: Register shared services in both workers** + +Telegram Program: + +```csharp +builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +``` + +Discord Program uses the same registrations with `PlatformKind.Discord`. + +- [ ] **Step 9: Run GREEN** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "SchedulerNotificationSourceTests|SessionSchedulerServiceTests|SessionTriggerStoreSourceTests|RsvpFlowRulesTests" +``` + +Expected: tests pass. + +## Task 7: RED - Telegram Messenger Regression Tests + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` +- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs` + +- [ ] **Step 1: Add source assertions for new Telegram notification methods** + +Assert `TelegramPlatformMessenger.cs` contains: + +```csharp +Assert.Contains("SendConfirmationRequestAsync", source, StringComparison.Ordinal); +Assert.Contains("UpdateConfirmationRequestAsync", source, StringComparison.Ordinal); +Assert.Contains("SendJoinLinkNotificationAsync", source, StringComparison.Ordinal); +Assert.Contains("SendDirectSessionNotificationAsync", source, StringComparison.Ordinal); +Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); +Assert.Contains("messageThreadId", source, StringComparison.Ordinal); +Assert.Contains("ParseMode.Html", source, StringComparison.Ordinal); +Assert.Contains("InlineKeyboardButton.WithCallbackData", source, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Update topic smoke tests** + +Expected strings should move from handlers to shared DTO construction and messenger usage. Keep assertions for: + +```csharp +Assert.Contains("ExternalThreadId", confirmationHandler, StringComparison.Ordinal); +Assert.Contains("ThreadId", joinLinkHandler, StringComparison.Ordinal); +Assert.Contains("UpdateConfirmationRequestAsync", rsvpHandler, StringComparison.Ordinal); +Assert.Contains("messageThreadId", telegramMessenger, StringComparison.Ordinal); +``` + +- [ ] **Step 3: Run RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|TelegramTopicIntegrationSmokeTests" +``` + +Expected: failures until Telegram messenger implements new semantic methods and old tests are updated to the new boundary. + +## Task 8: GREEN - Implement Telegram Semantic Notification Methods + +**Files:** +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` + +- [ ] **Step 1: Add Telegram rendering helpers inside `TelegramPlatformMessenger`** + +Use private helpers equivalent to existing handler text builders: + +```csharp +private static string FormatTelegramParticipant(PlatformSessionParticipant participant) => + participant.User.ExternalUsername is not null + ? $"@{participant.User.ExternalUsername}" + : participant.User.DisplayName; +``` + +Build confirmation text, join-link text, direct notification HTML, and RSVP outcome text with the same content currently in handlers. + +- [ ] **Step 2: Implement confirmation send/update** + +`SendConfirmationRequestAsync` calls `bot.SendMessage` with `messageThreadId`, `ParseMode.Html`, and RSVP buttons: + +```csharp +InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{request.SessionId}") +InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{request.SessionId}") +``` + +`UpdateConfirmationRequestAsync` calls `bot.EditMessageText` and removes reply markup when `DisableActions` is true. + +- [ ] **Step 3: Implement join-link and direct methods** + +`SendJoinLinkNotificationAsync` uses `bot.SendMessage` to the group/thread and returns `TelegramPlatformIds.Message`. + +`SendDirectSessionNotificationAsync` uses `bot.SendMessage` to `Recipient.ExternalUserId` with `ParseMode.Html`. + +`SendRsvpOutcomeAsync` sends group or direct messages based on `PlatformRsvpOutcomeNotification.Kind`. + +- [ ] **Step 4: Map Telegram callback queries to platform-neutral RSVP commands** + +In `UpdateRouter`, build: + +```csharp +new HandleRsvpCommand( + parsedSessionId, + TelegramPlatformIds.User(callback.From.Id, callback.From.FirstName, callback.From.Username), + status, + callback.Id, + TelegramPlatformIds.Group(callback.Message.Chat.Id, callback.Message.MessageThreadId), + TelegramPlatformIds.Message(callback.Message.Chat.Id, callback.Message.MessageThreadId, callback.Message.MessageId)) +``` + +- [ ] **Step 5: Run GREEN** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|TelegramTopicIntegrationSmokeTests" +``` + +Expected: pass. + +## Task 9: RED - Discord Messenger And Scheduler Wiring Tests + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs` +- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs` +- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` + +- [ ] **Step 1: Add source assertions for Discord messenger notification support** + +```csharp +Assert.Contains("SendConfirmationRequestAsync", source, StringComparison.Ordinal); +Assert.Contains("UpdateConfirmationRequestAsync", source, StringComparison.Ordinal); +Assert.Contains("SendJoinLinkNotificationAsync", source, StringComparison.Ordinal); +Assert.Contains("SendDirectSessionNotificationAsync", source, StringComparison.Ordinal); +Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); +Assert.Contains("DiscordSessionBatchRenderer", source, StringComparison.Ordinal); +Assert.Contains("DiscordRescheduleVotingRenderer", source, StringComparison.Ordinal); +Assert.Contains("GetDMChannelAsync", source, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Add RSVP route assertions** + +In `DiscordSessionInteractionModuleSourceTests`, assert: + +```csharp +Assert.Contains("[ComponentInteraction(\"rsvp\")", source, StringComparison.Ordinal); +Assert.Contains("HandleRsvpHandler", source, StringComparison.Ordinal); +Assert.Contains("PlatformKind.Discord", source, StringComparison.Ordinal); +``` + +- [ ] **Step 3: Add startup assertions** + +In `DiscordStartupTests`, assert: + +```csharp +Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program); +Assert.Contains("AddHostedService", program); +Assert.Contains("HandleRsvpHandler", program); +``` + +- [ ] **Step 4: Run RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "DiscordPlatformMessengerTests|DiscordSessionInteractionModuleSourceTests|DiscordStartupTests" +``` + +Expected: failures until Discord messenger and Program are updated. + +## Task 10: GREEN - Implement Discord Semantic Notification Methods And RSVP Route + +**Files:** +- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` +- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` +- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionMapper.cs` +- Modify: `src/GmRelay.DiscordBot/Program.cs` + +- [ ] **Step 1: Implement channel confirmation messages** + +Use `RestClient.SendMessageAsync(channelId, new MessageProperties().WithEmbeds(...).WithComponents(...))`. + +Buttons use NetCord custom ids that route to one component handler: + +```csharp +new ButtonProperties($"rsvp:confirm:{request.SessionId}", "Буду", ButtonStyle.Success) +new ButtonProperties($"rsvp:decline:{request.SessionId}", "Не смогу", ButtonStyle.Danger) +``` + +Return: + +```csharp +new PlatformMessageRef(PlatformKind.Discord, request.Group.ExternalGroupId, null, sentMessage.Id.ToString(CultureInfo.InvariantCulture)); +``` + +- [ ] **Step 2: Implement confirmation updates** + +Render an updated embed listing confirmed, declined, and pending participants. If `DisableActions` is true, set no components or disabled buttons. + +Use: + +```csharp +await restClient.ModifyMessageAsync(channelId, messageId, options => +{ + options.Embeds = new[] { embed }; + options.Components = disabledOrActiveRows; +}); +``` + +- [ ] **Step 3: Implement join-link notifications** + +Send a channel message containing title, join link, and Discord mentions: + +```csharp +var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(p => $"<@{p.User.ExternalUserId}>")); +``` + +- [ ] **Step 4: Implement direct Discord notifications** + +Use NetCord DM channel creation/opening before sending: + +```csharp +var userId = ulong.Parse(notification.Recipient.ExternalUserId, CultureInfo.InvariantCulture); +var dm = await restClient.GetDMChannelAsync(new DMChannelProperties(userId)); +await restClient.SendMessageAsync(dm.Id, BuildDirectContent(notification)); +``` + +Wrap this method in try/catch at call sites or inside the method so blocked DMs log a warning and do not throw back into scheduler processing. + +- [ ] **Step 5: Implement RSVP route** + +Add component route: + +```csharp +[ComponentInteraction("rsvp")] +public async Task RsvpAsync(string status, string sessionId) +``` + +Map Discord context to `HandleRsvpCommand` with `PlatformKind.Discord`, `Context.User.Id`, guild id, channel id, and message id. Defer ephemerally, invoke `HandleRsvpHandler`, then complete from `DiscordInteractionReplyCache`. + +- [ ] **Step 6: Register shared scheduler services in Discord Program** + +Add: + +```csharp +builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +``` + +- [ ] **Step 7: Run GREEN** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "DiscordPlatformMessengerTests|DiscordSessionInteractionModuleSourceTests|DiscordStartupTests|SchedulerNotificationSourceTests" +``` + +Expected: pass. + +## Task 11: RED - Reschedule Deadline Boundary Tests + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleDeadlineBoundaryTests.cs` + +- [ ] **Step 1: Assert deadline services no longer edit messages directly** + +Add source assertions: + +```csharp +Assert.DoesNotContain("ITelegramBotClient", telegramDeadlineService, StringComparison.Ordinal); +Assert.DoesNotContain(".EditMessageText(", telegramDeadlineService, StringComparison.Ordinal); +Assert.Contains("UpdateRescheduleVoteAsync", telegramDeadlineService, StringComparison.Ordinal); +Assert.Contains("IPlatformMessenger", telegramDeadlineService, StringComparison.Ordinal); +``` + +For Discord: + +```csharp +Assert.DoesNotContain("RestClient", source, StringComparison.Ordinal); +Assert.DoesNotContain("ModifyMessageAsync", source, StringComparison.Ordinal); +Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); +Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|DiscordRescheduleDeadlineBoundaryTests" +``` + +Expected: failures until deadline services call the platform messenger. + +## Task 12: GREEN - Move Reschedule Deadline Message Updates Behind Messenger + +**Files:** +- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs` +- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` +- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` + +- [ ] **Step 1: Telegram deadline service** + +Remove `ITelegramBotClient` from constructor. After finalizer returns, load the existing vote message ref and call: + +```csharp +await messenger.UpdateRescheduleVoteAsync(update, ct); +``` + +Keep schedule message update through existing `UpdateScheduleAsync`. Keep direct result notifications through `SendDirectSessionNotificationAsync`. + +- [ ] **Step 2: Discord deadline service** + +Remove `RestClient` from constructor. For vote message updates, load `platform_messages` purpose `reschedule_vote`, build `PlatformRescheduleVoteUpdate`, and call `UpdateRescheduleVoteAsync`. + +For approved schedule changes, keep the existing batch lookup but call: + +```csharp +await messenger.UpdateScheduleAsync(new PlatformScheduleMessage(group, view, existingMessage), ct); +``` + +- [ ] **Step 3: Messenger implementations** + +Telegram `UpdateRescheduleVoteAsync` uses the existing `HandleRescheduleTimeInputHandler.BuildVotingMessage(...)` text and appends the final result text, then calls `bot.EditMessageText`. + +Discord `UpdateRescheduleVoteAsync` uses `DiscordRescheduleVotingRenderer.Render(...)`, appends result text to the embed description, disables buttons, and calls `restClient.ModifyMessageAsync`. + +- [ ] **Step 4: Run GREEN** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "TelegramPlatformMessengerSourceTests|DiscordRescheduleDeadlineBoundaryTests|FullyQualifiedName~Reschedule" +``` + +Expected: pass. + +## Task 13: Version, Docs, And Source Assertion Updates + +**Files:** +- Modify: `Directory.Build.props` +- Modify: `compose.yaml` +- Modify: `.gitea/workflows/deploy.yml` +- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` +- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` +- Review and update if necessary: `README.md` +- Review and update if necessary: `docs/adr/002-platform-neutral-batch-rendering.md` + +- [ ] **Step 1: Bump version to 2.7.0** + +Update all version locations from `2.6.0` to `2.7.0`. + +- [ ] **Step 2: Update version assertions** + +Change `DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease` expected strings to `2.7.0`. + +- [ ] **Step 3: Review documentation** + +Run: + +```powershell +rg -n "scheduler|notification|IPlatformMessenger|Discord|Telegram|2\\.6\\.0|v2\\.6\\.0" README.md docs +``` + +Update stale statements that say scheduler notifications are Telegram-only or that Discord notifications are no-op. + +- [ ] **Step 4: Verify version sync** + +Run: + +```powershell +rg -n "2\\.6\\.0|2\\.7\\.0" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +``` + +Expected: no `2.6.0` in version-controlled version assertions or deployment files; `2.7.0` appears in every required file. + +## Task 14: Full Verification + +**Files:** no planned code edits unless verification exposes issues. + +- [ ] **Step 1: Restore** + +Run: + +```powershell +dotnet restore +``` + +Expected: exit code 0. + +- [ ] **Step 2: Format** + +Run: + +```powershell +dotnet format --verify-no-changes --verbosity diagnostic +``` + +Expected: exit code 0. If formatting fails, run `dotnet format`, inspect diff, and rerun verify. + +- [ ] **Step 3: Build** + +Run: + +```powershell +dotnet build +``` + +Expected: `Build succeeded` with warnings treated as errors. + +- [ ] **Step 4: Tests** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal +``` + +Expected: all tests pass. + +## Task 15: Commit, PR, CI, Review, Merge, Deploy, Release + +**Files:** stage only files changed for issue #31. Do not stage `pr87.diff`. + +- [ ] **Step 1: Inspect diff** + +Run: + +```powershell +git status --short +git diff --stat +``` + +Expected: only issue #31 implementation, tests, docs, version files, and lock files are changed; `pr87.diff` remains untracked and unstaged. + +- [ ] **Step 2: Commit** + +Use a conventional commit: + +```powershell +git add +git commit -m "feat(platform): route scheduler notifications through platform messenger" +``` + +- [ ] **Step 3: Push branch** + +Run: + +```powershell +git push -u origin codex/issue-31-platform-messenger-scheduler +``` + +- [ ] **Step 4: Create Gitea PR** + +Create PR from `codex/issue-31-platform-messenger-scheduler` to `main` with: + +```markdown +## Summary +- Moves scheduler notifications and RSVP handling behind platform-neutral messenger contracts. +- Preserves Telegram notification behavior. +- Adds full Discord scheduler notifications, RSVP buttons, DMs, join-link notifications, and reschedule deadline updates. +- Bumps version to 2.7.0. + +## Test plan +- dotnet restore +- dotnet format --verify-no-changes --verbosity diagnostic +- dotnet build +- dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal + +Closes #31. +``` + +- [ ] **Step 5: CI and review** + +Watch PR checks. If CI fails, read job logs, fix on the same branch, rerun verification, and push. + +- [ ] **Step 6: Merge, deploy, release** + +After CI and review pass, merge the PR, monitor deploy workflow, create release `v2.7.0` with Russian release notes, and close issue #31 with links to PR and release. + +--- + +## Self-Review + +- Spec coverage: scheduler trigger platform filtering, shared scheduler handlers, Telegram preservation, full Discord notifications, RSVP buttons, DM failure behavior, reschedule deadline updates, tests, version bump, and Gitea workflow are covered. +- Placeholder scan: no TBD/TODO/fill-later placeholders remain. +- Type consistency: all planned contracts live under `GmRelay.Shared.Platform`; scheduler infrastructure lives under `GmRelay.Shared.Infrastructure.Scheduling`; Telegram/Discord SDK usage stays in platform implementations. +- Scope check: full scheduler move is included because DiscordBot cannot reference `GmRelay.Bot` without violating the existing no-Telegram-coupling tests. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-20-platform-messenger-scheduler-notifications.md`. + +Two execution options: + +1. Subagent-Driven (recommended): implement task groups with isolated workers and review between groups. +2. Inline Execution: execute this plan in the current session with TDD checkpoints. + +Because this environment only allows subagents when explicitly requested, use Inline Execution unless the user explicitly asks for subagents. From 2a707e482595665804ba9c58dcb3b8744e2fece2 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 21 May 2026 12:30:35 +0300 Subject: [PATCH 3/3] feat(platform): route scheduler notifications through platform messenger --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 11 +- compose.yaml | 6 +- .../002-platform-neutral-batch-rendering.md | 7 +- docs/c4-system-context.md | 30 +- .../HandleRsvp/HandleRsvpHandler.cs | 318 ---------------- .../SendConfirmationHandler.cs | 154 -------- .../SendJoinLink/SendJoinLinkHandler.cs | 134 ------- .../RescheduleVotingDeadlineService.cs | 72 ++-- .../Telegram/TelegramPlatformMessenger.cs | 259 +++++++++++++ .../Infrastructure/Telegram/UpdateRouter.cs | 10 +- src/GmRelay.Bot/Program.cs | 17 +- src/GmRelay.Bot/packages.lock.json | 1 + .../DiscordRescheduleVotingDeadlineService.cs | 121 +++--- .../DiscordSessionInteractionModule.cs | 70 +++- .../Discord/DiscordPlatformMessenger.cs | 328 +++++++++++++++- src/GmRelay.DiscordBot/Program.cs | 17 + src/GmRelay.DiscordBot/packages.lock.json | 1 + .../HandleRsvp/HandleRsvpHandler.cs | 356 ++++++++++++++++++ .../Confirmation/HandleRsvp/RsvpFlowRules.cs | 10 +- .../ISendConfirmationHandler.cs | 2 +- .../SendConfirmationHandler.cs | 217 +++++++++++ .../PlatformDirectNotificationSender.cs | 50 +++ .../SendJoinLink/ISendJoinLinkHandler.cs | 2 +- .../SendJoinLink/SendJoinLinkHandler.cs | 228 +++++++++++ .../ISendOneHourReminderHandler.cs | 2 +- .../SendOneHourReminderHandler.cs | 56 ++- src/GmRelay.Shared/GmRelay.Shared.csproj | 1 + .../Scheduling/ISessionTriggerStore.cs | 57 ++- .../Scheduling/PlatformSchedulerOptions.cs | 5 + .../Scheduling/SessionSchedulerService.cs | 21 +- .../Platform/IPlatformMessenger.cs | 18 + .../Platform/PlatformMessageContracts.cs | 79 ++++ src/GmRelay.Shared/packages.lock.json | 52 +++ .../Components/Layout/NavMenu.razor | 2 +- .../Discord/DiscordNewSessionHandlerTests.cs | 35 +- .../Discord/DiscordPlatformMessengerTests.cs | 15 + .../Discord/DiscordProjectStructureTests.cs | 14 +- .../DiscordRescheduleDeadlineBoundaryTests.cs | 31 ++ ...cordSessionInteractionModuleSourceTests.cs | 10 + .../Discord/DiscordStartupTests.cs | 3 + .../HandleRsvp/RsvpFlowRulesTests.cs | 2 +- .../SchedulerNotificationSourceTests.cs | 53 +++ .../SessionSchedulerServiceTests.cs | 14 +- .../SessionTriggerStoreSourceTests.cs | 32 ++ .../TelegramPlatformMessengerSourceTests.cs | 20 + .../TelegramTopicIntegrationSmokeTests.cs | 19 +- .../Platform/PlatformContractsTests.cs | 38 ++ 49 files changed, 2158 insertions(+), 846 deletions(-) delete mode 100644 src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs delete mode 100644 src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs delete mode 100644 src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs create mode 100644 src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs rename src/{GmRelay.Bot => GmRelay.Shared}/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs (72%) rename src/{GmRelay.Bot => GmRelay.Shared}/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs (62%) create mode 100644 src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs create mode 100644 src/GmRelay.Shared/Features/Notifications/PlatformDirectNotificationSender.cs rename src/{GmRelay.Bot => GmRelay.Shared}/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs (63%) create mode 100644 src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs rename src/{GmRelay.Bot => GmRelay.Shared}/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs (62%) rename src/{GmRelay.Bot => GmRelay.Shared}/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs (62%) rename src/{GmRelay.Bot => GmRelay.Shared}/Infrastructure/Scheduling/ISessionTriggerStore.cs (57%) create mode 100644 src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs rename src/{GmRelay.Bot => GmRelay.Shared}/Infrastructure/Scheduling/SessionSchedulerService.cs (86%) create mode 100644 tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleDeadlineBoundaryTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SchedulerNotificationSourceTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionTriggerStoreSourceTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8780090..d95354f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.6.0 + VERSION: 2.7.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index f308714..329a37a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.6.0 + 2.7.0 net10.0 preview enable diff --git a/README.md b/README.md index b6a2d2a..e0ae999 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard -**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр. +**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v2.5.0`. +**Текущая версия:** `v2.7.0`. --- @@ -22,11 +22,14 @@ - **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. -- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков. +- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах. ### Discord Bot - **Slash-команды расписания**: GM создаёт сессию через `/newsession` и публикует актуальное расписание через `/listsessions`. - **Кнопки записи и выхода**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message. +- **Подтверждения и RSVP**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает исходы RSVP через платформенный messenger. +- **Напоминания и ссылки**: one-hour reminders и join-link notifications отправляются в Discord DM при включенных личных уведомлениях; сбои DM логируются без публичного fallback. +- **Переносы**: deadline-сервис обновляет Discord vote message и schedule message через `IPlatformMessenger`. - **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав. ### 🌐 Web Dashboard (Blazor Server) @@ -42,7 +45,7 @@ - **⬆️ Управление очередью**: Заполненность, лист ожидания и ручное повышение игрока из очереди. - **📜 История изменений сессий**: Страница `/session/{id}/history` показывает аудит-лог всех значимых изменений (время, ссылка, название, участники, статус) с указанием акторов и дат. - **📊 Статистика посещаемости**: Страница `/group/{id}/stats` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы. -- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают Telegram-сообщения расписания. +- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают platform message расписания через `IPlatformMessenger`. --- diff --git a/compose.yaml b/compose.yaml index 75b335c..b56b02b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.0 restart: always depends_on: db: @@ -79,7 +79,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.0 restart: always depends_on: db: diff --git a/docs/adr/002-platform-neutral-batch-rendering.md b/docs/adr/002-platform-neutral-batch-rendering.md index eb901f4..f6ef0bc 100644 --- a/docs/adr/002-platform-neutral-batch-rendering.md +++ b/docs/adr/002-platform-neutral-batch-rendering.md @@ -30,7 +30,7 @@ SessionBatchViewModel (platform-neutral) │ ├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup │ - └──► DiscordSessionBatchRenderer ──► (issue #26) + └──► DiscordSessionBatchRenderer ──► Discord embeds + buttons ``` ### Изменённые компоненты @@ -41,7 +41,7 @@ SessionBatchViewModel (platform-neutral) | `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` | | `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` | | `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` | -| `DiscordSessionBatchRenderer` | — | `GmRelay.Shared.Rendering` (stub) | +| `DiscordSessionBatchRenderer` | — | `GmRelay.DiscordBot.Rendering` | | `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` | ## Consequences @@ -49,7 +49,7 @@ SessionBatchViewModel (platform-neutral) ### Positive - `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект. -- Можно добавить `DiscordSessionBatchRenderer` без изменений в `Shared`. +- Discord renderer lives in `GmRelay.DiscordBot`, so NetCord stays out of `Shared`. - Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`. - Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder). @@ -62,4 +62,5 @@ SessionBatchViewModel (platform-neutral) - Issue #22 — этот рефакторинг. - Issue #26 — Discord Bot MVP (потребитель новой архитектуры). +- Issue #31 — scheduler notifications and reschedule deadline updates now use `IPlatformMessenger` for Telegram and Discord. - ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`). diff --git a/docs/c4-system-context.md b/docs/c4-system-context.md index 2030035..6ba1970 100644 --- a/docs/c4-system-context.md +++ b/docs/c4-system-context.md @@ -18,11 +18,11 @@ C4Context Rel(gm, telegram, "Creates and manages sessions") Rel(gm, discord, "Uses /newsession and /listsessions") Rel(player, telegram, "Uses inline buttons") - Rel(player, discord, "Uses Join/Leave buttons") + Rel(player, discord, "Uses Join/Leave and RSVP buttons") Rel(telegram, gmrelay, "Updates via long polling") Rel(discord, gmrelay, "Gateway events and component interactions") Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery") - Rel(gmrelay, discord, "Send/edit schedule messages and ephemeral interaction replies") + Rel(gmrelay, discord, "Send/edit schedule, RSVP, reminder, and reschedule messages") Rel(gmrelay, postgres, "SQL via Npgsql and Dapper") ``` @@ -37,9 +37,9 @@ C4Container System_Boundary(runtime, "Docker Compose / Aspire runtime") { Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders") - Container(discordBot, "GmRelay.DiscordBot", "Worker Service, .NET 10", "NetCord Gateway, slash commands, Join/Leave button interactions") + Container(discordBot, "GmRelay.DiscordBot", "Worker Service, .NET 10", "NetCord Gateway, slash commands, scheduler notifications, and button interactions") Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats") - Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, and platform-neutral join/leave handlers") + Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers") ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities") } @@ -55,7 +55,7 @@ C4Container Rel(bot, telegram, "Bot API calls") Rel(discordBot, discord, "REST send/edit/reply calls") Rel(bot, shared, "Uses shared renderers and join/leave handlers") - Rel(discordBot, shared, "Uses shared renderers and join/leave handlers") + Rel(discordBot, shared, "Uses shared renderers, scheduler, and platform-neutral handlers") Rel(web, shared, "Uses shared domain and rendering models") Rel(bot, db, "Npgsql + Dapper.AOT") Rel(discordBot, db, "Npgsql + Dapper") @@ -71,18 +71,20 @@ C4Component Container_Boundary(shared, "GmRelay.Shared") { Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking") Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows") + Component(rsvp, "HandleRsvpHandler", "Feature handler", "Updates RSVP state and emits platform-neutral RSVP outcomes") + Component(scheduler, "SessionSchedulerService", "Background service", "Triggers confirmation, reminder, and join-link notifications per platform") Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message") Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions") } Container_Boundary(discordBot, "GmRelay.DiscordBot") { - Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session buttons to neutral commands") - Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Edits Discord schedule messages and stores interaction replies") + Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands") + Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages") } Container_Boundary(bot, "GmRelay.Bot") { Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands") - Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Edits Telegram schedule messages and answers callback queries") + Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Sends and edits Telegram schedule, RSVP, reminder, join-link, and reschedule messages") } ContainerDb(db, "PostgreSQL") @@ -92,19 +94,27 @@ C4Component Rel(discord, discordModule, "Button interaction") Rel(discordModule, join, "JoinSessionCommand") Rel(discordModule, leave, "LeaveSessionCommand") + Rel(discordModule, rsvp, "HandleRsvpCommand") Rel(discordModule, discord, "Deferred ephemeral reply, then modify response") Rel(updateRouter, join, "JoinSessionCommand") Rel(updateRouter, leave, "LeaveSessionCommand") + Rel(updateRouter, rsvp, "HandleRsvpCommand") Rel(join, updateLock, "Acquire by PlatformMessageRef") Rel(leave, updateLock, "Acquire by PlatformMessageRef") Rel(join, db, "SELECT FOR UPDATE, INSERT participant") Rel(leave, db, "SELECT FOR UPDATE, DELETE/promote participant") + Rel(rsvp, db, "Update RSVP and load notification recipients") + Rel(scheduler, db, "Load due session triggers") Rel(join, renderer, "Build updated schedule view") Rel(leave, renderer, "Build updated schedule view") Rel(join, discordMessenger, "Update Discord schedule when command is Discord") Rel(leave, discordMessenger, "Update Discord schedule when command is Discord") Rel(join, telegramMessenger, "Update Telegram schedule when command is Telegram") Rel(leave, telegramMessenger, "Update Telegram schedule when command is Telegram") - Rel(discordMessenger, discord, "ModifyMessage + ephemeral text") - Rel(telegramMessenger, telegram, "EditMessage + AnswerCallbackQuery") + Rel(rsvp, discordMessenger, "Update Discord confirmation and outcomes") + Rel(rsvp, telegramMessenger, "Update Telegram confirmation and outcomes") + Rel(scheduler, discordMessenger, "Send Discord scheduler notifications") + Rel(scheduler, telegramMessenger, "Send Telegram scheduler notifications") + Rel(discordMessenger, discord, "REST send/edit/DM + ephemeral text") + Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery") ``` diff --git a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs deleted file mode 100644 index 987fd91..0000000 --- a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs +++ /dev/null @@ -1,318 +0,0 @@ -using Dapper; -using GmRelay.Shared.Domain; -using Npgsql; -using Telegram.Bot; -using Telegram.Bot.Types.ReplyMarkups; - -namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; - -public sealed record HandleRsvpCommand( - Guid SessionId, - long TelegramUserId, - string Status, - string CallbackQueryId, - long ChatId, - int MessageId); - -internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); - -internal sealed record SessionContext( - string Title, - DateTime ScheduledAt, - string Status, - long GmTelegramId, - long TelegramChatId, - int? ThreadId); - -internal sealed record ParticipantRsvp( - long TelegramId, - string DisplayName, - string? TelegramUsername, - string RsvpStatus); - -public sealed class HandleRsvpHandler( - NpgsqlDataSource dataSource, - ITelegramBotClient bot, - ILogger logger) -{ - public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); - - var participantExists = await connection.ExecuteScalarAsync( - """ - SELECT EXISTS ( - SELECT 1 - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND p.telegram_id = @TelegramUserId - AND sp.is_gm = false - AND sp.registration_status = @Active - ) - """, - new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active }, - transaction); - - if (!participantExists) - { - await bot.AnswerCallbackQuery( - callbackQueryId: command.CallbackQueryId, - text: "Вы не являетесь участником этой сессии.", - cancellationToken: ct); - return; - } - - var updated = await connection.ExecuteAsync( - """ - UPDATE session_participants - SET rsvp_status = @Status, - responded_at = now() - WHERE session_id = @SessionId - AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId) - AND registration_status = @Active - AND rsvp_status != @Status - """, - new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active }, - transaction); - - if (updated == 0) - { - var alreadyText = command.Status == RsvpStatus.Confirmed - ? "Вы уже подтвердили участие." - : "Вы уже отказались от участия."; - - await bot.AnswerCallbackQuery( - callbackQueryId: command.CallbackQueryId, - text: alreadyText, - cancellationToken: ct); - return; - } - - var session = await connection.QuerySingleAsync( - """ - SELECT s.title, - s.scheduled_at AS ScheduledAt, - s.status AS Status, - g.gm_telegram_id AS GmTelegramId, - g.telegram_chat_id AS TelegramChatId, - s.thread_id AS ThreadId - FROM sessions s - JOIN game_groups g ON g.id = s.group_id - WHERE s.id = @SessionId - """, - new { command.SessionId }, - transaction); - - if (command.Status == RsvpStatus.Declined) - { - var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0); - - if (decision.ShouldRevertSessionToConfirmationSent) - { - await connection.ExecuteAsync( - """ - UPDATE sessions - SET status = @ConfirmationSent, updated_at = now() - WHERE id = @SessionId AND status = @Confirmed - """, - new - { - command.SessionId, - ConfirmationSent = SessionStatus.ConfirmationSent, - Confirmed = SessionStatus.Confirmed - }, - transaction); - } - - var declinedPlayer = await connection.QuerySingleAsync( - "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", - new { command.TelegramUserId }, - transaction); - - await transaction.CommitAsync(ct); - - try - { - await bot.SendMessage( - chatId: session.GmTelegramId, - text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».", - cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId); - } - - await bot.AnswerCallbackQuery( - callbackQueryId: command.CallbackQueryId, - text: decision.CallbackText, - cancellationToken: ct); - } - else - { - var counts = await connection.QuerySingleAsync( - """ - SELECT - count(*) AS Total, - count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, - count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined - FROM session_participants - WHERE session_id = @SessionId AND is_gm = false - AND registration_status = @Active - """, - new - { - command.SessionId, - Confirmed = RsvpStatus.Confirmed, - Declined = RsvpStatus.Declined, - Active = ParticipantRegistrationStatus.Active - }, - transaction); - - var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed); - - if (decision.ShouldMarkSessionConfirmed) - { - await connection.ExecuteAsync( - """ - UPDATE sessions - SET status = @Confirmed, updated_at = now() - WHERE id = @SessionId - """, - new { command.SessionId, Confirmed = SessionStatus.Confirmed }, - transaction); - } - - await transaction.CommitAsync(ct); - - if (decision.ShouldNotifyGroup) - { - try - { - await bot.SendMessage( - chatId: session.TelegramChatId, - messageThreadId: session.ThreadId, - text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.", - cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId); - } - } - - if (decision.ShouldNotifyGm) - { - try - { - await bot.SendMessage( - chatId: session.GmTelegramId, - text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).", - cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId); - } - } - - await bot.AnswerCallbackQuery( - callbackQueryId: command.CallbackQueryId, - text: decision.CallbackText, - cancellationToken: ct); - } - - await UpdateConfirmationMessage(command, session, ct); - } - - private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct) - { - try - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - - var participants = (await connection.QueryAsync( - """ - SELECT p.telegram_id AS TelegramId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, - sp.rsvp_status AS RsvpStatus - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND sp.is_gm = false - AND sp.registration_status = @Active - ORDER BY sp.responded_at NULLS LAST - """, - new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); - - var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList(); - var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList(); - var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList(); - - var lines = new List - { - $"🎲 Подтвердите участие в «{session.Title}»", - $"📅 {session.ScheduledAt.FormatMoscow()} (МСК)", - string.Empty - }; - - foreach (var participant in confirmed) - { - lines.Add($" ✅ {FormatName(participant)}"); - } - - foreach (var participant in declined) - { - lines.Add($" ❌ ~~{FormatName(participant)}~~"); - } - - foreach (var participant in pending) - { - lines.Add($" ⏳ {FormatName(participant)}"); - } - - lines.Add(string.Empty); - - if (confirmed.Count == participants.Count) - { - lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})"); - } - else if (declined.Count > 0) - { - lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)"); - } - else - { - lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})"); - } - - var text = string.Join("\n", lines); - - var replyMarkup = confirmed.Count == participants.Count - ? null - : new InlineKeyboardMarkup([ - [ - InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"), - InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}") - ] - ]); - - await bot.EditMessageText( - chatId: command.ChatId, - messageId: command.MessageId, - text: text, - replyMarkup: replyMarkup, - cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId); - } - } - - private static string FormatName(ParticipantRsvp participant) => - participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName; -} diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs deleted file mode 100644 index 59a664d..0000000 --- a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs +++ /dev/null @@ -1,154 +0,0 @@ -using Dapper; -using GmRelay.Bot.Features.Notifications; -using GmRelay.Shared.Domain; -using Npgsql; -using Telegram.Bot; -using Telegram.Bot.Types.ReplyMarkups; - -namespace GmRelay.Bot.Features.Confirmation.SendConfirmation; - -// ── DTOs for Dapper mapping ────────────────────────────────────────── - -internal sealed record SessionInfo( - Guid Id, - string Title, - DateTime ScheduledAt, - Guid GroupId, - long TelegramChatId, - int? ThreadId, - string NotificationMode); - -internal sealed record ParticipantInfo( - long TelegramId, - string DisplayName, - string? TelegramUsername); - -// ── Handler ────────────────────────────────────────────────────────── - -/// -/// Sends the interactive confirmation message (inline keyboard) to the group chat. -/// Called by SessionSchedulerService at T-24h. -/// -public sealed class SendConfirmationHandler( - NpgsqlDataSource dataSource, - ITelegramBotClient bot, - DirectSessionNotificationSender directSender, - ILogger logger) : ISendConfirmationHandler -{ - public async Task HandleAsync(Guid sessionId, CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - - // 1. Load session + group info - var session = await connection.QuerySingleOrDefaultAsync( - """ - SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId, - g.telegram_chat_id AS TelegramChatId, - s.thread_id AS ThreadId, - s.notification_mode AS NotificationMode - FROM sessions s - JOIN game_groups g ON g.id = s.group_id - WHERE s.id = @SessionId AND s.status = @Planned - """, - new { SessionId = sessionId, Planned = SessionStatus.Planned }); - - if (session is null) - { - logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId); - return; - } - - // 2. Load non-GM participants - var participants = (await connection.QueryAsync( - """ - SELECT p.telegram_id AS TelegramId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND sp.is_gm = false - AND sp.registration_status = @Active - """, - new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); - - if (participants.Count == 0) - { - logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId); - return; - } - - // 3. Build confirmation message - var playerList = string.Join("\n", participants.Select(p => - $" ⏳ {FormatPlayerName(p)}")); - - var text = $""" - 🎲 Подтвердите участие в «{session.Title}» - 📅 {session.ScheduledAt.FormatMoscow()} (МСК) - - {playerList} - - Статус: ожидаем подтверждения (0/{participants.Count}) - """; - - var keyboard = new InlineKeyboardMarkup([ - [ - InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"), - InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}") - ] - ]); - - // 4. Send to group - var message = await bot.SendMessage( - chatId: session.TelegramChatId, - messageThreadId: session.ThreadId, - text: text, - replyMarkup: keyboard, - cancellationToken: ct); - - // 5. Update session status, store message ID, and mark confirmation sent - await connection.ExecuteAsync( - """ - UPDATE sessions - SET status = @Status, - confirmation_message_id = @MessageId, - confirmation_sent_at = now(), - updated_at = now() - WHERE id = @SessionId - AND confirmation_sent_at IS NULL - """, - new - { - SessionId = sessionId, - Status = SessionStatus.ConfirmationSent, - MessageId = message.MessageId - }); - - var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); - if (mode.ShouldSendDirectMessages()) - { - var directText = $""" - 🎲 Подтвердите участие в игре - - 📌 {System.Net.WebUtility.HtmlEncode(session.Title)} - 📅 {session.ScheduledAt.FormatMoscow()} (МСК) - - Ответьте кнопкой в групповом сообщении расписания. - """; - - await directSender.SendAsync( - participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)), - directText, - "confirmation", - sessionId, - ct); - } - - logger.LogInformation( - "Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}", - sessionId, session.Title, message.MessageId); - } - - internal static string FormatPlayerName(ParticipantInfo p) => - p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; -} diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs deleted file mode 100644 index 593ce60..0000000 --- a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Dapper; -using GmRelay.Bot.Features.Notifications; -using GmRelay.Shared.Domain; -using Npgsql; -using Telegram.Bot; - -namespace GmRelay.Bot.Features.Reminders.SendJoinLink; - -// ── DTOs ───────────────────────────────────────────────────────────── - -internal sealed record JoinLinkSession( - Guid Id, - string Title, - string JoinLink, - DateTime ScheduledAt, - long TelegramChatId, - int? ThreadId, - string NotificationMode); - -internal sealed record ConfirmedPlayer( - long TelegramId, - string DisplayName, - string? TelegramUsername); - -// ── Handler ────────────────────────────────────────────────────────── - -/// -/// Sends the join link to the group chat at T-5min, tagging all confirmed players. -/// Called by SessionSchedulerService. -/// -public sealed class SendJoinLinkHandler( - NpgsqlDataSource dataSource, - ITelegramBotClient bot, - DirectSessionNotificationSender directSender, - ILogger logger) : ISendJoinLinkHandler -{ - public async Task HandleAsync(Guid sessionId, CancellationToken ct) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - - // 1. Load session - var session = await connection.QuerySingleOrDefaultAsync( - """ - SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt, - g.telegram_chat_id AS TelegramChatId, - s.thread_id AS ThreadId, - s.notification_mode AS NotificationMode - FROM sessions s - JOIN game_groups g ON g.id = s.group_id - WHERE s.id = @SessionId - AND s.status = @Confirmed - AND s.link_message_id IS NULL - """, - new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed }); - - if (session is null) - { - logger.LogWarning("Session {SessionId} not eligible for join link", sessionId); - return; - } - - // 2. Load confirmed players - var players = (await connection.QueryAsync( - """ - SELECT p.telegram_id AS TelegramId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND sp.rsvp_status = @Confirmed - AND sp.registration_status = @Active - """, - new - { - SessionId = sessionId, - Confirmed = RsvpStatus.Confirmed, - Active = ParticipantRegistrationStatus.Active - })).ToList(); - - // 3. Build message with player mentions - var mentions = string.Join(", ", players.Select(p => - p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName)); - - var text = $""" - 🎮 Игра «{session.Title}» начинается через 5 минут! - - 🔗 Ссылка на подключение: - {session.JoinLink} - - Участники: {mentions} - - Хорошей игры! 🎲 - """; - - // 4. Send - var message = await bot.SendMessage( - chatId: session.TelegramChatId, - messageThreadId: session.ThreadId, - text: text, - cancellationToken: ct); - - // 5. Mark as sent (idempotent — link_message_id IS NULL guard in query) - await connection.ExecuteAsync( - """ - UPDATE sessions - SET link_message_id = @MessageId, updated_at = now() - WHERE id = @SessionId AND link_message_id IS NULL - """, - new { SessionId = sessionId, MessageId = message.MessageId }); - - var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); - if (mode.ShouldSendDirectMessages()) - { - var directText = $""" - 🎮 Игра начинается через 5 минут - - 📌 {System.Net.WebUtility.HtmlEncode(session.Title)} - 🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)} - """; - - await directSender.SendAsync( - players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)), - directText, - "join-link", - sessionId, - ct); - } - - logger.LogInformation( - "Join link sent for session {SessionId} ({Title}), message_id={MessageId}", - sessionId, session.Title, message.MessageId); - } -} diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index e35c9e0..6ffa482 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -1,13 +1,11 @@ using Dapper; -using GmRelay.Bot.Features.Notifications; +using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Notifications; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Npgsql; -using Telegram.Bot; -using Telegram.Bot.Types.Enums; -using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; @@ -19,9 +17,8 @@ internal sealed record TelegramProposalFieldsDto( public sealed class RescheduleVotingDeadlineService( NpgsqlDataSource dataSource, - ITelegramBotClient bot, IPlatformMessenger messenger, - DirectSessionNotificationSender directSender, + PlatformDirectNotificationSender directSender, RescheduleVotingFinalizer finalizer, ILogger logger) : BackgroundService { @@ -98,7 +95,7 @@ public sealed class RescheduleVotingDeadlineService( } var directRecipients = result.Participants - .Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)) + .Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName)) .ToList(); await TryUpdateVoteMessage(result, telegramFields, ct); @@ -130,28 +127,24 @@ public sealed class RescheduleVotingDeadlineService( try { - var resultText = result.SelectedOption is not null - ? $"✅ Голосование завершено.\nПобедил вариант {result.SelectedOption.DisplayOrder}: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК)." - : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}"; - - var text = $""" - {HandleRescheduleTimeInputHandler.BuildVotingMessage( + await messenger.UpdateRescheduleVoteAsync( + new PlatformRescheduleVoteUpdate( + TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), + TelegramPlatformIds.Message( + telegramFields.TelegramChatId, + telegramFields.ThreadId, + telegramFields.VoteMessageId.Value), + result.ProposalId, + result.SessionId, result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt, + result.Decision, + result.SelectedOption, result.Options, - result.Participants, - result.Votes)} - - {resultText} - """; - - await bot.EditMessageText( - chatId: telegramFields.TelegramChatId, - messageId: telegramFields.VoteMessageId.Value, - text: text, - parseMode: ParseMode.Html, - cancellationToken: ct); + result.Votes, + result.Participants), + ct); } catch (Exception ex) { @@ -201,7 +194,7 @@ public sealed class RescheduleVotingDeadlineService( { await messenger.SendGroupMessageAsync( TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), - $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».", + $"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".", ct); } } @@ -213,29 +206,20 @@ public sealed class RescheduleVotingDeadlineService( private async Task SendDirectResult( RescheduleVotingFinalizerResult result, - IReadOnlyList recipients, + IReadOnlyList recipients, CancellationToken ct) { - var htmlText = result.SelectedOption is not null - ? $""" - ✅ Сессия перенесена по итогам голосования - - 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} - 📅 Новое время: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК) - """ - : $""" - ❌ Перенос сессии отклонён по итогам голосования - - 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} - 📅 Время остаётся прежним: {result.CurrentScheduledAt.FormatMoscow()} (МСК) - Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)} - """; - await directSender.SendAsync( + result.SelectedOption is not null + ? PlatformDirectSessionNotificationKind.RescheduleApproved + : PlatformDirectSessionNotificationKind.RescheduleRejected, recipients, - htmlText, - result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", result.SessionId, + result.Title, + result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt, + joinLink: null, + actorDisplayName: null, + reason: result.SelectedOption is null ? result.Decision.Reason : null, ct); } } diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs index 3171238..5f5cacd 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs @@ -1,4 +1,6 @@ using System.Globalization; +using GmRelay.Bot.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; using Telegram.Bot; using Telegram.Bot.Types; @@ -125,6 +127,135 @@ public sealed class TelegramPlatformMessenger( cancellationToken: ct); } + public async Task SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct) + { + EnsureTelegram(request.Group.Platform); + + var chatId = ParseLong(request.Group.ExternalGroupId); + var threadId = ParseNullableInt(request.Group.ExternalThreadId); + var message = await bot.SendMessage( + chatId: chatId, + messageThreadId: threadId, + text: BuildConfirmationText(request), + parseMode: ParseMode.Html, + replyMarkup: BuildRsvpKeyboard(request.SessionId), + cancellationToken: ct); + + return TelegramPlatformIds.Message(chatId, threadId, message.MessageId); + } + + public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct) + { + var request = update.Request; + EnsureTelegram(request.Group.Platform); + var existingMessage = request.ExistingMessage + ?? throw new ArgumentException("Existing confirmation message reference is required.", nameof(update)); + + EnsureTelegram(existingMessage.Platform); + await bot.EditMessageText( + chatId: ParseLong(existingMessage.ExternalGroupId), + messageId: ParseInt(existingMessage.ExternalMessageId), + text: BuildConfirmationText(request), + parseMode: ParseMode.Html, + replyMarkup: update.DisableActions ? null : BuildRsvpKeyboard(request.SessionId), + cancellationToken: ct); + } + + public async Task SendJoinLinkNotificationAsync( + PlatformJoinLinkNotification notification, + CancellationToken ct) + { + EnsureTelegram(notification.Group.Platform); + + var chatId = ParseLong(notification.Group.ExternalGroupId); + var threadId = ParseNullableInt(notification.Group.ExternalThreadId); + var message = await bot.SendMessage( + chatId: chatId, + messageThreadId: threadId, + text: BuildJoinLinkText(notification), + cancellationToken: ct); + + return TelegramPlatformIds.Message(chatId, threadId, message.MessageId); + } + + public Task SendDirectSessionNotificationAsync( + PlatformDirectSessionNotification notification, + CancellationToken ct) + { + EnsureTelegram(notification.Recipient.Platform); + return bot.SendMessage( + chatId: ParseLong(notification.Recipient.ExternalUserId), + text: BuildDirectNotificationText(notification), + parseMode: ParseMode.Html, + cancellationToken: ct); + } + + public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct) + { + switch (notification.Kind) + { + case PlatformRsvpOutcomeKind.GroupAllConfirmed: + if (notification.Group is null) + { + throw new ArgumentException("Group notification requires a group.", nameof(notification)); + } + + EnsureTelegram(notification.Group.Platform); + await bot.SendMessage( + chatId: ParseLong(notification.Group.ExternalGroupId), + messageThreadId: ParseNullableInt(notification.Group.ExternalThreadId), + text: $"🎉 Игра «{notification.Title}» подтверждена! Все участники на месте.", + cancellationToken: ct); + break; + + case PlatformRsvpOutcomeKind.GmAllConfirmed: + case PlatformRsvpOutcomeKind.GmPlayerDeclined: + foreach (var recipient in notification.Recipients) + { + EnsureTelegram(recipient.Platform); + await bot.SendMessage( + chatId: ParseLong(recipient.ExternalUserId), + text: BuildRsvpOutcomeDirectText(notification), + parseMode: ParseMode.Html, + cancellationToken: ct); + } + + break; + + default: + throw new ArgumentOutOfRangeException(nameof(notification), notification.Kind, "Unknown RSVP outcome kind."); + } + } + + public Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct) + { + EnsureTelegram(update.Group.Platform); + EnsureTelegram(update.ExistingMessage.Platform); + + var resultText = update.SelectedOption is not null + ? $"✅ Голосование завершено.\nПобедил вариант {update.SelectedOption.DisplayOrder}: {update.SelectedOption.ProposedAt.FormatMoscow()} (МСК)." + : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(update.Decision.Reason)}"; + + var text = $""" + {HandleRescheduleTimeInputHandler.BuildVotingMessage( + update.Title, + update.CurrentScheduledAt, + update.VotingDeadlineAt, + update.Options, + update.Participants, + update.Votes)} + + {resultText} + """; + + return bot.EditMessageText( + chatId: ParseLong(update.ExistingMessage.ExternalGroupId), + messageId: ParseInt(update.ExistingMessage.ExternalMessageId), + text: text, + parseMode: ParseMode.Html, + cancellationToken: ct); + } + private async Task SendScheduleTextMessage( long chatId, int? threadId, @@ -139,6 +270,134 @@ public sealed class TelegramPlatformMessenger( replyMarkup: markup, cancellationToken: ct); + private static string BuildConfirmationText(PlatformConfirmationRequest request) + { + var confirmed = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList(); + var declined = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList(); + var pending = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList(); + + var lines = new List + { + $"🎲 Подтвердите участие в «{System.Net.WebUtility.HtmlEncode(request.Title)}»", + $"📅 {request.ScheduledAt.FormatMoscow()} (МСК)", + string.Empty + }; + + foreach (var participant in confirmed) + { + lines.Add($" ✅ {FormatTelegramParticipant(participant)}"); + } + + foreach (var participant in declined) + { + lines.Add($" ❌ {FormatTelegramParticipant(participant)}"); + } + + foreach (var participant in pending) + { + lines.Add($" ⏳ {FormatTelegramParticipant(participant)}"); + } + + lines.Add(string.Empty); + + if (request.Participants.Count > 0 && confirmed.Count == request.Participants.Count) + { + lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{request.Participants.Count})"); + } + else if (declined.Count > 0) + { + lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{request.Participants.Count} подтвердили)"); + } + else + { + lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{request.Participants.Count})"); + } + + return string.Join("\n", lines); + } + + private static string BuildJoinLinkText(PlatformJoinLinkNotification notification) + { + var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(FormatTelegramParticipant)); + + return $""" + 🎮 Игра «{notification.Title}» начинается через 5 минут! + + 🔗 Ссылка на подключение: + {notification.JoinLink} + + Участники: {mentions} + + Хорошей игры! 🎲 + """; + } + + private static string BuildDirectNotificationText(PlatformDirectSessionNotification notification) => + notification.Kind switch + { + PlatformDirectSessionNotificationKind.ConfirmationRequest => $""" + 🎲 Подтвердите участие в игре + + 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} + 📅 {notification.ScheduledAt.FormatMoscow()} (МСК) + + Ответьте кнопкой в групповом сообщении расписания. + """, + PlatformDirectSessionNotificationKind.OneHourReminder => $""" + ⏰ Игра начнётся примерно через 1 час + + 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} + 📅 {notification.ScheduledAt.FormatMoscow()} (МСК) + 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} + """, + PlatformDirectSessionNotificationKind.JoinLink => $""" + 🎮 Игра начинается через 5 минут + + 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} + 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} + """, + PlatformDirectSessionNotificationKind.RescheduleApproved => $""" + ✅ Сессия перенесена по итогам голосования + + 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} + 📅 Новое время: {notification.ScheduledAt.FormatMoscow()} (МСК) + """, + PlatformDirectSessionNotificationKind.RescheduleRejected => $""" + ❌ Перенос сессии отклонён по итогам голосования + + 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} + 📅 Время остаётся прежним: {notification.ScheduledAt.FormatMoscow()} (МСК) + Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)} + """, + _ => BuildFallbackDirectText(notification) + }; + + private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) => + $"{System.Net.WebUtility.HtmlEncode(notification.Title)}\n{notification.ScheduledAt.FormatMoscow()} (МСК)"; + + private static string BuildRsvpOutcomeDirectText(PlatformRsvpOutcomeNotification notification) => + notification.Kind switch + { + PlatformRsvpOutcomeKind.GmAllConfirmed => + $"✅ Все подтвердили участие в «{System.Net.WebUtility.HtmlEncode(notification.Title)}» ({notification.ScheduledAt.FormatMoscow()} МСК).", + PlatformRsvpOutcomeKind.GmPlayerDeclined => + $"🚨 Отмена! {System.Net.WebUtility.HtmlEncode(notification.ActorDisplayName ?? "Игрок")} не сможет прийти на игру «{System.Net.WebUtility.HtmlEncode(notification.Title)}».", + _ => System.Net.WebUtility.HtmlEncode(notification.Title) + }; + + private static InlineKeyboardMarkup BuildRsvpKeyboard(Guid sessionId) => + new([ + [ + InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"), + InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}") + ] + ]); + + private static string FormatTelegramParticipant(PlatformSessionParticipant participant) => + participant.User.ExternalUsername is not null + ? $"@{participant.User.ExternalUsername}" + : System.Net.WebUtility.HtmlEncode(participant.User.DisplayName); + private async Task TrySendScheduleImageOnly( long chatId, int? threadId, diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index ec5f8d9..36520c6 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -1,8 +1,8 @@ // ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Rendering; -using GmRelay.Bot.Features.Confirmation.HandleRsvp; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.ListSessions; using GmRelay.Bot.Features.Sessions.ExportCalendar; @@ -187,11 +187,11 @@ public sealed class UpdateRouter( var command = new HandleRsvpCommand( SessionId: sessionId, - TelegramUserId: query.From.Id, + User: user, Status: status, - CallbackQueryId: query.Id, - ChatId: message.Chat.Id, - MessageId: message.MessageId); + InteractionId: query.Id, + Group: group, + ConfirmationMessage: scheduleMessage); await rsvpHandler.HandleAsync(command, ct); } diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index c47606f..29e1968 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -1,17 +1,17 @@ -using GmRelay.Bot.Features.Confirmation.HandleRsvp; -using GmRelay.Bot.Features.Confirmation.SendConfirmation; -using GmRelay.Bot.Features.Notifications; -using GmRelay.Bot.Features.Reminders.SendJoinLink; -using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Infrastructure.Database; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Bot.Infrastructure.Health; using GmRelay.Bot.Infrastructure.Logging; -using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Telegram; +using GmRelay.Shared.Features.Confirmation.HandleRsvp; +using GmRelay.Shared.Features.Confirmation.SendConfirmation; +using GmRelay.Shared.Features.Notifications; +using GmRelay.Shared.Features.Reminders.SendJoinLink; +using GmRelay.Shared.Features.Reminders.SendOneHourReminder; using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Infrastructure.Scheduling; using GmRelay.Shared.Platform; using Npgsql; using Telegram.Bot; @@ -54,11 +54,12 @@ builder.Services.AddSingleton(sp => }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram)); // ── Feature handlers (explicit registration — AOT safe) ────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); @@ -85,7 +86,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); // ── Clock and scheduling ────────────────────────────────────────────── -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // ── Session scheduler ──────────────────────────────────────────────── diff --git a/src/GmRelay.Bot/packages.lock.json b/src/GmRelay.Bot/packages.lock.json index af21e45..579e768 100644 --- a/src/GmRelay.Bot/packages.lock.json +++ b/src/GmRelay.Bot/packages.lock.json @@ -664,6 +664,7 @@ "type": "Project", "dependencies": { "Dapper": "[2.1.72, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )", "Npgsql": "[10.0.2, )" } diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs index bb88411..8acd428 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs @@ -1,19 +1,15 @@ namespace GmRelay.DiscordBot.Features.Sessions; using Dapper; -using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; -using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Rendering; -using NetCord; -using NetCord.Rest; using Npgsql; public sealed class DiscordRescheduleVotingDeadlineService( NpgsqlDataSource dataSource, RescheduleVotingFinalizer finalizer, - RestClient restClient, + IPlatformMessenger messenger, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -27,7 +23,9 @@ public sealed class DiscordRescheduleVotingDeadlineService( await ProcessDueProposals(stoppingToken); } } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } } private async Task ProcessDueProposals(CancellationToken ct) @@ -57,10 +55,8 @@ public sealed class DiscordRescheduleVotingDeadlineService( if (result.SourcePlatform != "Discord") return; - // Update Discord vote message await TryUpdateDiscordVoteMessage(result, ct); - // If approved, update batch schedule if (result.SelectedOption is not null) { await TryUpdateBatchScheduleAsync(result, ct); @@ -68,7 +64,9 @@ public sealed class DiscordRescheduleVotingDeadlineService( logger.LogInformation( "Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", - proposalId, result.SessionId, result.Decision.Outcome); + proposalId, + result.SessionId, + result.Decision.Outcome); } catch (Exception ex) { @@ -83,10 +81,13 @@ public sealed class DiscordRescheduleVotingDeadlineService( await using var connection = await dataSource.OpenConnectionAsync(ct); var msgRef = await connection.QuerySingleOrDefaultAsync( """ - SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId - FROM platform_messages - WHERE session_id = @SessionId AND purpose = 'reschedule_vote' AND platform = 'Discord' - ORDER BY created_at DESC + SELECT g.external_group_id AS ExternalGroupId, + COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId, + pm.external_message_id AS ExternalMessageId + FROM platform_messages pm + JOIN game_groups g ON g.id = pm.group_id + WHERE pm.session_id = @SessionId AND pm.purpose = 'reschedule_vote' AND pm.platform = 'Discord' + ORDER BY pm.created_at DESC LIMIT 1 """, new { result.SessionId }); @@ -94,31 +95,27 @@ public sealed class DiscordRescheduleVotingDeadlineService( if (msgRef is null) return; - var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( - result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt, - result.Options, result.Participants, result.Votes); + var group = CreateDiscordGroup(msgRef); - var channelId = ulong.Parse(msgRef.ExternalChannelId); - var messageId = ulong.Parse(msgRef.ExternalMessageId); - - // Disable buttons after finalization - var disabledRow = new ActionRowProperties(); - foreach (var btn in actionRow.OfType()) - { - disabledRow.Add(new ButtonProperties(btn.CustomId, btn.Label ?? string.Empty, ButtonStyle.Secondary) { Disabled = true }); - } - - var resultText = result.SelectedOption is not null - ? $"Голосование завершено. Победил вариант {result.SelectedOption.DisplayOrder}: **{result.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)." - : $"Голосование завершено. {result.Decision.Reason}"; - - var updatedEmbed = embed.WithDescription($"{embed.Description}\n\n{resultText}"); - - await restClient.ModifyMessageAsync(channelId, messageId, options => - { - options.Embeds = new[] { updatedEmbed }; - options.Components = new[] { disabledRow }; - }); + await messenger.UpdateRescheduleVoteAsync( + new PlatformRescheduleVoteUpdate( + group, + new PlatformMessageRef( + PlatformKind.Discord, + msgRef.ExternalGroupId, + null, + msgRef.ExternalMessageId), + result.ProposalId, + result.SessionId, + result.Title, + result.CurrentScheduledAt, + result.VotingDeadlineAt, + result.Decision, + result.SelectedOption, + result.Options, + result.Votes, + result.Participants), + ct); } catch (Exception ex) { @@ -130,14 +127,16 @@ public sealed class DiscordRescheduleVotingDeadlineService( { try { - // Query batch schedule message ref await using var connection = await dataSource.OpenConnectionAsync(ct); var batchRef = await connection.QuerySingleOrDefaultAsync( """ - SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId - FROM platform_messages - WHERE batch_id = @BatchId AND purpose = 'schedule' AND platform = 'Discord' - ORDER BY created_at DESC + SELECT g.external_group_id AS ExternalGroupId, + COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId, + pm.external_message_id AS ExternalMessageId + FROM platform_messages pm + JOIN game_groups g ON g.id = pm.group_id + WHERE pm.batch_id = @BatchId AND pm.purpose = 'schedule' AND pm.platform = 'Discord' + ORDER BY pm.created_at DESC LIMIT 1 """, new { result.BatchId }); @@ -145,14 +144,16 @@ public sealed class DiscordRescheduleVotingDeadlineService( if (batchRef is null) return; - // Rebuild schedule view and update Discord message var sessions = (await connection.QueryAsync( "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", new { result.BatchId })).ToList(); var participants = (await connection.QueryAsync( """ - SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, sp.registration_status AS RegistrationStatus + SELECT sp.session_id AS SessionId, + p.display_name AS DisplayName, + COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, + sp.registration_status AS RegistrationStatus FROM session_participants sp JOIN players p ON p.id = sp.player_id JOIN sessions s ON sp.session_id = s.id @@ -162,16 +163,18 @@ public sealed class DiscordRescheduleVotingDeadlineService( new { result.BatchId })).ToList(); var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants); - var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); + var group = CreateDiscordGroup(batchRef); - var channelId = ulong.Parse(batchRef.ExternalChannelId); - var messageId = ulong.Parse(batchRef.ExternalMessageId); - - await restClient.ModifyMessageAsync(channelId, messageId, options => - { - options.Embeds = embeds; - options.Components = actionRows; - }); + await messenger.UpdateScheduleAsync( + new PlatformScheduleMessage( + group, + view, + new PlatformMessageRef( + PlatformKind.Discord, + batchRef.ExternalGroupId, + null, + batchRef.ExternalMessageId)), + ct); } catch (Exception ex) { @@ -179,5 +182,15 @@ public sealed class DiscordRescheduleVotingDeadlineService( } } - internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId); + private static PlatformGroup CreateDiscordGroup(PlatformMessageRefDto message) => + new( + PlatformKind.Discord, + message.ExternalGroupId, + message.ExternalGroupId, + message.ExternalChannelId); + + internal sealed record PlatformMessageRefDto( + string ExternalGroupId, + string ExternalChannelId, + string ExternalMessageId); } diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs index 0d15f18..ab44406 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs @@ -1,5 +1,9 @@ using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Platform; +using System.Globalization; using NetCord; using NetCord.Rest; using NetCord.Services.ComponentInteractions; @@ -9,6 +13,7 @@ namespace GmRelay.DiscordBot.Features.Sessions; public sealed class DiscordSessionInteractionModule( JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, + HandleRsvpHandler rsvpHandler, DiscordRescheduleVoteHandler voteHandler, DiscordInteractionReplyCache interactionReplies, ILogger logger) : ComponentInteractionModule @@ -67,6 +72,65 @@ public sealed class DiscordSessionInteractionModule( await CompleteWithStoredReplyAsync(input.InteractionId); } + [ComponentInteraction("rsvp")] + public async Task RsvpAsync(string status, string sessionId) + { + if (!Guid.TryParse(sessionId, out var parsedSessionId)) + { + await RespondAsync(CreateEphemeralReply("Session button is outdated.")); + return; + } + + var rsvpStatus = status switch + { + "confirm" => RsvpStatus.Confirmed, + "decline" => RsvpStatus.Declined, + _ => null + }; + + if (rsvpStatus is null) + { + await RespondAsync(CreateEphemeralReply("Session button is outdated.")); + return; + } + + var input = CreateInput(parsedSessionId); + await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); + + try + { + await rsvpHandler.HandleAsync( + new HandleRsvpCommand( + parsedSessionId, + new PlatformUser( + PlatformKind.Discord, + Context.User.Id.ToString(CultureInfo.InvariantCulture), + string.IsNullOrWhiteSpace(Context.User.GlobalName) ? Context.User.Username : Context.User.GlobalName, + Context.User.Username), + rsvpStatus, + input.InteractionId, + new PlatformGroup( + PlatformKind.Discord, + input.GuildId, + input.GuildId, + input.ChannelId), + new PlatformMessageRef( + PlatformKind.Discord, + input.GuildId, + null, + input.MessageId)), + CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId); + await CompleteResponseAsync("РќРµ удалось обработать РєРЅРѕРїРєСѓ."); + return; + } + + await CompleteWithStoredReplyAsync(input.InteractionId); + } + [ComponentInteraction("reschedule_vote")] public async Task RescheduleVoteAsync(string optionId) { @@ -112,9 +176,9 @@ public sealed class DiscordSessionInteractionModule( return new DiscordSessionInteractionInput( SessionId: sessionId, InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), - GuildId: guild.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), - ChannelId: Context.Channel.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), - MessageId: message.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + GuildId: guild.Id.ToString(CultureInfo.InvariantCulture), + ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture), + MessageId: message.Id.ToString(CultureInfo.InvariantCulture), UserId: Context.User.Id, Username: Context.User.Username, DisplayName: Context.User.GlobalName); diff --git a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs index e4ab108..35d5c29 100644 --- a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs +++ b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs @@ -1,21 +1,43 @@ +using System.Globalization; +using System.Text; using GmRelay.DiscordBot.Rendering; +using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; +using Microsoft.Extensions.Logging; using NetCord; using NetCord.Rest; namespace GmRelay.DiscordBot.Infrastructure.Discord; -public sealed class DiscordPlatformMessenger( - RestClient restClient, - DiscordInteractionReplyCache interactionReplies) : IPlatformMessenger +public sealed class DiscordPlatformMessenger : IPlatformMessenger { + private readonly RestClient restClient; + private readonly DiscordInteractionReplyCache interactionReplies; + private readonly ILogger? logger; + + public DiscordPlatformMessenger( + RestClient restClient, + DiscordInteractionReplyCache interactionReplies) + : this(restClient, interactionReplies, logger: null) + { + } + + public DiscordPlatformMessenger( + RestClient restClient, + DiscordInteractionReplyCache interactionReplies, + ILogger? logger) + { + this.restClient = restClient; + this.interactionReplies = interactionReplies; + this.logger = logger; + } + public async Task SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) { var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View); - var channelId = ulong.Parse(message.Group.ExternalChannelId - ?? message.Group.ExternalGroupId); + var channelId = GetChannelId(message.Group); var msg = await restClient.SendMessageAsync( channelId, @@ -27,7 +49,7 @@ public sealed class DiscordPlatformMessenger( PlatformKind.Discord, message.Group.ExternalGroupId, null, - msg.Id.ToString()); + msg.Id.ToString(CultureInfo.InvariantCulture)); } public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) @@ -37,9 +59,8 @@ public sealed class DiscordPlatformMessenger( var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View); - var channelId = ulong.Parse(message.Group.ExternalChannelId - ?? message.Group.ExternalGroupId); - var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId); + var channelId = GetChannelId(message.Group); + var messageId = ParseSnowflake(message.ExistingMessage.ExternalMessageId); await restClient.ModifyMessageAsync( channelId, @@ -53,18 +74,12 @@ public sealed class DiscordPlatformMessenger( public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) { - var channelIdStr = group.ExternalChannelId ?? group.ExternalGroupId - ?? throw new InvalidOperationException("Group has no ExternalChannelId or ExternalGroupId."); - - if (!ulong.TryParse(channelIdStr, out var channelId)) - throw new InvalidOperationException($"Invalid Discord channel/group ID: '{channelIdStr}'."); - - await restClient.SendMessageAsync(channelId, htmlText); + await restClient.SendMessageAsync(GetChannelId(group), htmlText); } - public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) + public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) { - return Task.CompletedTask; + await SendDirectContentAsync(message.Recipient, message.HtmlText, ct); } public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) @@ -77,4 +92,281 @@ public sealed class DiscordPlatformMessenger( { return Task.CompletedTask; } + + public async Task SendConfirmationRequestAsync( + PlatformConfirmationRequest request, + CancellationToken ct) + { + var channelId = GetChannelId(request.Group); + var message = await restClient.SendMessageAsync( + channelId, + new MessageProperties() + .WithEmbeds([BuildConfirmationEmbed(request)]) + .WithComponents(BuildRsvpRows(request.SessionId, disabled: false))); + + return new PlatformMessageRef( + PlatformKind.Discord, + request.Group.ExternalGroupId, + null, + message.Id.ToString(CultureInfo.InvariantCulture)); + } + + public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct) + { + if (update.Request.ExistingMessage is null) + return; + + var channelId = GetChannelId(update.Request.Group); + var messageId = ParseSnowflake(update.Request.ExistingMessage.ExternalMessageId); + var components = BuildRsvpRows(update.Request.SessionId, update.DisableActions); + + await restClient.ModifyMessageAsync( + channelId, + messageId, + options => + { + options.Embeds = [BuildConfirmationEmbed(update.Request)]; + options.Components = components; + }); + } + + public async Task SendJoinLinkNotificationAsync( + PlatformJoinLinkNotification notification, + CancellationToken ct) + { + var channelId = GetChannelId(notification.Group); + var message = await restClient.SendMessageAsync( + channelId, + new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)])); + + return new PlatformMessageRef( + PlatformKind.Discord, + notification.Group.ExternalGroupId, + null, + message.Id.ToString(CultureInfo.InvariantCulture)); + } + + public async Task SendDirectSessionNotificationAsync( + PlatformDirectSessionNotification notification, + CancellationToken ct) + { + try + { + await SendDirectContentAsync( + notification.Recipient, + BuildDirectContent(notification), + ct); + } + catch (Exception ex) + { + logger?.LogWarning( + ex, + "Failed to send Discord direct notification {NotificationKind} for session {SessionId} to user {ExternalUserId}", + notification.Kind, + notification.SessionId, + notification.Recipient.ExternalUserId); + } + } + + public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct) + { + if (notification.Kind == PlatformRsvpOutcomeKind.GroupAllConfirmed && notification.Group is not null) + { + await restClient.SendMessageAsync( + GetChannelId(notification.Group), + BuildRsvpGroupOutcomeContent(notification)); + return; + } + + var directKind = notification.Kind == PlatformRsvpOutcomeKind.GmPlayerDeclined + ? PlatformDirectSessionNotificationKind.RsvpDeclined + : PlatformDirectSessionNotificationKind.RsvpAllConfirmed; + + foreach (var recipient in notification.Recipients) + { + await SendDirectSessionNotificationAsync( + new PlatformDirectSessionNotification( + directKind, + recipient, + notification.SessionId, + notification.Title, + notification.ScheduledAt, + ActorDisplayName: notification.ActorDisplayName), + ct); + } + } + + public async Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct) + { + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( + update.Title, + update.CurrentScheduledAt, + update.VotingDeadlineAt, + update.Options, + update.Participants, + update.Votes); + + var disabledRow = new ActionRowProperties(); + foreach (var button in actionRow.OfType()) + { + disabledRow.Add(new ButtonProperties( + button.CustomId, + button.Label ?? string.Empty, + ButtonStyle.Secondary) + { + Disabled = true + }); + } + + var updatedEmbed = embed.WithDescription( + $"{embed.Description}\n\n{BuildRescheduleResultText(update)}"); + + await restClient.ModifyMessageAsync( + GetChannelId(update.Group), + ParseSnowflake(update.ExistingMessage.ExternalMessageId), + options => + { + options.Embeds = [updatedEmbed]; + options.Components = [disabledRow]; + }); + } + + private static EmbedProperties BuildConfirmationEmbed(PlatformConfirmationRequest request) + { + var embed = new EmbedProperties() + .WithTitle($"Подтверждение: {request.Title}") + .WithDescription(BuildConfirmationDescription(request)) + .WithColor(new Color(0x5865F2)); + + return embed.AddFields( + [ + BuildParticipantField("Подтвердили", request.Participants, RsvpStatus.Confirmed), + BuildParticipantField("Отказались", request.Participants, RsvpStatus.Declined), + BuildParticipantField("Ожидаем ответ", request.Participants, RsvpStatus.Pending) + ]); + } + + private static string BuildConfirmationDescription(PlatformConfirmationRequest request) => + $"Время: **{request.ScheduledAt.FormatMoscow()}** (МСК)\n" + + "Подтвердите участие кнопкой ниже."; + + private static EmbedFieldProperties BuildParticipantField( + string title, + IReadOnlyList participants, + string status) + { + var values = participants + .Where(participant => participant.RsvpStatus == status) + .Select(FormatDiscordParticipant) + .ToList(); + + return new EmbedFieldProperties() + .WithName(title) + .WithValue(values.Count == 0 ? "—" : string.Join("\n", values)) + .WithInline(); + } + + private static EmbedProperties BuildJoinLinkEmbed(PlatformJoinLinkNotification notification) + { + var mentions = notification.ConfirmedPlayers.Count == 0 + ? "—" + : string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User))); + + return new EmbedProperties() + .WithTitle($"Ссылка на игру: {notification.Title}") + .WithDescription( + $"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" + + $"Ссылка: {notification.JoinLink}\n\n" + + $"Участники: {mentions}") + .WithUrl(notification.JoinLink) + .WithColor(new Color(0x57F287)); + } + + private static IReadOnlyList BuildRsvpRows(Guid sessionId, bool disabled) + { + var row = new ActionRowProperties(); + row.Add(new ButtonProperties($"rsvp:confirm:{sessionId}", "Буду", ButtonStyle.Success) + { + Disabled = disabled + }); + row.Add(new ButtonProperties($"rsvp:decline:{sessionId}", "Не смогу", ButtonStyle.Danger) + { + Disabled = disabled + }); + + return [row]; + } + + private static string BuildDirectContent(PlatformDirectSessionNotification notification) + { + var builder = new StringBuilder(); + builder.AppendLine(notification.Kind switch + { + PlatformDirectSessionNotificationKind.ConfirmationRequest => "Нужно подтвердить участие", + PlatformDirectSessionNotificationKind.OneHourReminder => "Напоминание: сессия через час", + PlatformDirectSessionNotificationKind.JoinLink => "Ссылка на игру", + PlatformDirectSessionNotificationKind.RsvpAllConfirmed => "Все игроки подтвердили участие", + PlatformDirectSessionNotificationKind.RsvpDeclined => "Игрок отказался от участия", + PlatformDirectSessionNotificationKind.RescheduleApproved => "Сессия перенесена", + PlatformDirectSessionNotificationKind.RescheduleRejected => "Перенос сессии отклонен", + _ => "Уведомление по сессии" + }); + + builder.AppendLine(); + builder.AppendLine($"**{notification.Title}**"); + builder.AppendLine($"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)"); + + if (!string.IsNullOrWhiteSpace(notification.JoinLink)) + builder.AppendLine($"Ссылка: {notification.JoinLink}"); + + if (!string.IsNullOrWhiteSpace(notification.ActorDisplayName)) + builder.AppendLine($"Игрок: {notification.ActorDisplayName}"); + + if (!string.IsNullOrWhiteSpace(notification.Reason)) + builder.AppendLine($"Причина: {notification.Reason}"); + + return builder.ToString(); + } + + private static string BuildRsvpGroupOutcomeContent(PlatformRsvpOutcomeNotification notification) => + $"Все участники подтвердили сессию **{notification.Title}** на " + + $"**{notification.ScheduledAt.FormatMoscow()}** (МСК)."; + + private static string BuildRescheduleResultText(PlatformRescheduleVoteUpdate update) + { + if (update.SelectedOption is not null) + { + return "Голосование завершено. " + + $"Победил вариант {update.SelectedOption.DisplayOrder}: " + + $"**{update.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)."; + } + + return $"Голосование завершено. {update.Decision.Reason}"; + } + + private async Task SendDirectContentAsync(PlatformUser recipient, string content, CancellationToken ct) + { + var userId = ParseSnowflake(recipient.ExternalUserId); + var dm = await restClient.GetDMChannelAsync(userId, cancellationToken: ct); + await restClient.SendMessageAsync( + dm.Id, + new MessageProperties().WithContent(content), + cancellationToken: ct); + } + + private static string FormatDiscordParticipant(PlatformSessionParticipant participant) => + $"{Mention(participant.User)} ({participant.User.DisplayName})"; + + private static string Mention(PlatformUser user) => $"<@{user.ExternalUserId}>"; + + private static ulong GetChannelId(PlatformGroup group) + { + var channelId = group.ExternalChannelId ?? group.ExternalGroupId + ?? throw new InvalidOperationException("Discord group has no channel or group identifier."); + + return ParseSnowflake(channelId); + } + + private static ulong ParseSnowflake(string value) => + ulong.Parse(value, CultureInfo.InvariantCulture); } diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 88e07ed..8cac081 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -3,8 +3,14 @@ using GmRelay.DiscordBot.Features.Sessions; using GmRelay.DiscordBot.Infrastructure; using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Logging; +using GmRelay.Shared.Features.Confirmation.HandleRsvp; +using GmRelay.Shared.Features.Confirmation.SendConfirmation; +using GmRelay.Shared.Features.Notifications; +using GmRelay.Shared.Features.Reminders.SendJoinLink; +using GmRelay.Shared.Features.Reminders.SendOneHourReminder; using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Infrastructure.Scheduling; using GmRelay.Shared.Platform; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -54,7 +60,18 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services diff --git a/src/GmRelay.DiscordBot/packages.lock.json b/src/GmRelay.DiscordBot/packages.lock.json index cd04943..776398d 100644 --- a/src/GmRelay.DiscordBot/packages.lock.json +++ b/src/GmRelay.DiscordBot/packages.lock.json @@ -669,6 +669,7 @@ "type": "Project", "dependencies": { "Dapper": "[2.1.72, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )", "Npgsql": "[10.0.2, )" } diff --git a/src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs b/src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs new file mode 100644 index 0000000..6ecb9f6 --- /dev/null +++ b/src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs @@ -0,0 +1,356 @@ +using System.Globalization; +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Notifications; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace GmRelay.Shared.Features.Confirmation.HandleRsvp; + +public sealed record HandleRsvpCommand( + Guid SessionId, + PlatformUser User, + string Status, + string InteractionId, + PlatformGroup Group, + PlatformMessageRef ConfirmationMessage); + +internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); + +internal sealed record RsvpSessionContext( + Guid GroupId, + string Title, + DateTime ScheduledAt, + string Status); + +internal sealed record ParticipantRsvpRow( + string Platform, + string ExternalUserId, + string DisplayName, + string? ExternalUsername, + string RsvpStatus, + string RegistrationStatus, + bool IsGm); + +internal sealed record RsvpRecipientRow( + string Platform, + string ExternalUserId, + string DisplayName, + string? ExternalUsername); + +public sealed class HandleRsvpHandler( + NpgsqlDataSource dataSource, + IPlatformMessenger messenger, + ILogger logger) +{ + public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + var participantExists = await connection.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND COALESCE(p.platform, 'Telegram') = @Platform + AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId + AND sp.is_gm = false + AND sp.registration_status = @Active + ) + """, + new + { + command.SessionId, + Platform = command.User.Platform.ToString(), + command.User.ExternalUserId, + Active = ParticipantRegistrationStatus.Active + }, + transaction); + + if (!participantExists) + { + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply( + command.InteractionId, + "Вы не являетесь участником этой сессии."), + ct); + return; + } + + var updated = await connection.ExecuteAsync( + """ + UPDATE session_participants + SET rsvp_status = @Status, + responded_at = now() + WHERE session_id = @SessionId + AND player_id = ( + SELECT id + FROM players + WHERE COALESCE(platform, 'Telegram') = @Platform + AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId + LIMIT 1 + ) + AND registration_status = @Active + AND rsvp_status != @Status + """, + new + { + command.SessionId, + command.Status, + Platform = command.User.Platform.ToString(), + command.User.ExternalUserId, + Active = ParticipantRegistrationStatus.Active + }, + transaction); + + if (updated == 0) + { + var alreadyText = command.Status == RsvpStatus.Confirmed + ? "Вы уже подтвердили участие." + : "Вы уже отказались от участия."; + + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply(command.InteractionId, alreadyText), + ct); + return; + } + + var session = await connection.QuerySingleAsync( + """ + SELECT s.group_id AS GroupId, + s.title, + s.scheduled_at AS ScheduledAt, + s.status AS Status + FROM sessions s + WHERE s.id = @SessionId + """, + new { command.SessionId }, + transaction); + + if (command.Status == RsvpStatus.Declined) + { + var decision = RsvpFlowRules.Evaluate( + command.Status, + session.Status, + totalParticipants: 0, + confirmedParticipants: 0); + + if (decision.ShouldRevertSessionToConfirmationSent) + { + await connection.ExecuteAsync( + """ + UPDATE sessions + SET status = @ConfirmationSent, updated_at = now() + WHERE id = @SessionId AND status = @Confirmed + """, + new + { + command.SessionId, + ConfirmationSent = SessionStatus.ConfirmationSent, + Confirmed = SessionStatus.Confirmed + }, + transaction); + } + + var gmRecipients = (await GetGmRecipientsAsync(connection, session.GroupId, transaction)) + .ToList(); + + await transaction.CommitAsync(ct); + + if (gmRecipients.Count > 0) + { + await messenger.SendRsvpOutcomeAsync( + new PlatformRsvpOutcomeNotification( + PlatformRsvpOutcomeKind.GmPlayerDeclined, + Group: null, + gmRecipients, + command.SessionId, + session.Title, + session.ScheduledAt, + ActorDisplayName: command.User.DisplayName), + ct); + } + + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply(command.InteractionId, decision.CallbackText), + ct); + } + else + { + var counts = await connection.QuerySingleAsync( + """ + SELECT + count(*) AS Total, + count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, + count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined + FROM session_participants + WHERE session_id = @SessionId AND is_gm = false + AND registration_status = @Active + """, + new + { + command.SessionId, + Confirmed = RsvpStatus.Confirmed, + Declined = RsvpStatus.Declined, + Active = ParticipantRegistrationStatus.Active + }, + transaction); + + var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed); + + if (decision.ShouldMarkSessionConfirmed) + { + await connection.ExecuteAsync( + """ + UPDATE sessions + SET status = @Confirmed, updated_at = now() + WHERE id = @SessionId + """, + new { command.SessionId, Confirmed = SessionStatus.Confirmed }, + transaction); + } + + var gmRecipients = decision.ShouldNotifyGm + ? (await GetGmRecipientsAsync(connection, session.GroupId, transaction)).ToList() + : []; + + await transaction.CommitAsync(ct); + + if (decision.ShouldNotifyGroup) + { + await messenger.SendRsvpOutcomeAsync( + new PlatformRsvpOutcomeNotification( + PlatformRsvpOutcomeKind.GroupAllConfirmed, + command.Group, + [], + command.SessionId, + session.Title, + session.ScheduledAt), + ct); + } + + if (decision.ShouldNotifyGm && gmRecipients.Count > 0) + { + await messenger.SendRsvpOutcomeAsync( + new PlatformRsvpOutcomeNotification( + PlatformRsvpOutcomeKind.GmAllConfirmed, + Group: null, + gmRecipients, + command.SessionId, + session.Title, + session.ScheduledAt), + ct); + } + + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply(command.InteractionId, decision.CallbackText), + ct); + } + + await UpdateConfirmationMessage(command, session, ct); + } + + private async Task UpdateConfirmationMessage( + HandleRsvpCommand command, + RsvpSessionContext session, + CancellationToken ct) + { + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var participants = (await connection.QueryAsync( + """ + SELECT COALESCE(p.platform, 'Telegram') AS Platform, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, + p.display_name AS DisplayName, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, + sp.rsvp_status AS RsvpStatus, + sp.registration_status AS RegistrationStatus, + sp.is_gm AS IsGm + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + ORDER BY sp.responded_at NULLS LAST + """, + new { command.SessionId, Active = ParticipantRegistrationStatus.Active })) + .Select(ToParticipant) + .ToList(); + + var disableActions = participants.Count > 0 && + participants.All(participant => participant.RsvpStatus == RsvpStatus.Confirmed); + + await messenger.UpdateConfirmationRequestAsync( + new PlatformRsvpMessageUpdate( + new PlatformConfirmationRequest( + command.Group, + command.SessionId, + session.Title, + session.ScheduledAt, + participants, + command.ConfirmationMessage), + disableActions), + ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId); + } + } + + private static async Task> GetGmRecipientsAsync( + NpgsqlConnection connection, + Guid groupId, + NpgsqlTransaction transaction) + { + var rows = await connection.QueryAsync( + """ + SELECT DISTINCT + COALESCE(p.platform, 'Telegram') AS Platform, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, + p.display_name AS DisplayName, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = @GroupId + UNION + SELECT DISTINCT + COALESCE(p.platform, 'Telegram') AS Platform, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, + p.display_name AS DisplayName, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername + FROM game_groups g + JOIN players p ON p.telegram_id = g.gm_telegram_id + WHERE g.id = @GroupId + AND g.gm_telegram_id IS NOT NULL + """, + new { GroupId = groupId }, + transaction); + + return rows.Select(row => new PlatformUser( + ParsePlatform(row.Platform), + row.ExternalUserId, + row.DisplayName, + row.ExternalUsername)); + } + + private static PlatformSessionParticipant ToParticipant(ParticipantRsvpRow row) => + new( + new PlatformUser( + ParsePlatform(row.Platform), + row.ExternalUserId, + row.DisplayName, + row.ExternalUsername), + row.RsvpStatus, + row.RegistrationStatus, + row.IsGm); + + private static PlatformKind ParsePlatform(string platform) => + Enum.Parse(platform, ignoreCase: true); +} diff --git a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs b/src/GmRelay.Shared/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs similarity index 72% rename from src/GmRelay.Bot/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs rename to src/GmRelay.Shared/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs index 309f5ca..df27455 100644 --- a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs +++ b/src/GmRelay.Shared/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs @@ -1,8 +1,8 @@ using GmRelay.Shared.Domain; -namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; +namespace GmRelay.Shared.Features.Confirmation.HandleRsvp; -internal sealed record RsvpFlowDecision( +public sealed record RsvpFlowDecision( string CallbackText, bool ShouldAlertGm, bool ShouldRevertSessionToConfirmationSent, @@ -10,7 +10,7 @@ internal sealed record RsvpFlowDecision( bool ShouldNotifyGroup, bool ShouldNotifyGm); -internal static class RsvpFlowRules +public static class RsvpFlowRules { public static RsvpFlowDecision Evaluate( string requestedStatus, @@ -21,7 +21,7 @@ internal static class RsvpFlowRules if (requestedStatus == RsvpStatus.Declined) { return new RsvpFlowDecision( - CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.", + CallbackText: "Вы отказались от участия.", ShouldAlertGm: true, ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed, ShouldMarkSessionConfirmed: false, @@ -32,7 +32,7 @@ internal static class RsvpFlowRules var everyoneConfirmed = confirmedParticipants == totalParticipants; return new RsvpFlowDecision( - CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!", + CallbackText: "Вы подтвердили участие!", ShouldAlertGm: false, ShouldRevertSessionToConfirmationSent: false, ShouldMarkSessionConfirmed: everyoneConfirmed, diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs b/src/GmRelay.Shared/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs similarity index 62% rename from src/GmRelay.Bot/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs rename to src/GmRelay.Shared/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs index ce46721..fed4434 100644 --- a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs +++ b/src/GmRelay.Shared/Features/Confirmation/SendConfirmation/ISendConfirmationHandler.cs @@ -1,4 +1,4 @@ -namespace GmRelay.Bot.Features.Confirmation.SendConfirmation; +namespace GmRelay.Shared.Features.Confirmation.SendConfirmation; public interface ISendConfirmationHandler { diff --git a/src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs new file mode 100644 index 0000000..c58ab34 --- /dev/null +++ b/src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs @@ -0,0 +1,217 @@ +using System.Globalization; +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Notifications; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace GmRelay.Shared.Features.Confirmation.SendConfirmation; + +internal sealed record ConfirmationSessionRow( + Guid Id, + string Title, + DateTime ScheduledAt, + Guid GroupId, + string Platform, + string ExternalGroupId, + string DisplayName, + string? ExternalChannelId, + int? ThreadId, + string NotificationMode); + +internal sealed record ConfirmationParticipantRow( + string Platform, + string ExternalUserId, + string DisplayName, + string? ExternalUsername, + string RsvpStatus, + string RegistrationStatus, + bool IsGm); + +public sealed class SendConfirmationHandler( + NpgsqlDataSource dataSource, + IPlatformMessenger messenger, + PlatformDirectNotificationSender directSender, + ILogger logger) : ISendConfirmationHandler +{ + public async Task HandleAsync(Guid sessionId, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var session = await connection.QuerySingleOrDefaultAsync( + """ + SELECT s.id, + s.title, + s.scheduled_at AS ScheduledAt, + s.group_id AS GroupId, + COALESCE(g.platform, 'Telegram') AS Platform, + COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId, + g.name AS DisplayName, + COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId, + s.thread_id AS ThreadId, + s.notification_mode AS NotificationMode + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.id = @SessionId AND s.status = @Planned + """, + new { SessionId = sessionId, Planned = SessionStatus.Planned }); + + if (session is null) + { + logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId); + return; + } + + var participants = (await connection.QueryAsync( + """ + SELECT COALESCE(p.platform, 'Telegram') AS Platform, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, + p.display_name AS DisplayName, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, + sp.rsvp_status AS RsvpStatus, + sp.registration_status AS RegistrationStatus, + sp.is_gm AS IsGm + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + ORDER BY sp.created_at ASC + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })) + .Select(ToParticipant) + .ToList(); + + if (participants.Count == 0) + { + logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId); + return; + } + + var group = CreateGroup(session); + var message = await messenger.SendConfirmationRequestAsync( + new PlatformConfirmationRequest( + group, + session.Id, + session.Title, + session.ScheduledAt, + participants), + ct); + + await connection.ExecuteAsync( + """ + UPDATE sessions + SET status = @Status, + confirmation_message_id = @MessageId, + confirmation_sent_at = now(), + updated_at = now() + WHERE id = @SessionId + AND confirmation_sent_at IS NULL + """, + new + { + SessionId = sessionId, + Status = SessionStatus.ConfirmationSent, + MessageId = TryGetTelegramMessageId(message) + }); + + await PersistPlatformMessageAsync( + connection, + message, + session.GroupId, + session.Id, + batchId: null, + purpose: "confirmation"); + + var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + await directSender.SendAsync( + PlatformDirectSessionNotificationKind.ConfirmationRequest, + participants.Select(p => p.User), + session.Id, + session.Title, + session.ScheduledAt, + joinLink: null, + actorDisplayName: null, + reason: null, + ct); + } + + logger.LogInformation( + "Confirmation sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}", + sessionId, + session.Title, + message.Platform, + message.ExternalMessageId); + } + + private static PlatformSessionParticipant ToParticipant(ConfirmationParticipantRow row) => + new( + new PlatformUser( + ParsePlatform(row.Platform), + row.ExternalUserId, + row.DisplayName, + row.ExternalUsername), + row.RsvpStatus, + row.RegistrationStatus, + row.IsGm); + + private static PlatformGroup CreateGroup(ConfirmationSessionRow row) => + new( + ParsePlatform(row.Platform), + row.ExternalGroupId, + row.DisplayName, + row.ExternalChannelId, + row.ThreadId?.ToString(CultureInfo.InvariantCulture)); + + private static PlatformKind ParsePlatform(string platform) => + Enum.Parse(platform, ignoreCase: true); + + private static int? TryGetTelegramMessageId(PlatformMessageRef message) => + message.Platform == PlatformKind.Telegram && + int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId) + ? messageId + : null; + + private static Task PersistPlatformMessageAsync( + NpgsqlConnection connection, + PlatformMessageRef message, + Guid groupId, + Guid? sessionId, + Guid? batchId, + string purpose) => + connection.ExecuteAsync( + """ + INSERT INTO platform_messages ( + platform, + group_id, + batch_id, + session_id, + external_channel_id, + external_thread_id, + external_message_id, + purpose) + VALUES ( + @Platform, + @GroupId, + @BatchId, + @SessionId, + @ExternalChannelId, + @ExternalThreadId, + @ExternalMessageId, + @Purpose) + """, + new + { + Platform = message.Platform.ToString(), + GroupId = groupId, + BatchId = batchId, + SessionId = sessionId, + ExternalChannelId = message.ExternalGroupId, + message.ExternalThreadId, + message.ExternalMessageId, + Purpose = purpose + }); +} diff --git a/src/GmRelay.Shared/Features/Notifications/PlatformDirectNotificationSender.cs b/src/GmRelay.Shared/Features/Notifications/PlatformDirectNotificationSender.cs new file mode 100644 index 0000000..388e111 --- /dev/null +++ b/src/GmRelay.Shared/Features/Notifications/PlatformDirectNotificationSender.cs @@ -0,0 +1,50 @@ +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; + +namespace GmRelay.Shared.Features.Notifications; + +public sealed class PlatformDirectNotificationSender( + IPlatformMessenger messenger, + ILogger logger) +{ + public async Task SendAsync( + PlatformDirectSessionNotificationKind kind, + IEnumerable recipients, + Guid sessionId, + string title, + DateTime scheduledAt, + string? joinLink, + string? actorDisplayName, + string? reason, + CancellationToken ct) + { + foreach (var recipient in recipients) + { + try + { + await messenger.SendDirectSessionNotificationAsync( + new PlatformDirectSessionNotification( + kind, + recipient, + sessionId, + title, + scheduledAt, + joinLink, + actorDisplayName, + reason), + ct); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to send {NotificationKind} notification for session {SessionId} to {Platform} user {ExternalUserId} ({DisplayName})", + kind, + sessionId, + recipient.Platform, + recipient.ExternalUserId, + recipient.DisplayName); + } + } + } +} diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs similarity index 63% rename from src/GmRelay.Bot/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs rename to src/GmRelay.Shared/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs index d81887b..3f86cb3 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs +++ b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/ISendJoinLinkHandler.cs @@ -1,4 +1,4 @@ -namespace GmRelay.Bot.Features.Reminders.SendJoinLink; +namespace GmRelay.Shared.Features.Reminders.SendJoinLink; public interface ISendJoinLinkHandler { diff --git a/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs new file mode 100644 index 0000000..da90f19 --- /dev/null +++ b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Notifications; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace GmRelay.Shared.Features.Reminders.SendJoinLink; + +internal sealed record JoinLinkSessionRow( + Guid Id, + Guid GroupId, + string Title, + string JoinLink, + DateTime ScheduledAt, + string Platform, + string ExternalGroupId, + string DisplayName, + string? ExternalChannelId, + int? ThreadId, + string NotificationMode); + +internal sealed record JoinLinkPlayerRow( + string Platform, + string ExternalUserId, + string DisplayName, + string? ExternalUsername, + string RsvpStatus, + string RegistrationStatus, + bool IsGm); + +public sealed class SendJoinLinkHandler( + NpgsqlDataSource dataSource, + IPlatformMessenger messenger, + PlatformDirectNotificationSender directSender, + ILogger logger) : ISendJoinLinkHandler +{ + public async Task HandleAsync(Guid sessionId, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var session = await connection.QuerySingleOrDefaultAsync( + """ + SELECT s.id, + s.group_id AS GroupId, + s.title, + s.join_link AS JoinLink, + s.scheduled_at AS ScheduledAt, + COALESCE(g.platform, 'Telegram') AS Platform, + COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId, + g.name AS DisplayName, + COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId, + s.thread_id AS ThreadId, + s.notification_mode AS NotificationMode + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.id = @SessionId + AND s.status = @Confirmed + AND ( + (COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL) + OR ( + COALESCE(g.platform, 'Telegram') <> 'Telegram' + AND NOT EXISTS ( + SELECT 1 + FROM platform_messages pm + WHERE pm.session_id = s.id + AND pm.platform = COALESCE(g.platform, 'Telegram') + AND pm.purpose = 'join_link' + ) + ) + ) + """, + new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed }); + + if (session is null) + { + logger.LogWarning("Session {SessionId} not eligible for join link", sessionId); + return; + } + + var players = (await connection.QueryAsync( + """ + SELECT COALESCE(p.platform, 'Telegram') AS Platform, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, + p.display_name AS DisplayName, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, + sp.rsvp_status AS RsvpStatus, + sp.registration_status AS RegistrationStatus, + sp.is_gm AS IsGm + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.rsvp_status = @Confirmed + AND sp.registration_status = @Active + ORDER BY sp.created_at ASC + """, + new + { + SessionId = sessionId, + Confirmed = RsvpStatus.Confirmed, + Active = ParticipantRegistrationStatus.Active + })) + .Select(ToParticipant) + .ToList(); + + var group = CreateGroup(session); + var message = await messenger.SendJoinLinkNotificationAsync( + new PlatformJoinLinkNotification( + group, + session.Id, + session.Title, + session.ScheduledAt, + session.JoinLink, + players), + ct); + + await connection.ExecuteAsync( + """ + UPDATE sessions + SET link_message_id = @MessageId, updated_at = now() + WHERE id = @SessionId + """, + new + { + SessionId = sessionId, + MessageId = TryGetTelegramMessageId(message) + }); + + await PersistPlatformMessageAsync( + connection, + message, + session.GroupId, + session.Id, + batchId: null, + purpose: "join_link"); + + var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + await directSender.SendAsync( + PlatformDirectSessionNotificationKind.JoinLink, + players.Select(p => p.User), + session.Id, + session.Title, + session.ScheduledAt, + session.JoinLink, + actorDisplayName: null, + reason: null, + ct); + } + + logger.LogInformation( + "Join link sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}", + sessionId, + session.Title, + message.Platform, + message.ExternalMessageId); + } + + private static PlatformSessionParticipant ToParticipant(JoinLinkPlayerRow row) => + new( + new PlatformUser( + ParsePlatform(row.Platform), + row.ExternalUserId, + row.DisplayName, + row.ExternalUsername), + row.RsvpStatus, + row.RegistrationStatus, + row.IsGm); + + private static PlatformGroup CreateGroup(JoinLinkSessionRow row) => + new( + ParsePlatform(row.Platform), + row.ExternalGroupId, + row.DisplayName, + row.ExternalChannelId, + row.ThreadId?.ToString(CultureInfo.InvariantCulture)); + + private static PlatformKind ParsePlatform(string platform) => + Enum.Parse(platform, ignoreCase: true); + + private static int? TryGetTelegramMessageId(PlatformMessageRef message) => + message.Platform == PlatformKind.Telegram && + int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId) + ? messageId + : null; + + private static Task PersistPlatformMessageAsync( + NpgsqlConnection connection, + PlatformMessageRef message, + Guid groupId, + Guid? sessionId, + Guid? batchId, + string purpose) => + connection.ExecuteAsync( + """ + INSERT INTO platform_messages ( + platform, + group_id, + batch_id, + session_id, + external_channel_id, + external_thread_id, + external_message_id, + purpose) + VALUES ( + @Platform, + @GroupId, + @BatchId, + @SessionId, + @ExternalChannelId, + @ExternalThreadId, + @ExternalMessageId, + @Purpose) + """, + new + { + Platform = message.Platform.ToString(), + GroupId = groupId, + BatchId = batchId, + SessionId = sessionId, + ExternalChannelId = message.ExternalGroupId, + message.ExternalThreadId, + message.ExternalMessageId, + Purpose = purpose + }); +} diff --git a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs b/src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs similarity index 62% rename from src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs rename to src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs index 21564d6..76d12d6 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs +++ b/src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/ISendOneHourReminderHandler.cs @@ -1,4 +1,4 @@ -namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder; +namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder; public interface ISendOneHourReminderHandler { diff --git a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs b/src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs similarity index 62% rename from src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs rename to src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs index 474b72a..05702c3 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs +++ b/src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs @@ -1,27 +1,35 @@ using Dapper; -using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Notifications; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; using Npgsql; -namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder; +namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder; -internal sealed record OneHourReminderSession( +internal sealed record OneHourReminderSessionRow( Guid Id, string Title, string JoinLink, DateTime ScheduledAt, string NotificationMode); +internal sealed record OneHourReminderRecipientRow( + string Platform, + string ExternalUserId, + string DisplayName, + string? ExternalUsername); + public sealed class SendOneHourReminderHandler( NpgsqlDataSource dataSource, - DirectSessionNotificationSender directSender, + PlatformDirectNotificationSender directSender, ILogger logger) : ISendOneHourReminderHandler { public async Task HandleAsync(Guid sessionId, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); - var session = await connection.QuerySingleOrDefaultAsync( + var session = await connection.QuerySingleOrDefaultAsync( """ SELECT id, title, @@ -46,10 +54,12 @@ public sealed class SendOneHourReminderHandler( return; } - var recipients = (await connection.QueryAsync( + var recipients = (await connection.QueryAsync( """ - SELECT p.telegram_id AS TelegramId, - p.display_name AS DisplayName + SELECT COALESCE(p.platform, 'Telegram') AS Platform, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, + p.display_name AS DisplayName, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId @@ -62,20 +72,27 @@ public sealed class SendOneHourReminderHandler( SessionId = sessionId, Active = ParticipantRegistrationStatus.Active, Declined = RsvpStatus.Declined - })).ToList(); + })) + .Select(row => new PlatformUser( + ParsePlatform(row.Platform), + row.ExternalUserId, + row.DisplayName, + row.ExternalUsername)) + .ToList(); var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); if (mode.ShouldSendDirectMessages() && recipients.Count > 0) { - var text = $""" - ⏰ Игра начнётся примерно через 1 час - - 📌 {System.Net.WebUtility.HtmlEncode(session.Title)} - 📅 {session.ScheduledAt.FormatMoscow()} (МСК) - 🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)} - """; - - await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct); + await directSender.SendAsync( + PlatformDirectSessionNotificationKind.OneHourReminder, + recipients, + session.Id, + session.Title, + session.ScheduledAt, + session.JoinLink, + actorDisplayName: null, + reason: null, + ct); } await connection.ExecuteAsync( @@ -94,4 +111,7 @@ public sealed class SendOneHourReminderHandler( session.Title, session.NotificationMode); } + + private static PlatformKind ParsePlatform(string platform) => + Enum.Parse(platform, ignoreCase: true); } diff --git a/src/GmRelay.Shared/GmRelay.Shared.csproj b/src/GmRelay.Shared/GmRelay.Shared.csproj index b2767b6..fb2efbc 100644 --- a/src/GmRelay.Shared/GmRelay.Shared.csproj +++ b/src/GmRelay.Shared/GmRelay.Shared.csproj @@ -11,6 +11,7 @@ + diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs b/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs similarity index 57% rename from src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs rename to src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs index fe2b0a8..99e34b0 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.cs +++ b/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs @@ -2,7 +2,7 @@ using Dapper; using GmRelay.Shared.Domain; using Npgsql; -namespace GmRelay.Bot.Infrastructure.Scheduling; +namespace GmRelay.Shared.Infrastructure.Scheduling; public interface ISessionTriggerStore { @@ -11,7 +11,9 @@ public interface ISessionTriggerStore Task> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct); } -public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessionTriggerStore +public sealed class DbSessionTriggerStore( + NpgsqlDataSource dataSource, + PlatformSchedulerOptions options) : ISessionTriggerStore { private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24); private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1); @@ -23,14 +25,17 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio var results = await connection.QueryAsync( """ - SELECT id - FROM sessions - WHERE status = @Planned - AND scheduled_at - @LeadTime <= @Now - AND confirmation_sent_at IS NULL + SELECT s.id + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE g.platform = @Platform + AND s.status = @Planned + AND s.scheduled_at - @LeadTime <= @Now + AND s.confirmation_sent_at IS NULL """, new { + Platform = options.Platform.ToString(), Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime, Now = now.UtcDateTime @@ -45,14 +50,17 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio var results = await connection.QueryAsync( """ - SELECT id - FROM sessions - WHERE status IN (@Confirmed, @ConfirmationSent) - AND scheduled_at - @LeadTime <= @Now - AND one_hour_reminder_processed_at IS NULL + SELECT s.id + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE g.platform = @Platform + AND s.status IN (@Confirmed, @ConfirmationSent) + AND s.scheduled_at - @LeadTime <= @Now + AND s.one_hour_reminder_processed_at IS NULL """, new { + Platform = options.Platform.ToString(), Confirmed = SessionStatus.Confirmed, ConfirmationSent = SessionStatus.ConfirmationSent, LeadTime = OneHourReminderLeadTime, @@ -68,14 +76,29 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio var results = await connection.QueryAsync( """ - SELECT id - FROM sessions - WHERE status = @Confirmed - AND scheduled_at - @LeadTime <= @Now - AND link_message_id IS NULL + SELECT s.id + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE g.platform = @Platform + AND s.status = @Confirmed + AND s.scheduled_at - @LeadTime <= @Now + AND ( + (g.platform = 'Telegram' AND s.link_message_id IS NULL) + OR ( + g.platform <> 'Telegram' + AND NOT EXISTS ( + SELECT 1 + FROM platform_messages pm + WHERE pm.session_id = s.id + AND pm.platform = g.platform + AND pm.purpose = 'join_link' + ) + ) + ) """, new { + Platform = options.Platform.ToString(), Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime, Now = now.UtcDateTime diff --git a/src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs b/src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs new file mode 100644 index 0000000..52d2784 --- /dev/null +++ b/src/GmRelay.Shared/Infrastructure/Scheduling/PlatformSchedulerOptions.cs @@ -0,0 +1,5 @@ +using GmRelay.Shared.Platform; + +namespace GmRelay.Shared.Infrastructure.Scheduling; + +public sealed record PlatformSchedulerOptions(PlatformKind Platform); diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs b/src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs similarity index 86% rename from src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs rename to src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs index c7e89ee..28947cf 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs +++ b/src/GmRelay.Shared/Infrastructure/Scheduling/SessionSchedulerService.cs @@ -1,18 +1,15 @@ -using GmRelay.Bot.Features.Confirmation.SendConfirmation; -using GmRelay.Bot.Features.Reminders.SendJoinLink; -using GmRelay.Bot.Features.Reminders.SendOneHourReminder; +using GmRelay.Shared.Features.Confirmation.SendConfirmation; +using GmRelay.Shared.Features.Reminders.SendJoinLink; +using GmRelay.Shared.Features.Reminders.SendOneHourReminder; using GmRelay.Shared.Platform; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; -namespace GmRelay.Bot.Infrastructure.Scheduling; +namespace GmRelay.Shared.Infrastructure.Scheduling; /// /// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions. -/// Three triggers: -/// T-24h: send confirmation request with inline keyboard -/// T-1h: send one-hour direct reminder -/// T-5min: send join link to all confirmed players -/// -/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB. +/// All state is kept in the database so worker restarts do not lose scheduled work. /// public sealed class SessionSchedulerService( ISessionTriggerStore triggerStore, @@ -50,10 +47,6 @@ public sealed class SessionSchedulerService( logger.LogInformation("Session scheduler stopped"); } - /// - /// Runs a single scheduler tick using the current clock time. - /// Public so it can be called from integration tests with a fake clock. - /// public async Task TickAsync(CancellationToken ct) { var now = clock.UtcNow; diff --git a/src/GmRelay.Shared/Platform/IPlatformMessenger.cs b/src/GmRelay.Shared/Platform/IPlatformMessenger.cs index e3b33a4..1023b21 100644 --- a/src/GmRelay.Shared/Platform/IPlatformMessenger.cs +++ b/src/GmRelay.Shared/Platform/IPlatformMessenger.cs @@ -13,4 +13,22 @@ public interface IPlatformMessenger Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct); Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct); + + Task SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support confirmation requests."); + + Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support confirmation request updates."); + + Task SendJoinLinkNotificationAsync(PlatformJoinLinkNotification notification, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support join-link notifications."); + + Task SendDirectSessionNotificationAsync(PlatformDirectSessionNotification notification, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support direct session notifications."); + + Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support RSVP outcome notifications."); + + Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support reschedule vote updates."); } diff --git a/src/GmRelay.Shared/Platform/PlatformMessageContracts.cs b/src/GmRelay.Shared/Platform/PlatformMessageContracts.cs index 98bd5ba..66428b6 100644 --- a/src/GmRelay.Shared/Platform/PlatformMessageContracts.cs +++ b/src/GmRelay.Shared/Platform/PlatformMessageContracts.cs @@ -1,3 +1,4 @@ +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Rendering; namespace GmRelay.Shared.Platform; @@ -34,3 +35,81 @@ public sealed record PlatformCalendarFile( byte[] Content, string CaptionHtml, IReadOnlyList Actions); + +public sealed record PlatformSessionParticipant( + PlatformUser User, + string RsvpStatus, + string RegistrationStatus, + bool IsGm = false); + +public sealed record PlatformConfirmationRequest( + PlatformGroup Group, + Guid SessionId, + string Title, + DateTime ScheduledAt, + IReadOnlyList Participants, + PlatformMessageRef? ExistingMessage = null); + +public sealed record PlatformJoinLinkNotification( + PlatformGroup Group, + Guid SessionId, + string Title, + DateTime ScheduledAt, + string JoinLink, + IReadOnlyList ConfirmedPlayers, + PlatformMessageRef? ExistingMessage = null); + +public enum PlatformDirectSessionNotificationKind +{ + ConfirmationRequest = 0, + OneHourReminder = 1, + JoinLink = 2, + RsvpAllConfirmed = 3, + RsvpDeclined = 4, + RescheduleApproved = 5, + RescheduleRejected = 6 +} + +public sealed record PlatformDirectSessionNotification( + PlatformDirectSessionNotificationKind Kind, + PlatformUser Recipient, + Guid SessionId, + string Title, + DateTime ScheduledAt, + string? JoinLink = null, + string? ActorDisplayName = null, + string? Reason = null); + +public sealed record PlatformRsvpMessageUpdate( + PlatformConfirmationRequest Request, + bool DisableActions); + +public enum PlatformRsvpOutcomeKind +{ + GroupAllConfirmed = 0, + GmAllConfirmed = 1, + GmPlayerDeclined = 2 +} + +public sealed record PlatformRsvpOutcomeNotification( + PlatformRsvpOutcomeKind Kind, + PlatformGroup? Group, + IReadOnlyList Recipients, + Guid SessionId, + string Title, + DateTime ScheduledAt, + string? ActorDisplayName = null); + +public sealed record PlatformRescheduleVoteUpdate( + PlatformGroup Group, + PlatformMessageRef ExistingMessage, + Guid ProposalId, + Guid SessionId, + string Title, + DateTime CurrentScheduledAt, + DateTimeOffset VotingDeadlineAt, + RescheduleVoteDecision Decision, + RescheduleOptionDto? SelectedOption, + IReadOnlyList Options, + IReadOnlyList Votes, + IReadOnlyList Participants); diff --git a/src/GmRelay.Shared/packages.lock.json b/src/GmRelay.Shared/packages.lock.json index e4a2a9e..fb9bb79 100644 --- a/src/GmRelay.Shared/packages.lock.json +++ b/src/GmRelay.Shared/packages.lock.json @@ -14,6 +14,19 @@ "resolved": "1.0.48", "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA==" }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", "requested": "[10.0.5, )", @@ -38,10 +51,49 @@ "resolved": "5.6.7", "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", "resolved": "10.0.5", "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" } } } diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 1c94201..42d00dc 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/Discord/DiscordNewSessionHandlerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs index 4c323bb..bb13611 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs @@ -20,11 +20,14 @@ public sealed class DiscordNewSessionHandlerTests [Fact] public void ParseTimeInput_ShouldParseDiscordDateFormat() { - var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30"); + var expected = FutureDateAt1930(); + var result = DiscordNewSessionHandler.ParseTimeInput( + expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(result.IsSuccess); - Assert.Equal(2026, result.Value.Year); - Assert.Equal(5, result.Value.Month); - Assert.Equal(20, result.Value.Day); + Assert.Equal(expected.Year, result.Value.Year); + Assert.Equal(expected.Month, result.Value.Month); + Assert.Equal(expected.Day, result.Value.Day); Assert.Equal(19, result.Value.Hour); Assert.Equal(30, result.Value.Minute); } @@ -39,11 +42,14 @@ public sealed class DiscordNewSessionHandlerTests [Fact] public void ParseTimeInput_ShouldParseRussianDateFormat() { - var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30"); + var expected = FutureDateAt1930(); + var result = DiscordNewSessionHandler.ParseTimeInput( + expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(result.IsSuccess); - Assert.Equal(2026, result.Value.Year); - Assert.Equal(5, result.Value.Month); - Assert.Equal(20, result.Value.Day); + Assert.Equal(expected.Year, result.Value.Year); + Assert.Equal(expected.Month, result.Value.Month); + Assert.Equal(expected.Day, result.Value.Day); } [Fact] @@ -141,4 +147,17 @@ public sealed class DiscordNewSessionHandlerTests Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal); Assert.Contains("WithEmbeds", source, StringComparison.Ordinal); } + + private static DateTimeOffset FutureDateAt1930() + { + var future = DateTimeOffset.UtcNow.AddDays(7); + return new DateTimeOffset( + future.Year, + future.Month, + future.Day, + 19, + 30, + 0, + TimeSpan.Zero); + } } diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs index dfbcb0a..c88ae3e 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs @@ -32,6 +32,21 @@ public sealed class DiscordPlatformMessengerTests Assert.Contains("interactionReplies.Store(reply)", source, StringComparison.Ordinal); } + [Fact] + public async Task DiscordPlatformMessenger_ShouldSupportSchedulerNotifications() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs"); + + Assert.Contains("SendConfirmationRequestAsync", source, StringComparison.Ordinal); + Assert.Contains("UpdateConfirmationRequestAsync", source, StringComparison.Ordinal); + Assert.Contains("SendJoinLinkNotificationAsync", source, StringComparison.Ordinal); + Assert.Contains("SendDirectSessionNotificationAsync", source, StringComparison.Ordinal); + Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); + Assert.Contains("DiscordSessionBatchRenderer", source, StringComparison.Ordinal); + Assert.Contains("DiscordRescheduleVotingRenderer", source, StringComparison.Ordinal); + Assert.Contains("GetDMChannelAsync", source, StringComparison.Ordinal); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index c4f3370..0cdaec7 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - Assert.Contains("gmrelay-discord-bot:2.6.0", compose); + Assert.Contains("gmrelay-discord-bot:2.7.0", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy); @@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("2.6.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 2.6.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("2.7.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 2.7.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:2.7.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:2.7.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:2.7.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v2.6.0", + "v2.7.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } } diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleDeadlineBoundaryTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleDeadlineBoundaryTests.cs new file mode 100644 index 0000000..f4dd21a --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleDeadlineBoundaryTests.cs @@ -0,0 +1,31 @@ +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordRescheduleDeadlineBoundaryTests +{ + [Fact] + public async Task DiscordDeadlineService_ShouldUsePlatformMessengerForMessageUpdates() + { + var source = await ReadRepositoryFileAsync( + "src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs"); + + Assert.DoesNotContain("RestClient", source, StringComparison.Ordinal); + Assert.DoesNotContain("ModifyMessageAsync", source, StringComparison.Ordinal); + Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); + Assert.Contains("IPlatformMessenger", 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/Discord/DiscordSessionInteractionModuleSourceTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs index b98be76..a01b46f 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordSessionInteractionModuleSourceTests.cs @@ -21,6 +21,16 @@ public sealed class DiscordSessionInteractionModuleSourceTests Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal); } + [Fact] + public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs"); + + Assert.Contains("[ComponentInteraction(\"rsvp\")", source, StringComparison.Ordinal); + Assert.Contains("HandleRsvpHandler", source, StringComparison.Ordinal); + Assert.Contains("PlatformKind.Discord", source, StringComparison.Ordinal); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs index a7eba6d..5656855 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs @@ -82,6 +82,9 @@ public sealed class DiscordStartupTests Assert.Contains("DiscordPermissionChecker", program); Assert.Contains("DiscordPlatformMessenger", program); Assert.Contains("IPlatformMessenger", program); + Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program); + Assert.Contains("AddHostedService", program); + Assert.Contains("HandleRsvpHandler", program); } private static string ReadProgram() diff --git a/tests/GmRelay.Bot.Tests/Features/Confirmation/HandleRsvp/RsvpFlowRulesTests.cs b/tests/GmRelay.Bot.Tests/Features/Confirmation/HandleRsvp/RsvpFlowRulesTests.cs index ed64702..3a08380 100644 --- a/tests/GmRelay.Bot.Tests/Features/Confirmation/HandleRsvp/RsvpFlowRulesTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Confirmation/HandleRsvp/RsvpFlowRulesTests.cs @@ -1,4 +1,4 @@ -using GmRelay.Bot.Features.Confirmation.HandleRsvp; +using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Domain; namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp; diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SchedulerNotificationSourceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SchedulerNotificationSourceTests.cs new file mode 100644 index 0000000..c8981d9 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SchedulerNotificationSourceTests.cs @@ -0,0 +1,53 @@ +namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; + +public sealed class SchedulerNotificationSourceTests +{ + [Theory] + [InlineData("src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs")] + [InlineData("src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs")] + [InlineData("src/GmRelay.Shared/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs")] + [InlineData("src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs")] + public async Task SchedulerNotificationHandlers_ShouldUsePlatformMessengerWithoutSdkClients(string relativePath) + { + var source = await ReadRepositoryFileAsync(relativePath); + + Assert.True( + source.Contains("IPlatformMessenger", StringComparison.Ordinal) || + source.Contains("PlatformDirectNotificationSender", StringComparison.Ordinal), + "Handler should use IPlatformMessenger directly or through PlatformDirectNotificationSender."); + Assert.DoesNotContain("Telegram.Bot", source, StringComparison.Ordinal); + Assert.DoesNotContain("ITelegramBotClient", source, StringComparison.Ordinal); + Assert.DoesNotContain("NetCord", source, StringComparison.Ordinal); + Assert.DoesNotContain("RestClient", source, StringComparison.Ordinal); + } + + [Fact] + public async Task DiscordProgram_ShouldRegisterSharedSchedulerForDiscordPlatform() + { + var program = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Program.cs"); + + Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program, StringComparison.Ordinal); + Assert.Contains("AddHostedService", program, StringComparison.Ordinal); + Assert.Contains("DbSessionTriggerStore", program, StringComparison.Ordinal); + Assert.Contains("SendConfirmationHandler", program, StringComparison.Ordinal); + Assert.Contains("SendJoinLinkHandler", program, StringComparison.Ordinal); + Assert.Contains("SendOneHourReminderHandler", program, 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/Scheduling/SessionSchedulerServiceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs index a76a6ef..7536d17 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionSchedulerServiceTests.cs @@ -1,7 +1,8 @@ -using GmRelay.Bot.Features.Confirmation.SendConfirmation; -using GmRelay.Bot.Features.Reminders.SendJoinLink; -using GmRelay.Bot.Features.Reminders.SendOneHourReminder; -using GmRelay.Bot.Infrastructure.Scheduling; +using GmRelay.Shared.Features.Confirmation.SendConfirmation; +using GmRelay.Shared.Features.Reminders.SendJoinLink; +using GmRelay.Shared.Features.Reminders.SendOneHourReminder; +using GmRelay.Shared.Infrastructure.Scheduling; +using GmRelay.Shared.Platform; using Microsoft.Extensions.Logging.Abstractions; namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; @@ -211,4 +212,9 @@ public sealed class SessionSchedulerServiceTests return Task.FromResult>(SessionsNeedingJoinLink); } } + + private sealed class FakeSystemClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow; + } } diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionTriggerStoreSourceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionTriggerStoreSourceTests.cs new file mode 100644 index 0000000..ba72272 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/SessionTriggerStoreSourceTests.cs @@ -0,0 +1,32 @@ +namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; + +public sealed class SessionTriggerStoreSourceTests +{ + [Fact] + public async Task DbSessionTriggerStore_ShouldFilterTriggersByConfiguredPlatform() + { + var source = await ReadRepositoryFileAsync( + "src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs"); + + Assert.Contains("PlatformSchedulerOptions", source, StringComparison.Ordinal); + Assert.Contains("JOIN game_groups g ON g.id = s.group_id", source, StringComparison.Ordinal); + Assert.Contains("g.platform = @Platform", 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/TelegramPlatformMessengerSourceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs index 12dea04..f165f4b 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs @@ -39,6 +39,26 @@ public sealed class TelegramPlatformMessengerSourceTests Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal); Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal); Assert.Contains("SendDocument", source, StringComparison.Ordinal); + Assert.Contains("SendConfirmationRequestAsync", source, StringComparison.Ordinal); + Assert.Contains("UpdateConfirmationRequestAsync", source, StringComparison.Ordinal); + Assert.Contains("SendJoinLinkNotificationAsync", source, StringComparison.Ordinal); + Assert.Contains("SendDirectSessionNotificationAsync", source, StringComparison.Ordinal); + Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); + Assert.Contains("messageThreadId", source, StringComparison.Ordinal); + Assert.Contains("ParseMode.Html", source, StringComparison.Ordinal); + Assert.Contains("InlineKeyboardButton.WithCallbackData", source, StringComparison.Ordinal); + } + + [Fact] + public async Task RescheduleVotingDeadlineService_ShouldUsePlatformMessengerForVoteMessageUpdates() + { + var source = await ReadRepositoryFileAsync( + "src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs"); + + Assert.DoesNotContain("ITelegramBotClient", source, StringComparison.Ordinal); + Assert.DoesNotContain(".EditMessageText(", source, StringComparison.Ordinal); + Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal); + Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); } private static async Task ReadRepositoryFileAsync(string relativePath) diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs index 380bb31..cacd78d 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -22,25 +22,25 @@ public sealed class TelegramTopicIntegrationSmokeTests [Fact] public async Task GroupNotifications_ShouldSendToStoredForumTopic() { - var confirmationHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs"); - var joinLinkHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs"); - var rsvpHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs"); + var confirmationHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs"); + var joinLinkHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs"); + var rsvpHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs"); var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs"); var initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs"); var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs"); var rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs"); + var telegramMessenger = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs"); Assert.Contains("int? ThreadId", confirmationHandler, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", confirmationHandler, StringComparison.Ordinal); - Assert.Contains("messageThreadId: session.ThreadId", confirmationHandler, StringComparison.Ordinal); + Assert.Contains("ExternalThreadId", confirmationHandler, StringComparison.Ordinal); Assert.Contains("int? ThreadId", joinLinkHandler, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", joinLinkHandler, StringComparison.Ordinal); - Assert.Contains("messageThreadId: session.ThreadId", joinLinkHandler, StringComparison.Ordinal); + Assert.Contains("ExternalThreadId", joinLinkHandler, StringComparison.Ordinal); - Assert.Contains("int? ThreadId", rsvpHandler, StringComparison.Ordinal); - Assert.Contains("s.thread_id AS ThreadId", rsvpHandler, StringComparison.Ordinal); - Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, StringComparison.Ordinal); + Assert.Contains("PlatformMessageRef ConfirmationMessage", rsvpHandler, StringComparison.Ordinal); + Assert.Contains("UpdateConfirmationRequestAsync", rsvpHandler, StringComparison.Ordinal); Assert.Contains("int? MessageThreadId", cancelHandler, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", cancelHandler, StringComparison.Ordinal); @@ -55,6 +55,9 @@ public sealed class TelegramTopicIntegrationSmokeTests Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal); + + Assert.Contains("messageThreadId", telegramMessenger, StringComparison.Ordinal); + Assert.Contains("ExternalThreadId", telegramMessenger, 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 index 673655a..7a446a9 100644 --- a/tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs +++ b/tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs @@ -51,4 +51,42 @@ public sealed class PlatformContractsTests Assert.Equal(PlatformKind.Discord, message.Group.Platform); Assert.Same(view, message.View); } + + [Fact] + public void PlatformNotificationContracts_ShouldBeSdkAssemblyFree() + { + var contractTypes = new[] + { + typeof(PlatformSessionParticipant), + typeof(PlatformConfirmationRequest), + typeof(PlatformJoinLinkNotification), + typeof(PlatformDirectSessionNotification), + typeof(PlatformRsvpMessageUpdate), + typeof(PlatformRsvpOutcomeNotification), + typeof(PlatformRescheduleVoteUpdate) + }; + + Assert.All(contractTypes, type => + { + var refs = string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)); + Assert.DoesNotContain("Telegram", refs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("NetCord", refs, StringComparison.OrdinalIgnoreCase); + }); + } + + [Fact] + public void PlatformMessenger_ShouldExposeSchedulerNotificationOperations() + { + var methods = typeof(IPlatformMessenger) + .GetMethods() + .Select(method => method.Name) + .ToHashSet(StringComparer.Ordinal); + + Assert.Contains("SendConfirmationRequestAsync", methods); + Assert.Contains("UpdateConfirmationRequestAsync", methods); + Assert.Contains("SendJoinLinkNotificationAsync", methods); + Assert.Contains("SendDirectSessionNotificationAsync", methods); + Assert.Contains("SendRsvpOutcomeAsync", methods); + Assert.Contains("UpdateRescheduleVoteAsync", methods); + } }