Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 492d47a863 | |||
| fe8d5fe026 | |||
| a2fa9aaa6c | |||
| 5b65ac4a2f | |||
| feb3e08b63 | |||
| f1d8f56fec | |||
| 08ffc6694e | |||
| 3199c48fcd | |||
| 2a707e4825 | |||
| 5dbec1a0a4 | |||
| 7426000937 |
@@ -10,6 +10,10 @@ TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
|
||||
# Используется ботом для кнопки меню Telegram и кнопки /start.
|
||||
TELEGRAM_MINI_APP_URL=
|
||||
|
||||
# Токен Discord application bot
|
||||
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
|
||||
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
|
||||
|
||||
# Пароль для базы данных PostgreSQL
|
||||
POSTGRES_PASSWORD=StrongPasswordForDatabase
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 2.6.0
|
||||
VERSION: 2.7.1
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>2.6.0</Version>
|
||||
<Version>2.7.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-3
@@ -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.1
|
||||
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.1
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -77,9 +77,14 @@ services:
|
||||
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
|
||||
networks:
|
||||
- gmrelay
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.6.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.1
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -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`).
|
||||
|
||||
+20
-10
@@ -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")
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+140
@@ -0,0 +1,140 @@
|
||||
# Platform Messenger Scheduler Notifications Design
|
||||
|
||||
## Goal
|
||||
|
||||
Issue #31 moves scheduler-driven notifications and reschedule deadline message updates behind `IPlatformMessenger`, preserving Telegram behavior and adding full Discord support instead of no-op placeholders.
|
||||
|
||||
## Scope
|
||||
|
||||
- `SessionSchedulerService` remains the trigger orchestrator, but scheduler handlers stop depending on Telegram API types for outbound notification work.
|
||||
- Confirmation requests, one-hour reminders, join-link notifications, RSVP follow-up messages, and reschedule deadline updates use platform-neutral contracts.
|
||||
- Telegram keeps the current user-visible behavior: same message content, RSVP buttons, direct messages, topic/thread targeting, and stored legacy message ids.
|
||||
- Discord receives full channel and direct notifications:
|
||||
- confirmation requests are sent to the Discord channel with RSVP buttons;
|
||||
- Discord RSVP button clicks update participant RSVP state, refresh the confirmation message, and send the same group/GM outcome notifications where applicable;
|
||||
- one-hour reminders and join-link notifications are sent as Discord DMs when direct notifications are enabled;
|
||||
- join-link notifications also post the channel message with participant mentions;
|
||||
- reschedule deadline processing updates Discord vote and schedule messages through the same messenger boundary.
|
||||
- Discord DM failures are non-fatal: log a warning and continue without posting a public fallback message.
|
||||
|
||||
## Architecture
|
||||
|
||||
The platform boundary should be semantic, not Telegram-shaped. `GmRelay.Shared.Platform` already owns `PlatformKind`, `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `IPlatformMessenger`; issue #31 extends that layer with notification-specific DTOs and messenger methods.
|
||||
|
||||
The scheduler handlers own database queries and notification eligibility. They load platform-neutral groups, users, message refs, and session data, then ask the platform messenger to send or update the platform message. Platform implementations own rendering details: Telegram renders HTML and inline keyboards; Discord renders embeds, components, channel messages, mentions, and DMs.
|
||||
|
||||
RSVP handling should become platform-neutral enough for both Telegram and Discord. The current `HandleRsvpHandler` logic is not duplicated. Its command changes from Telegram ids to `PlatformUser`, `PlatformGroup`, `PlatformMessageRef`, and `InteractionId`. Telegram update routing maps callback queries into that command; Discord component routing maps RSVP button interactions into the same command.
|
||||
|
||||
Reschedule finalization already has shared database logic in `RescheduleVotingFinalizer`. The remaining platform-specific deadline services should stop editing messages through `ITelegramBotClient` or Discord `RestClient` directly. They should load message refs and call `IPlatformMessenger` to update vote messages, schedule messages, and direct result notifications.
|
||||
|
||||
## Platform Contracts
|
||||
|
||||
Add semantic notification records in `GmRelay.Shared.Platform`, with names finalized during implementation planning:
|
||||
|
||||
- `PlatformSessionParticipant`: a `PlatformUser` plus RSVP, registration, and display metadata needed by notification renderers.
|
||||
- `PlatformSessionNotification`: common session title, time, join link, notification mode, group, optional existing message, and participants.
|
||||
- `PlatformConfirmationRequest`: confirmation-specific session notification with RSVP actions.
|
||||
- `PlatformJoinLinkNotification`: join-link group/direct notification data.
|
||||
- `PlatformOneHourReminder`: one-hour direct reminder data.
|
||||
- `PlatformRsvpMessageUpdate`: refreshed confirmation message state after a participant responds.
|
||||
- `PlatformRescheduleVoteUpdate`: finalized reschedule vote message state, including selected option or rejection reason.
|
||||
|
||||
Extend `IPlatformMessenger` with methods for these semantic operations while keeping existing schedule, group, private, interaction, and calendar methods intact for current flows:
|
||||
|
||||
- send and update confirmation request messages;
|
||||
- send one-hour reminder direct notifications;
|
||||
- send join-link channel and direct notifications;
|
||||
- update finalized reschedule vote messages;
|
||||
- send RSVP outcome messages to the group and GM recipients.
|
||||
|
||||
The exact method names should be chosen in the implementation plan after tests define the desired API, but each method should accept platform-neutral DTOs and return `PlatformMessageRef` when the caller must persist a sent message id.
|
||||
|
||||
## Telegram Behavior
|
||||
|
||||
Telegram implementation lives in `GmRelay.Bot.Infrastructure.Telegram.TelegramPlatformMessenger`.
|
||||
|
||||
It must preserve:
|
||||
|
||||
- `messageThreadId` handling for forum topics;
|
||||
- HTML parse mode where the existing flow uses HTML;
|
||||
- current confirmation and RSVP button callback payloads;
|
||||
- `confirmation_message_id` and `link_message_id` storage in `sessions`;
|
||||
- direct notification behavior controlled by `SessionNotificationMode`;
|
||||
- warning-and-continue behavior for failed direct messages;
|
||||
- existing schedule rendering through `TelegramSessionBatchRenderer` and `BatchMessageEditor`.
|
||||
|
||||
Telegram-specific inbound parsing remains at the Telegram boundary. `UpdateRouter` can still use `Telegram.Bot.Types`, but the command it passes into the RSVP handler should be platform-neutral.
|
||||
|
||||
## Discord Behavior
|
||||
|
||||
Discord implementation lives in `GmRelay.DiscordBot.Infrastructure.Discord.DiscordPlatformMessenger`.
|
||||
|
||||
It must support:
|
||||
|
||||
- channel messages through the configured channel id in `PlatformGroup.ExternalChannelId`;
|
||||
- interactive RSVP buttons routed by `DiscordSessionInteractionModule`;
|
||||
- ephemeral interaction replies via the existing `DiscordInteractionReplyCache` pattern;
|
||||
- DMs through Discord user ids in `PlatformUser.ExternalUserId`;
|
||||
- non-fatal DM failures with warning logs;
|
||||
- Discord-friendly rendering, not raw Telegram HTML;
|
||||
- persistence of Discord schedule and notification message refs in `platform_messages` where later updates need them.
|
||||
|
||||
The current Discord reschedule deadline service directly uses `RestClient` for vote and schedule message edits. This should be folded into `DiscordPlatformMessenger` so deadline services and future platform handlers do not need to know Discord API details.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. `SessionSchedulerService.TickAsync` asks `ISessionTriggerStore` for due confirmation, one-hour reminder, and join-link session ids.
|
||||
2. Each handler loads the session, group platform identity, message refs, participants, RSVP state, and notification mode.
|
||||
3. The handler builds a semantic platform notification DTO and calls `IPlatformMessenger`.
|
||||
4. The messenger renders and sends/updates platform messages.
|
||||
5. The handler persists sent message ids where required, using legacy `sessions.confirmation_message_id` and `sessions.link_message_id` for Telegram and `platform_messages` for Discord refs that need later updates.
|
||||
6. Telegram callback queries and Discord component interactions both call the same platform-neutral RSVP handler.
|
||||
7. Reschedule deadline services use `RescheduleVotingFinalizer`, then call `IPlatformMessenger` for vote message updates, schedule updates, and direct result notifications.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- A failed trigger query still logs and lets the scheduler continue to the next trigger category.
|
||||
- A failed send/update for one session logs and does not stop other sessions in the same tick.
|
||||
- DM failures are warning-level and non-fatal for Telegram and Discord.
|
||||
- A missing platform message ref logs a warning and skips only the update that needs the ref.
|
||||
- Unsupported platform values throw at the messenger boundary, not inside scheduler orchestration.
|
||||
- If Discord cannot parse a stored channel, message, or user id, it logs the bad external id and skips that platform send/update.
|
||||
|
||||
## Testing
|
||||
|
||||
Use TDD for implementation.
|
||||
|
||||
Focused tests should cover:
|
||||
|
||||
- `IPlatformMessenger` exposes semantic notification methods without referencing Telegram or Discord assemblies from `GmRelay.Shared`.
|
||||
- `SendConfirmationHandler`, `SendOneHourReminderHandler`, `SendJoinLinkHandler`, `HandleRsvpHandler`, and reschedule deadline services do not call `ITelegramBotClient`, `BatchMessageEditor`, or Discord `RestClient` directly for notification output.
|
||||
- Telegram source/regression tests preserve thread ids, callback payloads, message id persistence, and direct notification mode behavior.
|
||||
- Discord source tests verify registration of scheduler handlers, RSVP component routes, and messenger methods.
|
||||
- RSVP flow tests run through platform-neutral `PlatformUser` identity, including Discord users without Telegram ids.
|
||||
- Discord messenger tests verify DMs are attempted, DM failures are swallowed after logging, channel notifications include buttons or mentions as appropriate, and message refs are returned.
|
||||
- Full regression: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`, `dotnet build`, and `dotnet format --verify-no-changes --verbosity diagnostic`.
|
||||
|
||||
## Versioning
|
||||
|
||||
Current repository version is `2.6.0`. Although the Gitea issue is labeled `type:refactor`, the approved scope adds full Discord notification behavior. Proposed bump: `2.6.0` to `2.7.0`.
|
||||
|
||||
Synchronize:
|
||||
|
||||
- `Directory.Build.props`
|
||||
- `compose.yaml` image tags for bot, discord, and web
|
||||
- `.gitea/workflows/deploy.yml` `VERSION`
|
||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Moving the entire scheduler hosted service into `GmRelay.Shared`.
|
||||
- Removing legacy Telegram columns such as `telegram_chat_id`, `confirmation_message_id`, or `link_message_id`.
|
||||
- Reworking Web dashboard Telegram behavior.
|
||||
- Public fallback messages when a Discord DM is blocked.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: every issue acceptance criterion is represented by scheduler handler boundaries, messenger contracts, Telegram behavior preservation, and Discord implementation requirements.
|
||||
- Placeholder scan: no TBD/TODO/fill-in-later sections remain.
|
||||
- Internal consistency: the design uses semantic platform DTOs consistently and keeps SDK-specific rendering in platform implementations.
|
||||
- Scope check: the work is large but still one coherent platform-notification refactor; moving the whole scheduler to shared remains explicitly out of scope.
|
||||
@@ -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<HandleRsvpHandler> 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<bool>(
|
||||
"""
|
||||
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<SessionContext>(
|
||||
"""
|
||||
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<string>(
|
||||
"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<RsvpCounts>(
|
||||
"""
|
||||
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<ParticipantRsvp>(
|
||||
"""
|
||||
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<string>
|
||||
{
|
||||
$"🎲 Подтвердите участие в «{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;
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
|
||||
/// Called by SessionSchedulerService at T-24h.
|
||||
/// </summary>
|
||||
public sealed class SendConfirmationHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<SendConfirmationHandler> 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<SessionInfo>(
|
||||
"""
|
||||
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<ParticipantInfo>(
|
||||
"""
|
||||
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 = $"""
|
||||
🎲 <b>Подтвердите участие в игре</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||
📅 {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;
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the join link to the group chat at T-5min, tagging all confirmed players.
|
||||
/// Called by SessionSchedulerService.
|
||||
/// </summary>
|
||||
public sealed class SendJoinLinkHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<SendJoinLinkHandler> 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<JoinLinkSession>(
|
||||
"""
|
||||
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<ConfirmedPlayer>(
|
||||
"""
|
||||
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 = $"""
|
||||
🎮 <b>Игра начинается через 5 минут</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||
🔗 {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);
|
||||
}
|
||||
}
|
||||
+28
-44
@@ -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<RescheduleVotingDeadlineService> 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
|
||||
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {result.SelectedOption.DisplayOrder}: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||
: $"❌ <b>Голосование завершено.</b>\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<DirectNotificationRecipient> recipients,
|
||||
IReadOnlyList<PlatformUser> recipients,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var htmlText = result.SelectedOption is not null
|
||||
? $"""
|
||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||
📅 Новое время: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
|
||||
"""
|
||||
: $"""
|
||||
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||
📅 Время остаётся прежним: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
Причина: {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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlatformMessageRef> 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<PlatformMessageRef> 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
|
||||
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {update.SelectedOption.DisplayOrder}: <b>{update.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||
: $"❌ <b>Голосование завершено.</b>\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<Message> 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<string>
|
||||
{
|
||||
$"🎲 Подтвердите участие в «{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($" ❌ <s>{FormatTelegramParticipant(participant)}</s>");
|
||||
}
|
||||
|
||||
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 => $"""
|
||||
🎲 <b>Подтвердите участие в игре</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
||||
|
||||
Ответьте кнопкой в групповом сообщении расписания.
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
|
||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.JoinLink => $"""
|
||||
🎮 <b>Игра начинается через 5 минут</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 Новое время: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.RescheduleRejected => $"""
|
||||
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 Время остаётся прежним: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)}
|
||||
""",
|
||||
_ => BuildFallbackDirectText(notification)
|
||||
};
|
||||
|
||||
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
|
||||
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ITelegramBotClient>(sp =>
|
||||
});
|
||||
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
|
||||
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
|
||||
|
||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
|
||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||
@@ -85,7 +86,7 @@ builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||
builder.Services.AddHostedService<TelegramBotService>();
|
||||
|
||||
// ── Clock and scheduling ──────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
|
||||
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
|
||||
// ── Session scheduler ────────────────────────────────────────────────
|
||||
|
||||
@@ -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, )"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:Use
|
||||
# Stage 2: Runtime
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Install wget for healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
USER $APP_UID
|
||||
ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"]
|
||||
|
||||
+67
-54
@@ -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<DiscordRescheduleVotingDeadlineService> 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<PlatformMessageRefDto>(
|
||||
"""
|
||||
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<ButtonProperties>())
|
||||
{
|
||||
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<PlatformMessageRefDto>(
|
||||
"""
|
||||
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<SessionBatchDto>(
|
||||
"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<ParticipantBatchDto>(
|
||||
"""
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||
@@ -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);
|
||||
|
||||
@@ -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<DiscordPlatformMessenger>? logger;
|
||||
|
||||
public DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies)
|
||||
: this(restClient, interactionReplies, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordPlatformMessenger>? logger)
|
||||
{
|
||||
this.restClient = restClient;
|
||||
this.interactionReplies = interactionReplies;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> 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<PlatformMessageRef> 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<PlatformMessageRef> 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<ButtonProperties>())
|
||||
{
|
||||
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<PlatformSessionParticipant> 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<ActionRowProperties> 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Health;
|
||||
|
||||
public sealed class DiscordHealthCheckHostedService : IHostedService
|
||||
{
|
||||
private readonly ILogger<DiscordHealthCheckHostedService> _logger;
|
||||
private readonly string _prefix;
|
||||
private HttpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _listenerTask;
|
||||
|
||||
public DiscordHealthCheckHostedService(
|
||||
ILogger<DiscordHealthCheckHostedService> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8082/")!;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add(_prefix);
|
||||
_listener.Start();
|
||||
|
||||
_logger.LogInformation("Discord health check server started on {Prefix}", _prefix);
|
||||
|
||||
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_listener?.Stop();
|
||||
|
||||
if (_listenerTask != null)
|
||||
{
|
||||
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
|
||||
}
|
||||
|
||||
_listener?.Close();
|
||||
_logger.LogInformation("Discord health check server stopped");
|
||||
}
|
||||
|
||||
private async Task ListenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _listener.GetContextAsync();
|
||||
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
|
||||
}
|
||||
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in Discord health check listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
var response = context.Response;
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
if (request.Url?.AbsolutePath == "/health")
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.OK;
|
||||
response.ContentType = "application/json";
|
||||
var body = "{\"status\":\"healthy\"}"u8.ToArray();
|
||||
await response.OutputStream.WriteAsync(body);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling Discord health check request");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,16 @@ using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Infrastructure;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Infrastructure.Health;
|
||||
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,8 +61,20 @@ builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord));
|
||||
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
|
||||
@@ -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, )"
|
||||
}
|
||||
|
||||
@@ -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<HandleRsvpHandler> 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<bool>(
|
||||
"""
|
||||
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<RsvpSessionContext>(
|
||||
"""
|
||||
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<RsvpCounts>(
|
||||
"""
|
||||
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<ParticipantRsvpRow>(
|
||||
"""
|
||||
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<IEnumerable<PlatformUser>> GetGmRecipientsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid groupId,
|
||||
NpgsqlTransaction transaction)
|
||||
{
|
||||
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
||||
"""
|
||||
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<PlatformKind>(platform, ignoreCase: true);
|
||||
}
|
||||
+5
-5
@@ -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,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
namespace GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
|
||||
public interface ISendConfirmationHandler
|
||||
{
|
||||
@@ -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<SendConfirmationHandler> logger) : ISendConfirmationHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var session = await connection.QuerySingleOrDefaultAsync<ConfirmationSessionRow>(
|
||||
"""
|
||||
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<ConfirmationParticipantRow>(
|
||||
"""
|
||||
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<PlatformKind>(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
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Shared.Features.Notifications;
|
||||
|
||||
public sealed class PlatformDirectNotificationSender(
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<PlatformDirectNotificationSender> logger)
|
||||
{
|
||||
public async Task SendAsync(
|
||||
PlatformDirectSessionNotificationKind kind,
|
||||
IEnumerable<PlatformUser> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
namespace GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
|
||||
public interface ISendJoinLinkHandler
|
||||
{
|
||||
@@ -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<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSessionRow>(
|
||||
"""
|
||||
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<JoinLinkPlayerRow>(
|
||||
"""
|
||||
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<PlatformKind>(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
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
|
||||
public interface ISendOneHourReminderHandler
|
||||
{
|
||||
+38
-18
@@ -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<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
|
||||
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSessionRow>(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
@@ -46,10 +54,12 @@ public sealed class SendOneHourReminderHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
||||
"""
|
||||
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 = $"""
|
||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||
📅 {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<PlatformKind>(platform, ignoreCase: true);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||
<PackageReference Include="Dapper.AOT" Version="1.0.48" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
+40
-17
@@ -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<IReadOnlyList<Guid>> 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<Guid>(
|
||||
"""
|
||||
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<Guid>(
|
||||
"""
|
||||
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<Guid>(
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Infrastructure.Scheduling;
|
||||
|
||||
public sealed record PlatformSchedulerOptions(PlatformKind Platform);
|
||||
+7
-14
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SessionSchedulerService(
|
||||
ISessionTriggerStore triggerStore,
|
||||
@@ -50,10 +47,6 @@ public sealed class SessionSchedulerService(
|
||||
logger.LogInformation("Session scheduler stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a single scheduler tick using the current clock time.
|
||||
/// Public so it can be called from integration tests with a fake clock.
|
||||
/// </summary>
|
||||
public async Task TickAsync(CancellationToken ct)
|
||||
{
|
||||
var now = clock.UtcNow;
|
||||
@@ -13,4 +13,22 @@ public interface IPlatformMessenger
|
||||
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
|
||||
|
||||
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
|
||||
|
||||
Task<PlatformMessageRef> 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<PlatformMessageRef> 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.");
|
||||
}
|
||||
|
||||
@@ -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<PlatformMessageAction> 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<PlatformSessionParticipant> Participants,
|
||||
PlatformMessageRef? ExistingMessage = null);
|
||||
|
||||
public sealed record PlatformJoinLinkNotification(
|
||||
PlatformGroup Group,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string JoinLink,
|
||||
IReadOnlyList<PlatformSessionParticipant> 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<PlatformUser> 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<RescheduleOptionDto> Options,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> Votes,
|
||||
IReadOnlyList<VoteParticipantDto> Participants);
|
||||
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v2.6.0</div>
|
||||
<div class="nav-version">v2.7.1</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
@@ -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.1", 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,39 @@ public sealed class DiscordProjectStructureTests
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
|
||||
Assert.Contains("<Version>2.6.0</Version>", 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("<Version>2.7.1</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 2.7.1", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:2.7.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:2.7.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:2.7.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v2.6.0",
|
||||
"v2.7.1",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvExample_ShouldContainDiscordBotToken()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var envExample = File.ReadAllText(Path.Combine(repoRoot, ".env.example"));
|
||||
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", envExample);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_ShouldIncludeDiscordHealthcheck()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
|
||||
|
||||
var discordIndex = compose.IndexOf("discord:", StringComparison.Ordinal);
|
||||
Assert.True(discordIndex >= 0, "compose.yaml should contain discord service");
|
||||
|
||||
var nextServiceIndex = compose.IndexOf(" web:", StringComparison.Ordinal);
|
||||
var discordBlock = compose[discordIndex..nextServiceIndex];
|
||||
|
||||
Assert.Contains("healthcheck:", discordBlock);
|
||||
Assert.Contains("test:", discordBlock);
|
||||
Assert.Contains("localhost:8082/health", discordBlock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -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<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
@@ -82,6 +82,17 @@ 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<SessionSchedulerService>", program);
|
||||
Assert.Contains("HandleRsvpHandler", program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterDiscordHealthCheckHostedService()
|
||||
{
|
||||
var program = ReadProgram();
|
||||
Assert.Contains("DiscordHealthCheckHostedService", program);
|
||||
Assert.Contains("AddHostedService<DiscordHealthCheckHostedService>", program);
|
||||
}
|
||||
|
||||
private static string ReadProgram()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using GmRelay.DiscordBot.Infrastructure.Health;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Infrastructure.Health;
|
||||
|
||||
public sealed class DiscordHealthCheckHostedServiceTests : IDisposable
|
||||
{
|
||||
private readonly DiscordHealthCheckHostedService _service;
|
||||
private readonly int _port;
|
||||
|
||||
public DiscordHealthCheckHostedServiceTests()
|
||||
{
|
||||
_port = GetAvailablePort();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["HealthCheck:Prefix"] = $"http://localhost:{_port}/"
|
||||
})
|
||||
.Build();
|
||||
|
||||
_service = new DiscordHealthCheckHostedService(
|
||||
NullLogger<DiscordHealthCheckHostedService>.Instance,
|
||||
config);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service.StopAsync(CancellationToken.None).Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ShouldReturn200_WhenServiceIsRunning()
|
||||
{
|
||||
await _service.StartAsync(CancellationToken.None);
|
||||
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
||||
var response = await client.GetAsync($"http://localhost:{_port}/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static int GetAvailablePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -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<SessionSchedulerService>", 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<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<Guid>>(SessionsNeedingJoinLink);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSystemClock : ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
+20
@@ -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<string> ReadRepositoryFileAsync(string relativePath)
|
||||
|
||||
+11
-8
@@ -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<string> ReadRepositoryFileAsync(string relativePath)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user