From 2a707e482595665804ba9c58dcb3b8744e2fece2 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 21 May 2026 12:30:35 +0300 Subject: [PATCH] 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); + } }