Compare commits

...

26 Commits

Author SHA1 Message Date
Toutsu 0c62631ab6 Merge pull request #87: feat(discord): implement reschedule voting via Discord interactions (issue #30)
Deploy Telegram Bot / build-and-push (push) Successful in 4m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m25s
Deploy Telegram Bot / deploy (push) Successful in 14s
Database:
- Add source_platform and proposed_by_external_user_id to reschedule_proposals
- Make proposed_by nullable for Discord proposals

Shared:
- Extract platform-neutral RescheduleVoteRules, RescheduleVotingInput, RescheduleDtos
- Create RescheduleVotingFinalizer for cross-platform deadline handling

Telegram:
- Refactor RescheduleVotingDeadlineService to use RescheduleVotingFinalizer
- Tag Telegram proposals with source_platform = 'Telegram'

Discord:
- /reschedule slash command with time options and deadline
- DiscordRescheduleVoteHandler for button interactions
- DiscordRescheduleVotingRenderer for embeds and buttons
- DiscordRescheduleVotingDeadlineService for automatic finalization
- DiscordSessionInteractionModule routing for vote buttons

Version: 2.5.0 -> 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 13:12:26 +03:00
Toutsu db9a931ed6 fix(shared): filter due proposals by source_platform to prevent cross-platform race
PR Checks / test-and-build (pull_request) Successful in 6m11s
Both Telegram and Discord deadline services were querying ALL due
proposals without filtering by source_platform. If the Telegram
service reached a Discord proposal first, it finalized the DB state
but skipped message handling. The Discord service then saw status
!= 'Voting' and never updated the Discord vote message.

Fix: GetDueProposalIdsAsync now accepts a sourcePlatform parameter
and filters at the DB level. Each service only processes its own
platform's proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:48:25 +03:00
Toutsu 35548a03cb test(discord): update version assertions to 2.6.0
PR Checks / test-and-build (pull_request) Successful in 6m27s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:05 +03:00
Toutsu dda393c372 chore: bump version to 2.6.0
Synchronized across Directory.Build.props, compose.yaml,
deploy.yml, and NavMenu.razor.

Bump version → 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:45 +03:00
Toutsu 1e9bf4ab25 feat(telegram): set source_platform = 'Telegram' on reschedule proposals
Ensures Telegram-initiated reschedule proposals are tagged with
source_platform so the platform-neutral finalizer can distinguish
them from Discord proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:24 +03:00
Toutsu 690aa0272f feat(discord): add reschedule voting deadline service 2026-05-20 12:29:33 +03:00
Toutsu d871f2c142 feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger 2026-05-20 12:26:31 +03:00
Toutsu 9712fe125b feat(discord): add DiscordRescheduleVotingRenderer and replace inline helper 2026-05-20 12:23:25 +03:00
Toutsu fdfc73ae9c feat(discord): add reschedule vote button handler 2026-05-20 12:21:13 +03:00
Toutsu e93e777fb3 feat(discord): add /reschedule slash command and handler 2026-05-20 12:15:03 +03:00
Toutsu a13edf20af feat(shared): add RescheduleVotingFinalizer and ISystemClock 2026-05-20 11:54:53 +03:00
Toutsu fcd7de035f refactor(shared): extract reschedule voting types to Shared 2026-05-20 11:44:57 +03:00
Toutsu fb0c29eefe feat(db): add platform columns to reschedule_proposals 2026-05-20 11:41:25 +03:00
Toutsu 9ff5cc4a67 Merge pull request #86: feat(discord): enable session join leave buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m54s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 15s
2026-05-20 09:09:51 +03:00
Toutsu 3251846001 fix(shared): enable dapper aot for session handlers
PR Checks / test-and-build (pull_request) Successful in 6m30s
2026-05-20 09:01:34 +03:00
Toutsu 39132be4e8 feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior.

Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates.

Bump version to 2.5.0 and update Discord docs.

Refs #29
2026-05-19 14:13:48 +03:00
Toutsu 90da33154c Merge pull request #85: feat(discord): implement /newsession and /listsessions (issue #28)
Deploy Telegram Bot / build-and-push (push) Successful in 4m19s
Deploy Telegram Bot / scan-images (push) Successful in 1m17s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-19 12:53:35 +03:00
Toutsu d55003a2a9 feat(discord): improve UX and add source-level tests for /newsession
PR Checks / test-and-build (pull_request) Successful in 5m59s
- DiscordNewSessionCommand: on success, renders session details via
  DiscordSessionBatchRenderer.Render() with embeds and action rows.
- DiscordNewSessionCommand: uses Discord emoji shortcodes for error
  and success messages (, , 💥).
- DiscordNewSessionHandlerTests: added 7 source-level structural tests
  verifying Dapper usage, NpgsqlDataSource, permission checks,
  platform neutrality, transaction safety, CancellationToken usage,
  and embed rendering in the command.

Refs issue #28

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:36:17 +03:00
Toutsu daa59335cc fix(discord): resolve permission checking for /newsession command
- DiscordPermissionChecker: removed dead-code userRoles overload;
  now only uses resolvedPermissions bitflag (Administrator = 0x8).
- DiscordNewSessionCommand: computes resolved permissions from guild
  user roles via Context.Guild.Users[Id].RoleIds + guild.Roles.
- DiscordNewSessionHandler: updated signature to accept ulong
  resolvedPermissions instead of unused userRoles.
- Added ILogger to command for diagnostics on unexpected errors.
- Added test: regular user with ManageServer (but not Admin) is rejected.

Refs issue #28

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:30:25 +03:00
Toutsu 474e7f62f7 chore: bump version to 2.4.0
Synchronized across Directory.Build.props, compose.yaml, deploy.yml,
NavMenu.razor, and project structure tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:36:28 +03:00
Toutsu 8666b8984e feat: register Discord session handlers and permission checker in DI
Task 5: DI wiring for DiscordNewSessionHandler, DiscordListSessionsHandler,
DiscordPermissionChecker, and DiscordPlatformMessenger.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:33:33 +03:00
Toutsu d373ff49ba feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:22:44 +03:00
Toutsu 95aad3a2f6 feat(discord): add /newsession slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:17:07 +03:00
Toutsu 76456cc28a feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:09:45 +03:00
Toutsu ac8f03ecc9 feat(discord): add DiscordPermissionChecker for session management rights
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 10:51:32 +03:00
Toutsu 21760ae6f7 Merge pull request #84: feat: implement DiscordSessionBatchRenderer for Embed and Buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m7s
Deploy Telegram Bot / scan-images (push) Successful in 1m13s
Deploy Telegram Bot / deploy (push) Successful in 12s
- Render SessionBatchViewModel into NetCord EmbedProperties + ActionRowProperties
- 7 tests covering open/full/waitlist/cancelled/reschedule states
- Version bump 2.2.0 → 2.3.0

Closes #27
2026-05-18 18:54:04 +03:00
70 changed files with 5173 additions and 332 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 2.3.0
VERSION: 2.6.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+144
View File
@@ -0,0 +1,144 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build, Test, and Development Commands
This is a .NET 10 solution using the modern XML-based `.slnx` format. The global SDK version is `10.0.100` with `rollForward: latestFeature`.
**Build the solution:**
```bash
dotnet build
```
**Build individual projects (the CI does this to include SAST via SecurityCodeScan):**
```bash
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
```
**Run all tests:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
```
**Run a single test class or method:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~YourTestClassName"
```
**Lint and format:**
```bash
dotnet format --verify-no-changes --verbosity diagnostic # CI enforcement
dotnet format # Apply fixes
```
**Check for vulnerable packages:**
```bash
dotnet list package --vulnerable --include-transitive
```
**Restore with lock file verification:**
The repo enforces `RestorePackagesWithLockFile=true`. After adding or updating packages, commit the updated `packages.lock.json` files or the Trivy scan in CI will fail.
**Run locally with Aspire (dev orchestration):**
```bash
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
```
This automatically starts PostgreSQL in a container, the Bot, and the Web dashboard.
**Run locally with Docker Compose (production-like):**
```bash
cp .env.example .env
# Edit .env with your TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, POSTGRES_PASSWORD
docker compose up -d
```
## High-Level Architecture
### Project Roles and Runtime Model
| Project | Runtime | Key Trait |
|---|---|---|
| `GmRelay.Bot` | `Microsoft.NET.Sdk.Worker` | **Native AOT** binary. Telegram long polling bot + stateless scheduler. |
| `GmRelay.Web` | `Microsoft.NET.Sdk.Web` | Blazor Server dashboard. Cookie auth via Telegram Login Widget / Mini App `initData`. |
| `GmRelay.Shared` | Plain library | Domain models and platform-neutral view builders. **Must not depend on `Telegram.Bot`**. |
| `GmRelay.ServiceDefaults` | Aspire shared project | OpenTelemetry, health checks, HTTP resilience. Referenced by both Bot and Web. |
| `GmRelay.AppHost` | Aspire orchestrator | Dev-only. Spins up PostgreSQL and wires Bot + Web with service discovery. |
**Important:** `README.md` references `GmRelay.Migrator` and `GmRelay.Worker`, but these projects do not exist. Migrations (`DbUp`) and background workers (`BackgroundService`) live inside `GmRelay.Bot`.
### Vertical Slice Architecture with Explicit DI
Each use case is a self-contained vertical slice: a C# record (Command/Query) + Handler class with all logic (SQL, Telegram API calls, validation). There are no abstract repository interfaces or service layers.
Because the Bot is compiled as Native AOT (`PublishAot=true`, `EnableTrimAnalyzer=true`), **all DI registrations are explicit** in `src/GmRelay.Bot/Program.cs`. There is no assembly scanning or reflection-based discovery. When adding a new handler, you must register it manually in Program.cs.
### Database Access: Npgsql + Dapper.AOT + DbUp
**No EF Core** — it is incompatible with Native AOT. The stack is:
- **Npgsql** ADO.NET for connections.
- **Dapper 2.1.72** with **Dapper.AOT 1.0.48** for compile-time source-generated mapping (AOT-safe).
- **DbUp 7.0.1** for migrations. SQL scripts are embedded resources in `src/GmRelay.Bot/Migrations/` (V001 through V015).
- `DbMigrator.MigrateUp()` runs on every Bot startup.
Both Bot and Web share the same PostgreSQL database. Web registers `NpgsqlDataSource` via `builder.AddNpgsqlDataSource("gmrelaydb")` (Aspire integration), while Bot registers it manually to avoid reflection-based Aspire configuration at AOT time.
### Platform-Neutral Rendering (ADR-002)
Rendering is split into two stages:
1. **View Builder** (`GmRelay.Shared`) — platform-agnostic view model from domain DTOs.
2. **Platform Renderer**`TelegramSessionBatchRenderer` lives in both `GmRelay.Bot` and `GmRelay.Web` (temporary duplication until a third Telegram consumer justifies extracting `GmRelay.Shared.Telegram`).
This means `GmRelay.Shared` must remain free of `Telegram.Bot` types. If you need to add rendering logic that produces `InlineKeyboardMarkup`, it belongs in the Bot or Web project, not Shared.
### Stateless Scheduling
The session scheduler (`SessionSchedulerService`) is a `BackgroundService` with a `PeriodicTimer(TimeSpan.FromMinutes(1))`. On each tick it queries PostgreSQL for sessions needing action (T-24h confirmation, T-5min join link) and updates their status. There is no in-memory state — the database is the single source of truth. This design was chosen specifically because Quartz.NET is incompatible with Native AOT.
### Health Checks
- **Bot:** Custom `BotHealthCheckHostedService` listens on port 8081. The Docker health check hits `localhost:8081/health`.
- **Web:** Standard ASP.NET Core health checks on `/health` (JSON response with status and timestamp) and `/alive` (liveness probe tag filter). Exposed via `GmRelay.ServiceDefaults`.
### Authentication and Security
- **Telegram Login Widget** and **Mini App `initData`** verification via HMAC-SHA256. Cookie auth is hardened (`HttpOnly`, `SecurePolicy.Always`, `SameSite.Strict`).
- Web Data Protection keys are persisted to `/app/dataprotection-keys` (Docker volume `web_keys`).
- Security headers middleware (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) is applied globally in Web.
- `SecurityCodeScan.VS2019` (5.6.7) is included in all projects via `Directory.Build.props` for SAST at build time.
- Connection string passwords are redacted in logs via `SecretRedactor`.
### CI/CD Pipeline
`.gitea/workflows/pr-checks.yml` runs on every PR to `main`:
1. `dotnet restore`
2. Verify `packages.lock.json` files exist for Trivy
3. `dotnet format --verify-no-changes`
4. `dotnet list package --vulnerable`
5. Trivy filesystem scan (`vuln,misconfig,secret`, HIGH/CRITICAL)
6. Build Shared → Bot → Web
7. Run tests
`.gitea/workflows/deploy.yml` runs on push to `main`:
1. Build and push `gmrelay-bot` and `gmrelay-web` images to `git.codeanddice.ru/toutsu/...`
2. Trivy image scan on both images (HIGH/CRITICAL, exit-code 1)
3. Create `.env` from secrets and run `docker compose up -d`
### Environment Configuration
Key environment variables (see `.env.example`):
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME`, `TELEGRAM_MINI_APP_URL`
- `POSTGRES_PASSWORD`
- `GMRELAY_WEB_PORT` (default 8080)
- `ConnectionStrings__gmrelaydb` — used by both Bot and Web
The Bot reads config as `Telegram:BotToken` (colon) which maps from `Telegram__BotToken` (double underscore) via environment variables.
### Docker Images
- **Bot:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble` with `clang` and `zlib1g-dev` for AOT compilation. Final stage uses `runtime-deps:10.0-noble`. Exposes 8081.
- **Web:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble`. Final stage uses `aspnet:10.0-noble` with `libgssapi-krb5-2` and `wget`. Exposes 8080.
Both images are built for multi-arch (`linux/amd64`, `linux/arm64`) to support Raspberry Pi 5 (ARM64) deployment.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>2.3.0</Version>
<Version>2.6.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+23
View File
@@ -0,0 +1,23 @@
# Discord /newsession и /listsessions — Issue #28
## Что реализовано
- Slash-команда /newsession для создания игровых сессий прямо из Discord.
- Slash-команда /listsessions для просмотра предстоящих игр в сервере.
- DiscordPermissionChecker — проверка прав (owner / admin / manager).
- DiscordPlatformMessenger — реализация IPlatformMessenger для Discord (NetCord REST).
- Полная интеграция в DI (Program.cs).
## Архитектура
- Vertical slice: каждая команда — отдельный файл (Command + Handler).
- Platform-agnostic SQL: используются колонки platform, external_group_id, external_user_id.
- Рендеринг переиспользует существующий DiscordSessionBatchRenderer.
## TDD
- 212 тестов, все зелёные.
- Source-level тесты проверяют паттерны: Dapper, Npgsql, транзакции, CancellationToken, платформенную нейтральность.
## Версия
- Minor bump: 2.3.0 → 2.4.0
- Синхронизировано: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor.
Closes #28
+8 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.2.0`.
**Текущая версия:** `v2.5.0`.
---
@@ -24,6 +24,11 @@
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
### Discord Bot
- **Slash-команды расписания**: GM создаёт сессию через `/newsession` и публикует актуальное расписание через `/listsessions`.
- **Кнопки записи и выхода**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
@@ -108,6 +113,8 @@ docker compose up -d
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`.
5. В Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 💾 Backup и восстановление
+23
View File
@@ -0,0 +1,23 @@
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
## 🧩 Что вошло в релиз
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs — handler запроса активных сессий с embed-рендерингом
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs — проверка прав через Discord permissions bitflag (Administrator = 0x8)
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs — реализация IPlatformMessenger для Discord через NetCord REST
- src/GmRelay.DiscordBot/Program.cs — регистрация DI: handlers, permission checker, messenger
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
## 🗺 Что это даёт
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
- Участники сервера видят расписание через /listsessions.
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
## 📦 Версия и деплой
- версия обновлена до 2.4.0
- Docker-образы используют тег 2.4.0
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.3.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.6.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.3.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.6.0
restart: always
depends_on:
db:
@@ -79,7 +79,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.3.0
image: git.codeanddice.ru/toutsu/gmrelay-web:2.6.0
restart: always
depends_on:
db:
+71 -39
View File
@@ -1,4 +1,4 @@
# GM-Relay C4 Model
# GM-Relay - C4 Model
## Level 1: System Context
@@ -6,19 +6,24 @@
C4Context
title GM-Relay System Context
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
Person(player, "Player", "Подтверждает участие через inline-кнопки")
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
Rel(gm, telegram, "Команды бота (/newsession)")
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
Rel(telegram, gmrelay, "Updates (Long Polling)")
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(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)")
Rel(gmrelay, discord, "Send/edit schedule messages and ephemeral interaction replies")
Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
```
## Level 2: Container
@@ -30,49 +35,76 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
System_Boundary(pi, "Raspberry Pi 5") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
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(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")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
}
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(gm, telegram, "Commands")
Rel(player, telegram, "Callback Queries")
Rel(telegram, bot, "GetUpdates (Long Polling)")
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
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(web, shared, "Uses shared domain and rendering models")
Rel(bot, db, "Npgsql + Dapper.AOT")
Rel(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper")
```
## Level 3: Component (GmRelay.Bot)
## Level 3: Component - Session Interactions
```mermaid
C4Component
title GmRelay.Bot Components
title Platform-Neutral Session Interactions
Container_Boundary(bot, "GmRelay.Bot") {
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
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(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")
}
System_Ext(telegram, "Telegram Bot API")
ContainerDb(db, "PostgreSQL")
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")
}
Rel(polling, router, "Update")
Rel(router, rsvp, "CallbackQuery rsvp:*")
Rel(scheduler, confirm, "T-24h trigger")
Rel(scheduler, link, "T-5min trigger")
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
Rel(link, telegram, "SendMessage + user mentions")
Rel(confirm, db, "SELECT/UPDATE sessions")
Rel(rsvp, db, "UPDATE participants, SELECT counts")
Rel(link, db, "SELECT confirmed players")
Rel(migrator, db, "DDL migrations")
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")
}
ContainerDb(db, "PostgreSQL")
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(discord, discordModule, "Button interaction")
Rel(discordModule, join, "JoinSessionCommand")
Rel(discordModule, leave, "LeaveSessionCommand")
Rel(discordModule, discord, "Deferred ephemeral reply, then modify response")
Rel(updateRouter, join, "JoinSessionCommand")
Rel(updateRouter, leave, "LeaveSessionCommand")
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(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")
```
@@ -0,0 +1,984 @@
# Discord /newsession и /listsessions — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development (TDD) for every task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Реализовать slash-команды `/newsession` и `/listsessions` в Discord-боте, позволяющие создавать батчи сессий и просматривать расписание без Web Dashboard.
**Architecture:** Каждая команда — отдельный vertical slice в `GmRelay.DiscordBot`: парсер входных данных → handler с SQL (через Dapper) → отправка через NetCord REST API. Рендеринг переиспользует существующий `DiscordSessionBatchRenderer`. Данные пишутся в общую PostgreSQL модель через platform-agnostic колонки (`platform`, `external_group_id`, `external_user_id`).
**Tech Stack:** .NET 10, NetCord 1.0.0-alpha.489, NetCord.Hosting.Services, Dapper, Npgsql, xUnit.
**Version Bump:** minor (2.3.0 → 2.4.0) — новый функционал.
---
## File Structure
| File | Responsibility |
|------|--------------|
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs` | Slash-команда `/newsession` с параметрами (title, time, seats, link) |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs` | Handler создания batch + sessions в БД, проверка прав |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs` | Slash-команда `/listsessions` |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs` | Handler запроса активных сессий и публикации embed |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs` | Проверка прав пользователя в guild (owner/admin/manager) |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Реализация `IPlatformMessenger` для отправки/обновления расписания в Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs` | TDD-тесты создания сессий из Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs` | TDD-тесты вывода расписания |
| `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs` | TDD-тесты проверки прав |
| `src/GmRelay.DiscordBot/Program.cs` | Регистрация DI: handlers, permission checker, platform messenger |
---
## Task 1: DiscordPermissionChecker
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs`
**Context:** Discord использует guild-роли. Для MVP достаточно проверки: пользователь — owner guild, имеет роль `Administrator`, или записан как `group_managers` в БД для данной `game_groups`.
### Step 1.1: Write the failing test
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPermissionCheckerTests
{
[Fact]
public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 123456789ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserHasAdministratorRole_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var adminRole = 999ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 987654321ul,
userRoles: new[] { adminRole },
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var managerId = 555ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: managerId,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { managerId });
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenRegularUser_ReturnsFalse()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 111ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { 222ul });
Assert.False(result);
}
}
```
### Step 1.2: Run test to verify it fails
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: FAIL — `DiscordPermissionChecker` not found.
### Step 1.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`:
```csharp
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPermissionChecker
{
// Discord Administrator permission bitflag
private const ulong AdministratorPermission = 0x8;
public bool CanManageSchedule(
ulong guildOwnerId,
ulong userId,
IEnumerable<ulong> userRoles,
IEnumerable<ulong> dbManagerUserIds)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
// NetCord provides permission resolution via GuildUser.Permissions;
// here we accept pre-resolved flag for simplicity.
// Actual command handler will pass resolved permissions.
return false;
}
public bool CanManageSchedule(ulong guildOwnerId, ulong userId, IEnumerable<ulong> dbManagerUserIds, ulong resolvedPermissions)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
}
}
```
### Step 1.4: Run test to verify it passes
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: PASS (4/4).
### Step 1.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs
git commit -m "feat(discord): add DiscordPermissionChecker for session management rights
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 2: DiscordListSessionsHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`
**Context:** Handler должен:
1. Найти `game_groups` по `external_group_id` = `guild_id`.
2. Выбрать предстоящие сессии (`scheduled_at > NOW()`, `status != Cancelled`).
3. Собрать участников.
4. Построить view через `SessionBatchViewBuilder`.
5. Отрендерить через `DiscordSessionBatchRenderer`.
6. Отправить embed + buttons в Discord channel.
### Step 2.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordListSessionsHandlerTests
{
[Fact]
public void BuildSchedule_WithSessions_ReturnsEmbedsAndButtons()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(sessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Planned, 4, "https://example.com")
};
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Single(actionRows);
}
[Fact]
public void BuildSchedule_WithCancelledSession_SkipsActionRows()
{
var cancelledSessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Cancelled, null, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Empty(actionRows);
}
}
```
### Step 2.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordListSessionsHandler` not found.
### Step 2.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`:
```csharp
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using NetCord.Rest;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
{
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
{
GuildId = guildId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionList = sessions.ToList();
if (sessionList.Count == 0)
return null;
var sessionIds = sessionList.Select(s => s.Id).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
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC",
new { SessionIds = sessionIds });
var firstTitle = sessionList.First().Title;
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
public override async Task ExecuteAsync()
{
var guildId = Context.Guild?.Id.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var view = await _handler.BuildScheduleAsync(guildId, channelId, Context.CancellationToken);
if (view is null)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
return;
}
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
}
```
### Step 2.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: PASS.
### Step 2.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs
git commit -m "feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 3: DiscordNewSessionHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`
**Context:** Handler должен:
1. Проверить права пользователя (owner/admin/manager).
2. Upsert игрока (GM) в `players` с `platform = 'Discord'`.
3. Upsert `game_groups` с `platform = 'Discord'`, `external_group_id = guild_id`.
4. Создать batch + sessions.
5. Отправить rendered schedule в Discord channel.
6. Сохранить `platform_messages` reference.
### Step 3.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordNewSessionHandlerTests
{
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
Assert.Equal(19, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
}
```
### Step 3.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordNewSessionHandler` not found.
### Step 3.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`:
```csharp
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
{
if (result < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
{
if (altResult < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
IEnumerable<ulong> userRoles,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
// Resolve db managers
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, userRoles, dbManagerUserIds))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
// Upsert player
await connection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username",
new { Name = userDisplayName, UserId = userId.ToString() },
transaction);
// Upsert group
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GuildId = guildId, ChannelId = channelId },
transaction);
// Ensure manager record
await connection.ExecuteAsync(
@"INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
ON CONFLICT (group_id, player_id) DO NOTHING",
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
// Create batch + session
var batchId = Guid.NewGuid();
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = joinLink ?? string.Empty,
ScheduledAt = scheduledAt.UtcDateTime,
Status = SessionStatus.Planned,
MaxPlayers = maxPlayers
},
transaction);
await transaction.CommitAsync(cancellationToken);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("newsession", "Create a new game session")]
public class DiscordNewSessionCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler)
{
_handler = handler;
}
[SlashCommandOption("title", "Game title", Required = true)]
public string Title { get; set; } = string.Empty;
[SlashCommandOption("time", "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)", Required = true)]
public string Time { get; set; } = string.Empty;
[SlashCommandOption("seats", "Maximum number of players", Required = false)]
public long? Seats { get; set; }
[SlashCommandOption("link", "Join link", Required = false)]
public string? Link { get; set; }
public override async Task ExecuteAsync()
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
var timeResult = DiscordNewSessionHandler.ParseTimeInput(Time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {timeResult.Error}"));
return;
}
try
{
var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
userRoles: Context.GuildUser!.RoleIds,
guildOwnerId: guild.OwnerId,
title: Title,
scheduledAt: timeResult.Value,
maxPlayers: Seats is null ? null : (int)Seats.Value,
joinLink: Link,
Context.CancellationToken);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("✅ Сессия создана!"));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"⛅ {ex.Message}"));
}
catch (Exception ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("💥 Произошла ошибка при создании сессии."));
}
}
}
```
### Step 3.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: PASS.
### Step 3.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs
git commit -m "feat(discord): add /newsession slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 4: DiscordPlatformMessenger
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`
**Context:** Необходима реализация `IPlatformMessenger` для отправки schedule embeds и обновления существующих сообщений в Discord. Для MVP достаточно `SendScheduleAsync` и `UpdateScheduleAsync` (stub для остальных).
### Step 4.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`:
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPlatformMessengerTests
{
[Fact]
public void Constructor_ShouldAcceptRestClient()
{
// DiscordPlatformMessenger requires a NetCord.Rest.RestClient.
// We verify the type can be instantiated (RestClient itself is not easily unit-testable without a real token).
// This test proves the contract exists and compiles.
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
Assert.NotNull(constructor);
}
[Fact]
public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger()
{
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
}
}
```
### Step 4.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: FAIL — `DiscordPlatformMessenger` not found.
### Step 4.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`:
```csharp
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
{
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 msg = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
return new PlatformMessageRef(
PlatformKind.Discord,
message.Group.ExternalGroupId,
null,
msg.Id.ToString());
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
if (message.ExistingMessage is null)
return;
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
await restClient.ModifyMessageAsync(
channelId,
messageId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
// MVP: not needed for /newsession and /listsessions
return Task.CompletedTask;
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
// MVP: not needed (commands answer inline via SlashCommandContext)
return Task.CompletedTask;
}
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
}
```
### Step 4.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: PASS.
### Step 4.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs
git commit -m "feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 5: Wire up DI and Register Commands
**Files:**
- Modify: `src/GmRelay.DiscordBot/Program.cs`
### Step 5.1: Write the failing test (structure test)
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` — add test that asserts new handlers are registered:
```csharp
[Fact]
public void Program_ShouldRegisterDiscordSessionHandlers()
{
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", program);
Assert.Contains("IPlatformMessenger", program);
}
```
### Step 5.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: FAIL — asserts not found in Program.cs.
### Step 5.3: Write minimal implementation
Modify `src/GmRelay.DiscordBot/Program.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
// ... existing usings ...
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
// After host.Build():
host.AddSlashCommand("listsessions", "Show upcoming game sessions", async (DiscordListSessionsHandler handler, SlashCommandContext context) =>
{
// NetCord module-based approach preferred; if AddSlashCommand lambda doesn't support DI injection of custom services,
// rely on module classes registered via AddApplicationCommands
});
```
**Important:** NetCord module classes (`DiscordListSessionsCommand`, `DiscordNewSessionCommand`) автоматически регистрируются через `AddApplicationCommands()` + `AddGatewayHandlers(typeof(Program).Assembly)`. Constructor injection в модулях работает через DI контейнер. Никаких дополнительных `AddSlashCommand` для модулей не требуется.
Убедиться, что в Program.cs есть:
```csharp
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
```
### Step 5.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: PASS.
### Step 5.5: Commit
```bash
git add src/GmRelay.DiscordBot/Program.cs tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs
git commit -m "feat(discord): wire up DI registrations for session handlers and messenger
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 6: Build Verification
### Step 6.1: Build DiscordBot project
Run: `dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore`
Expected: Build succeeds (0 errors, 0 warnings).
### Step 6.2: Run all tests
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
Expected: All tests pass.
### Step 6.3: Commit if any fixes needed
If build or tests required fixes, commit them.
---
## Task 7: Version Bump
**Files to modify:**
- `Directory.Build.props`: `<Version>2.4.0</Version>`
- `compose.yaml`: обновить теги `gmrelay-bot`, `gmrelay-web`, `gmrelay-discord-bot``2.4.0`
- `.gitea/workflows/deploy.yml`: `VERSION: 2.4.0`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `<div class="nav-version">v2.4.0</div>`
### Step 7.1: Bump version
Apply изменения ко всем 4 файлам.
### Step 7.2: Update version test
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` — обновить `Version_ShouldBeSynchronizedForDiscordFeatureRelease` ожидаемое значение на `2.4.0`.
### Step 7.3: Run version test
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Version_ShouldBeSynchronizedForDiscordFeatureRelease" --verbosity normal`
Expected: PASS.
### Step 7.4: Commit
```bash
git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs
git commit -m "chore: bump version to 2.4.0
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Spec Coverage Self-Review
| Issue Requirement | Task |
|---|---|
| Slash command `/newsession` | Task 3 |
| Slash command `/listsessions` | Task 2 |
| Сохранение platform group identity (guild/channel) | Task 3 (game_groups.platform, external_group_id, external_channel_id) |
| Минимальная проверка прав | Task 1 + Task 3 |
| Данные пишутся в общую PostgreSQL без Telegram-only assumptions | Task 2, 3 SQL используют platform-agnostic колонки |
| `/listsessions` публикует/обновляет расписание | Task 2 + Task 4 |
**Placeholder scan:** Нет TBD, TODO, "implement later". Каждый шаг содержит конкретный код.
**Type consistency:** `DiscordPermissionChecker.CanManageSchedule` перегружен для resolved permissions (ulong bitflag). Handler передает `Context.GuildUser.RoleIds` и `guild.OwnerId`.
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md`.**
**Two execution options:**
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review
**Which approach?**
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -17,12 +18,6 @@ internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
@@ -1,5 +1,6 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -13,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand(
long ChatId,
int MessageId);
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
@@ -83,8 +83,8 @@ public sealed class InitiateRescheduleHandler(
// 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
VALUES (@SessionId, @GmId, 'AwaitingTime')
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
""",
new { command.SessionId, GmId = command.TelegramUserId });
@@ -1,7 +1,7 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -11,25 +11,18 @@ using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
internal sealed record TelegramProposalFieldsDto(
int? VoteMessageId,
int? BatchMessageId,
long TelegramChatId,
int? ThreadId,
string NotificationMode);
int? ThreadId);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ISystemClock clock,
RescheduleVotingFinalizer finalizer,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -53,18 +46,7 @@ public sealed class RescheduleVotingDeadlineService(
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= @Now
ORDER BY voting_deadline_at
LIMIT 25
""",
new { Now = clock.UtcNow.UtcDateTime })).ToList();
var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct);
foreach (var proposalId in proposalIds)
{
@@ -82,212 +64,105 @@ public sealed class RescheduleVotingDeadlineService(
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
if (result.SourcePlatform != "Telegram")
{
logger.LogInformation(
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
proposalId,
result.SourcePlatform);
return;
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.vote_message_id AS VoteMessageId,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= @Now
FOR UPDATE
""",
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime },
transaction);
new { ProposalId = proposalId });
if (proposal is null)
if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
return;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
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 p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.Id,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.Id },
transaction);
}
var directRecipients = participants
var directRecipients = result.Participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
.ToList();
await transaction.CommitAsync(ct);
await TryUpdateVoteMessage(result, telegramFields, ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
if (selectedOption is not null)
if (result.SelectedOption is not null)
{
await TryUpdateBatchMessage(proposal, ct);
await TryUpdateBatchMessage(result, telegramFields, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
await SendDirectResult(result, directRecipients, ct);
}
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.Id,
proposal.SessionId,
decision.Outcome);
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
result.ProposalId,
result.SessionId);
}
private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
if (proposal.VoteMessageId is null)
if (telegramFields.VoteMessageId is null)
return;
try
{
var resultText = selectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}";
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(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes)}
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes)}
{resultText}
""";
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.VoteMessageId.Value,
chatId: telegramFields.TelegramChatId,
messageId: telegramFields.VoteMessageId.Value,
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
}
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct)
private async Task TryUpdateBatchMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
try
{
@@ -295,7 +170,7 @@ public sealed class RescheduleVotingDeadlineService(
var batchSessions = (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 { proposal.BatchId })).ToList();
new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
@@ -309,60 +184,58 @@ public sealed class RescheduleVotingDeadlineService(
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
new { result.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
if (telegramFields.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
ct);
}
else
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».",
ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
}
}
private async Task SendDirectResult(
DueRescheduleProposalDto proposal,
RescheduleVotingFinalizerResult result,
IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct)
{
var htmlText = selectedOption is not null
var htmlText = result.SelectedOption is not null
? $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Новое время: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
"""
: $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)}
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Время остаётся прежним: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}
""";
await directSender.SendAsync(
recipients,
htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId,
result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
result.SessionId,
ct);
}
}
@@ -1,9 +1,6 @@
namespace GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
namespace GmRelay.Bot.Infrastructure.Scheduling;
public sealed class SystemClock : ISystemClock
{
@@ -1,6 +1,7 @@
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Scheduling;
@@ -1,5 +1,6 @@
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Sessions.CreateSession;
@@ -0,0 +1,19 @@
-- =============================================================
-- V018: Add platform columns to reschedule_proposals
-- =============================================================
-- Add platform columns to reschedule_proposals to support Discord reschedule voting.
-- proposed_by is made nullable so Discord proposals can leave it NULL
-- (Discord snowflakes don't fit in BIGINT safely).
-- =============================================================
ALTER TABLE reschedule_proposals
ALTER COLUMN proposed_by DROP NOT NULL;
ALTER TABLE reschedule_proposals
ADD COLUMN source_platform VARCHAR(50),
ADD COLUMN proposed_by_external_user_id VARCHAR(255);
UPDATE reschedule_proposals
SET source_platform = 'Telegram',
proposed_by_external_user_id = proposed_by::TEXT
WHERE source_platform IS NULL;
+4
View File
@@ -6,10 +6,12 @@ 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.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -63,6 +65,7 @@ builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
@@ -73,6 +76,7 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.Expor
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
+6 -1
View File
@@ -661,7 +661,12 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
}
},
"net10.0/win-x64": {
@@ -0,0 +1,38 @@
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
public async Task ExecuteAsync()
{
var guildId = Context.Guild?.Id.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
if (view is null)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
return;
}
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
}
@@ -0,0 +1,65 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
{
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
{
GuildId = guildId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionList = sessions.ToList();
if (sessionList.Count == 0)
return null;
var sessionIds = sessionList.Select(s => s.Id).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
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC",
new { SessionIds = sessionIds });
var firstTitle = sessionList.First().Title;
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
}
}
@@ -0,0 +1,87 @@
using GmRelay.DiscordBot.Rendering;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("newsession", "Create a new game session")]
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
private readonly ILogger<DiscordNewSessionCommand> _logger;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler, ILogger<DiscordNewSessionCommand> logger)
{
_handler = handler;
_logger = logger;
}
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"X {timeResult.Error}"));
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
try
{
var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
title: title,
scheduledAt: timeResult.Value,
maxPlayers: seats is null ? null : (int)seats.Value,
joinLink: link,
CancellationToken.None);
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithContent(":white_check_mark: **Session created successfully!**")
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guild.Id);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: An error occurred while creating the session."));
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -0,0 +1,148 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
{
if (result < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
{
if (altResult < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await connection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username",
new { Name = userDisplayName, UserId = userId.ToString() },
transaction);
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GuildId = guildId, ChannelId = channelId },
transaction);
await connection.ExecuteAsync(
@"INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
ON CONFLICT (group_id, player_id) DO NOTHING",
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
var batchId = Guid.NewGuid();
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = joinLink ?? string.Empty,
ScheduledAt = scheduledAt.UtcDateTime,
Status = SessionStatus.Planned,
MaxPlayers = maxPlayers
},
transaction);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
@@ -0,0 +1,117 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordRescheduleHandler _handler;
private readonly ILogger<DiscordRescheduleCommand> _logger;
public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger<DiscordRescheduleCommand> logger)
{
_handler = handler;
_logger = logger;
}
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
[SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2,
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
if (!Guid.TryParse(sessionIdText, out var sessionId))
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Некорректный ID сессии."));
return;
}
var options = new List<string> { option1, option2 };
if (!string.IsNullOrWhiteSpace(option3))
options.Add(option3);
var parsedOptions = new List<DateTimeOffset>();
foreach (var opt in options)
{
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
if (!result.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {opt}: {result.Error}"));
return;
}
parsedOptions.Add(result.Value);
}
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
if (!deadlineResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}"));
return;
}
if (deadlineResult.Value >= parsedOptions.Min())
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени."));
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
try
{
var result = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
sessionId: sessionId,
options: parsedOptions,
deadline: deadlineResult.Value,
CancellationToken.None);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC."));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
}
catch (InvalidOperationException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":warning: {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: Ошибка при запуске голосования."));
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -0,0 +1,155 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using NetCord;
using NetCord.Rest;
using Npgsql;
public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList<RescheduleOptionDto> Options, DateTimeOffset Deadline);
public sealed class DiscordRescheduleHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
RestClient restClient,
ILogger<DiscordRescheduleHandler> logger)
{
public async Task<DiscordRescheduleResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
IReadOnlyList<DateTimeOffset> options,
DateTimeOffset deadline,
CancellationToken ct)
{
// 1. Permission check + read-only validation (before Discord message)
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var dbManagerUserIds = await readConnection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии.");
}
// 2. Ensure player exists
await readConnection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name",
new { Name = userDisplayName, UserId = userId.ToString() });
// 3. Verify session exists
var session = await readConnection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
"""
SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled });
if (session is null)
throw new InvalidOperationException("Сессия не найдена или отменена.");
// 4. Check no active proposal
var hasActive = await readConnection.ExecuteScalarAsync<bool>(
"SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))",
new { SessionId = sessionId });
if (hasActive)
throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии.");
// 5. Load participants for rendering
var participants = (await readConnection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
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();
// 6. Prepare proposal data
var proposalId = Guid.NewGuid();
var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList();
// 7. Build and send Discord vote message BEFORE transaction
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []);
var channelIdUlong = ulong.Parse(channelId);
// NOTE: Discord message is sent before DB transaction to avoid orphaned proposals
// if the send fails. There is a negligible race window where the message is visible
// before the DB commit; in practice users cannot click faster than the transaction commits.
var sentMessage = await restClient.SendMessageAsync(
channelIdUlong,
new MessageProperties()
.WithEmbeds(new[] { embed })
.WithComponents(new[] { actionRow }));
// 8. Create proposal + options + platform_messages in transaction
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at)
VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline)
""",
new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime },
transaction);
foreach (var option in optionDtos)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder },
transaction);
}
await connection.ExecuteAsync(
"""
INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose)
VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote')
""",
new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() },
transaction);
await transaction.CommitAsync(ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message");
try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ }
throw;
}
logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId);
return new DiscordRescheduleResult(proposalId, optionDtos, deadline);
}
}
internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt);
@@ -0,0 +1,131 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId,
string GuildId, string ChannelId, string MessageId);
public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource,
RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + option
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
if (proposal is null)
return "Голосование уже завершено или не найдено.";
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
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 p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId);
try
{
await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options =>
{
options.Embeds = new[] { embed };
options.Components = new[] { actionRow };
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
}
return "Ваш голос учтён. До дедлайна его можно изменить.";
}
}
@@ -0,0 +1,183 @@
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,
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await ProcessDueProposals(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
}
private async Task ProcessDueProposals(CancellationToken ct)
{
try
{
var proposalIds = await finalizer.GetDueProposalIdsAsync("Discord", ct);
foreach (var id in proposalIds)
{
await TryFinalizeAsync(id, ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process Discord reschedule proposals");
}
}
private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct)
{
try
{
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
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);
}
logger.LogInformation(
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposalId, result.SessionId, result.Decision.Outcome);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId);
}
}
private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
try
{
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
LIMIT 1
""",
new { result.SessionId });
if (msgRef is null)
return;
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt,
result.Options, result.Participants, result.Votes);
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 };
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId);
}
}
private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
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
LIMIT 1
""",
new { result.BatchId });
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
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC
""",
new { result.BatchId })).ToList();
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
var channelId = ulong.Parse(batchRef.ExternalChannelId);
var messageId = ulong.Parse(batchRef.ExternalMessageId);
await restClient.ModifyMessageAsync(channelId, messageId, options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId);
}
}
internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId);
}
@@ -0,0 +1,64 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordSessionInteractionInput(
Guid SessionId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId,
ulong UserId,
string Username,
string? DisplayName);
public static class DiscordSessionInteractionMapper
{
public static bool TryParseCustomId(string customId, string expectedAction, out Guid sessionId)
{
sessionId = default;
var parts = customId.Split(':', 2);
return parts.Length == 2
&& string.Equals(parts[0], expectedAction, StringComparison.Ordinal)
&& Guid.TryParse(parts[1], out sessionId);
}
public static JoinSessionCommand CreateJoinCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
public static LeaveSessionCommand CreateLeaveCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
private static PlatformUser CreateUser(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.UserId.ToString(System.Globalization.CultureInfo.InvariantCulture),
string.IsNullOrWhiteSpace(input.DisplayName) ? input.Username : input.DisplayName,
input.Username);
private static PlatformGroup CreateGroup(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
input.GuildId,
input.ChannelId);
private static PlatformMessageRef CreateMessageRef(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
null,
input.MessageId);
}
@@ -0,0 +1,137 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Features.Sessions.CreateSession;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ComponentInteractions;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
{
[ComponentInteraction("join_session")]
public async Task JoinAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("leave_session")]
public async Task LeaveAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("reschedule_vote")]
public async Task RescheduleVoteAsync(string optionId)
{
if (!Guid.TryParse(optionId, out var parsedOptionId))
{
await RespondAsync(CreateEphemeralReply("Vote button is outdated."));
return;
}
var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing
var voteInput = new DiscordRescheduleVoteInput(
parsedOptionId,
Context.User.Id,
Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
input.GuildId,
input.ChannelId,
input.MessageId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
string replyText;
try
{
replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId);
await CompleteResponseAsync("Не удалось обработать голос.");
return;
}
await CompleteResponseAsync(replyText);
}
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
{
var guild = Context.Guild
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
var message = Context.Interaction.Message
?? throw new InvalidOperationException("Session button interaction must include a message.");
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),
UserId: Context.User.Id,
Username: Context.User.Username,
DisplayName: Context.User.GlobalName);
}
private async Task CompleteWithStoredReplyAsync(string interactionId)
{
var reply = interactionReplies.Take(interactionId);
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
}
private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text);
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
}
@@ -10,9 +10,11 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Services" Version="1.0.0-alpha.489" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
@@ -0,0 +1,17 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordInteractionReplyCache
{
private readonly ConcurrentDictionary<string, PlatformInteractionReply> replies = new(StringComparer.Ordinal);
public void Store(PlatformInteractionReply reply) =>
replies[reply.InteractionId] = reply;
public PlatformInteractionReply? Take(string interactionId) =>
replies.TryRemove(interactionId, out var reply)
? reply
: null;
}
@@ -0,0 +1,22 @@
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPermissionChecker
{
private const ulong AdministratorPermission = 0x8;
public bool CanManageSchedule(
ulong guildOwnerId,
ulong userId,
IEnumerable<ulong> dbManagerUserIds,
ulong resolvedPermissions)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
}
}
@@ -0,0 +1,80 @@
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger(
RestClient restClient,
DiscordInteractionReplyCache interactionReplies) : IPlatformMessenger
{
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 msg = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows));
return new PlatformMessageRef(
PlatformKind.Discord,
message.Group.ExternalGroupId,
null,
msg.Id.ToString());
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
if (message.ExistingMessage is null)
return;
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
await restClient.ModifyMessageAsync(
channelId,
messageId,
options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
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);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
return Task.CompletedTask;
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
interactionReplies.Store(reply);
return Task.CompletedTask;
}
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,6 @@
namespace GmRelay.DiscordBot.Infrastructure;
public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
+20
View File
@@ -1,5 +1,11 @@
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Logging;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NetCord;
@@ -37,6 +43,20 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
return NpgsqlDataSource.Create(connectionString);
});
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services
.AddDiscordGateway(options =>
{
@@ -0,0 +1,67 @@
namespace GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using NetCord;
using NetCord.Rest;
public static class DiscordRescheduleVotingRenderer
{
public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render(
string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes)
{
var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)");
sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)");
sb.AppendLine();
sb.AppendLine("Выберите один из вариантов:");
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов");
if (optionVotes.Count > 0)
{
sb.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}");
}
}
if (pending.Count > 0)
{
sb.AppendLine();
sb.AppendLine($"Не проголосовали: {string.Join(", ", pending)}");
}
sb.AppendLine();
sb.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
sb.AppendLine("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
var embed = new EmbedProperties()
.WithTitle($"🔄 Перенос сессии «{title}»")
.WithDescription(sb.ToString())
.WithColor(new Color(0xFEE75C));
var actionRow = new ActionRowProperties();
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
actionRow.Add(new ButtonProperties(
$"reschedule_vote:{option.OptionId}",
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
ButtonStyle.Primary));
}
return (embed, actionRow);
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
}
+21 -9
View File
@@ -22,6 +22,12 @@
"OpenTelemetry.Extensions.Hosting": "1.15.0"
}
},
"Dapper": {
"type": "Direct",
"requested": "[2.1.72, )",
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[10.0.5, )",
@@ -79,6 +85,15 @@
"NetCord.Services": "1.0.0-alpha.489"
}
},
"NetCord.Services": {
"type": "Direct",
"requested": "[1.0.0-alpha.489, )",
"resolved": "1.0.0-alpha.489",
"contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==",
"dependencies": {
"NetCord": "1.0.0-alpha.489"
}
},
"Npgsql": {
"type": "Direct",
"requested": "[10.0.2, )",
@@ -519,14 +534,6 @@
"resolved": "1.0.0-alpha.489",
"contentHash": "/rM73l1pwwJCWHi7YrIiSVc+GVL0lV+k+amqNJUMINjLO+c5bKWj9PoNNoMhiPZoaORO4k6Uxp8EQfoQj3AYtA=="
},
"NetCord.Services": {
"type": "Transitive",
"resolved": "1.0.0-alpha.489",
"contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==",
"dependencies": {
"NetCord": "1.0.0-alpha.489"
}
},
"Npgsql.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
@@ -659,7 +666,12 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
[module: Dapper.DapperAot]
@@ -4,8 +4,9 @@ using Npgsql;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand(
Guid SessionId,
@@ -15,15 +16,17 @@ public sealed record JoinSessionCommand(
PlatformMessageRef ScheduleMessage);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger)
{
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
@@ -64,7 +67,7 @@ public sealed class JoinSessionHandler(
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
@"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers
FROM sessions
WHERE id = @SessionId
FOR UPDATE",
@@ -78,6 +81,13 @@ public sealed class JoinSessionHandler(
return;
}
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
"""
SELECT sp.registration_status
@@ -2,9 +2,10 @@ using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand(
Guid SessionId,
@@ -20,10 +21,12 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger)
{
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
@@ -0,0 +1,39 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public interface IScheduleMessageUpdateLock
{
ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct);
}
public sealed class ScheduleMessageUpdateLock : IScheduleMessageUpdateLock
{
private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal);
public async ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct)
{
var key = CreateKey(scheduleMessage);
var semaphore = locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
return new Releaser(semaphore);
}
private static string CreateKey(PlatformMessageRef scheduleMessage) =>
string.Join(
'\u001F',
scheduleMessage.Platform.ToString(),
scheduleMessage.ExternalGroupId,
scheduleMessage.ExternalThreadId ?? string.Empty,
scheduleMessage.ExternalMessageId);
private sealed class Releaser(SemaphoreSlim semaphore) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
semaphore.Release();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,29 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
public sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
public sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
public sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
@@ -1,13 +1,13 @@
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal enum RescheduleVoteOutcome
public enum RescheduleVoteOutcome
{
Pending,
Rejected,
Approved
}
internal sealed record RescheduleVoteDecision(
public sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string Reason,
Guid? SelectedOptionId = null,
@@ -15,7 +15,7 @@ internal sealed record RescheduleVoteDecision(
bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false);
internal static class RescheduleVoteRules
public static class RescheduleVoteRules
{
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{
@@ -49,8 +49,8 @@ internal static class RescheduleVoteRules
{
return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected,
Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.",
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.");
Reason: "Один из участников отклонил перенос.",
CallbackText: "Вы проголосовали против переноса.");
}
var everyoneApproved = approvedParticipants == totalParticipants;
@@ -58,11 +58,11 @@ internal static class RescheduleVoteRules
return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
Reason: everyoneApproved
? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b."
: "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.",
? "Все участники согласны."
: "Голосование продолжается.",
CallbackText: everyoneApproved
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
? "Вы подтвердили перенос! Все согласны — время обновлено."
: "Вы подтвердили перенос!",
ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved);
}
@@ -0,0 +1,215 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleVotingFinalizerResult(
Guid ProposalId,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string NotificationMode,
string SourcePlatform,
DateTimeOffset VotingDeadlineAt,
RescheduleVoteDecision Decision,
RescheduleOptionDto? SelectedOption,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
IReadOnlyList<VoteParticipantDto> Participants);
public sealed class RescheduleVotingFinalizer(
NpgsqlDataSource dataSource,
ISystemClock clock,
ILogger<RescheduleVotingFinalizer> logger)
{
public async Task<IReadOnlyList<Guid>> GetDueProposalIdsAsync(string sourcePlatform, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= @Now
AND source_platform = @SourcePlatform
ORDER BY voting_deadline_at
LIMIT 25
""",
new { Now = clock.UtcNow.UtcDateTime, SourcePlatform = sourcePlatform })).ToList();
return proposalIds;
}
public async Task<RescheduleVotingFinalizerResult?> FinalizeAsync(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<ProposalRow>(
"""
SELECT rp.id AS ProposalId,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.source_platform AS SourcePlatform,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= @Now
FOR UPDATE
""",
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime },
transaction);
if (proposal is null)
return null;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
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 p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.ProposalId },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.ProposalId },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.ProposalId,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.ProposalId },
transaction);
}
await transaction.CommitAsync(ct);
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.ProposalId,
proposal.SessionId,
decision.Outcome);
return new RescheduleVotingFinalizerResult(
proposal.ProposalId,
proposal.SessionId,
proposal.Title,
proposal.CurrentScheduledAt,
proposal.BatchId,
proposal.NotificationMode,
proposal.SourcePlatform,
proposal.VotingDeadlineAt,
decision,
selectedOption,
options,
votes,
participants);
}
private sealed record ProposalRow(
Guid ProposalId,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string SourcePlatform,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string NotificationMode);
}
@@ -1,9 +1,9 @@
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal sealed record RescheduleVotingInput(
public sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline)
{
@@ -93,18 +93,3 @@ internal sealed record RescheduleVotingInput(
|| normalized.StartsWith("до:", StringComparison.Ordinal);
}
}
internal sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
internal sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
internal sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
+8
View File
@@ -5,6 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
namespace GmRelay.Shared.Platform;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
+35
View File
@@ -2,11 +2,46 @@
"version": 1,
"dependencies": {
"net10.0": {
"Dapper": {
"type": "Direct",
"requested": "[2.1.72, )",
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Dapper.AOT": {
"type": "Direct",
"requested": "[1.0.48, )",
"resolved": "1.0.48",
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Npgsql": {
"type": "Direct",
"requested": "[10.0.2, )",
"resolved": "10.0.2",
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
}
}
}
@@ -56,7 +56,7 @@
</button>
</form>
<div class="nav-version">v2.3.0</div>
<div class="nav-version">v2.6.0</div>
</div>
</Authorized>
<NotAuthorized>
+5 -1
View File
@@ -243,7 +243,11 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Npgsql": "[10.0.2, )"
}
}
}
}
@@ -0,0 +1,69 @@
using System.IO;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordListSessionsHandlerTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
[Fact]
public void Handler_ShouldExist()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
Assert.True(File.Exists(handlerPath), "DiscordListSessionsHandler should exist.");
}
[Fact]
public void Handler_ShouldQueryByPlatformAndExternalGroupId()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
Assert.Contains("scheduled_at > NOW()", handler, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldNotContainTelegramSpecificColumns()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
var handler = File.ReadAllText(handlerPath);
Assert.DoesNotContain("telegram_chat_id", handler, StringComparison.Ordinal);
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
}
[Fact]
public void Command_ShouldExist()
{
var repoRoot = GetRepoRoot();
var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsCommand.cs");
Assert.True(File.Exists(commandPath), "DiscordListSessionsCommand should exist.");
}
[Fact]
public void Command_ShouldBeSlashCommandModule()
{
var repoRoot = GetRepoRoot();
var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsCommand.cs");
var command = File.ReadAllText(commandPath);
Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
Assert.Contains("listsessions", command, StringComparison.Ordinal);
}
}
@@ -0,0 +1,144 @@
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordNewSessionHandlerTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
// --- Runtime tests for ParseTimeInput (static, no DB) ---
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
Assert.Equal(19, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
// --- Source-level structural tests ---
[Fact]
public void Handler_ShouldExist()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
Assert.True(File.Exists(handlerPath), "DiscordNewSessionHandler should exist.");
}
[Fact]
public void Handler_ShouldUseDapperForDatabaseAccess()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("QueryAsync", source, StringComparison.Ordinal);
Assert.Contains("ExecuteAsync", source, StringComparison.Ordinal);
Assert.Contains("ExecuteScalarAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldUseNpgsqlDataSource()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("NpgsqlDataSource", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldCheckPermissionsViaPermissionChecker()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("CanManageSchedule", source, StringComparison.Ordinal);
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldBePlatformNeutral()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.DoesNotContain("telegram_chat_id", source, StringComparison.Ordinal);
Assert.DoesNotContain("telegram_id", source, StringComparison.Ordinal);
Assert.Contains("platform = 'Discord'", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldUseTransactions()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("BeginTransactionAsync", source, StringComparison.Ordinal);
Assert.Contains("CommitAsync", source, StringComparison.Ordinal);
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldRespectCancellationToken()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("CancellationToken", source, StringComparison.Ordinal);
}
[Fact]
public void Command_ShouldRenderEmbedOnSuccess()
{
var repoRoot = GetRepoRoot();
var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionCommand.cs");
var source = File.ReadAllText(commandPath);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("WithEmbeds", source, StringComparison.Ordinal);
}
}
@@ -0,0 +1,72 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPermissionCheckerTests
{
[Fact]
public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 123456789ul,
dbManagerUserIds: Array.Empty<ulong>(),
resolvedPermissions: 0);
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserHasAdministratorPermission_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 987654321ul,
dbManagerUserIds: Array.Empty<ulong>(),
resolvedPermissions: 0x8); // Administrator
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var managerId = 555ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: managerId,
dbManagerUserIds: new[] { managerId },
resolvedPermissions: 0);
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenRegularUser_ReturnsFalse()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 111ul,
dbManagerUserIds: new[] { 222ul },
resolvedPermissions: 0);
Assert.False(result);
}
[Fact]
public void CanManageSchedule_WhenUserHasOtherPermissionButNotAdmin_ReturnsFalse()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 111ul,
dbManagerUserIds: Array.Empty<ulong>(),
resolvedPermissions: 0x4); // ManageServer, not Administrator
Assert.False(result);
}
}
@@ -0,0 +1,51 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPlatformMessengerTests
{
[Fact]
public void Constructor_ShouldAcceptRestClientAndReplyCache()
{
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[]
{
typeof(NetCord.Rest.RestClient),
typeof(DiscordInteractionReplyCache)
});
Assert.NotNull(constructor);
}
[Fact]
public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger()
{
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
}
[Fact]
public async Task AnswerInteractionAsync_ShouldStoreReplyForComponentModule()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs");
Assert.Contains("DiscordInteractionReplyCache", source, StringComparison.Ordinal);
Assert.Contains("interactionReplies.Store(reply)", 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}'.");
}
}
@@ -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.3.0", compose);
Assert.Contains("gmrelay-discord-bot:2.6.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.3.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
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(
"v2.3.0",
"v2.6.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
}
@@ -0,0 +1,96 @@
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordSessionInteractionMapperTests
{
[Fact]
public void TryParseCustomId_WhenActionAndSessionIdMatch_ReturnsSessionId()
{
var sessionId = Guid.NewGuid();
var result = DiscordSessionInteractionMapper.TryParseCustomId(
$"join_session:{sessionId}",
"join_session",
out var parsedSessionId);
Assert.True(result);
Assert.Equal(sessionId, parsedSessionId);
}
[Fact]
public void TryParseCustomId_WhenActionDoesNotMatch_ReturnsFalse()
{
var result = DiscordSessionInteractionMapper.TryParseCustomId(
$"leave_session:{Guid.NewGuid()}",
"join_session",
out _);
Assert.False(result);
}
[Fact]
public void TryParseCustomId_WhenSessionIdIsInvalid_ReturnsFalse()
{
var result = DiscordSessionInteractionMapper.TryParseCustomId(
"join_session:not-a-guid",
"join_session",
out _);
Assert.False(result);
}
[Fact]
public void CreateJoinCommand_ShouldBuildPlatformNeutralDiscordCommand()
{
var sessionId = Guid.NewGuid();
var input = CreateInput(sessionId, displayName: "Alice GM");
JoinSessionCommand command = DiscordSessionInteractionMapper.CreateJoinCommand(input);
Assert.Equal(sessionId, command.SessionId);
Assert.Equal("interaction-1", command.InteractionId);
Assert.Equal(PlatformKind.Discord, command.User.Platform);
Assert.Equal("42", command.User.ExternalUserId);
Assert.Equal("Alice GM", command.User.DisplayName);
Assert.Equal("alice", command.User.ExternalUsername);
Assert.Equal(PlatformKind.Discord, command.Group.Platform);
Assert.Equal("guild-1", command.Group.ExternalGroupId);
Assert.Equal("channel-1", command.Group.ExternalChannelId);
Assert.Equal(PlatformKind.Discord, command.ScheduleMessage.Platform);
Assert.Equal("guild-1", command.ScheduleMessage.ExternalGroupId);
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
}
[Fact]
public void CreateLeaveCommand_ShouldBuildPlatformNeutralDiscordCommand()
{
var sessionId = Guid.NewGuid();
var input = CreateInput(sessionId, displayName: null);
LeaveSessionCommand command = DiscordSessionInteractionMapper.CreateLeaveCommand(input);
Assert.Equal(sessionId, command.SessionId);
Assert.Equal("interaction-1", command.InteractionId);
Assert.Equal(PlatformKind.Discord, command.User.Platform);
Assert.Equal("42", command.User.ExternalUserId);
Assert.Equal("alice", command.User.DisplayName);
Assert.Equal("alice", command.User.ExternalUsername);
Assert.Equal("guild-1", command.Group.ExternalGroupId);
Assert.Equal("channel-1", command.Group.ExternalChannelId);
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
}
private static DiscordSessionInteractionInput CreateInput(Guid sessionId, string? displayName)
=> new(
SessionId: sessionId,
InteractionId: "interaction-1",
GuildId: "guild-1",
ChannelId: "channel-1",
MessageId: "message-1",
UserId: 42,
Username: "alice",
DisplayName: displayName);
}
@@ -0,0 +1,40 @@
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordSessionInteractionModuleSourceTests
{
[Fact]
public async Task Module_ShouldRouteJoinAndLeaveButtonsToNeutralHandlers()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
Assert.Contains("[ComponentInteraction(\"join_session\")]", source, StringComparison.Ordinal);
Assert.Contains("[ComponentInteraction(\"leave_session\")]", source, StringComparison.Ordinal);
Assert.Contains("JoinSessionHandler", source, StringComparison.Ordinal);
Assert.Contains("LeaveSessionHandler", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionInteractionMapper.CreateJoinCommand", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionInteractionMapper.CreateLeaveCommand", source, StringComparison.Ordinal);
Assert.Contains("RespondAsync", source, StringComparison.Ordinal);
Assert.Contains("InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)", source, StringComparison.Ordinal);
Assert.Contains("ModifyResponseAsync", source, StringComparison.Ordinal);
Assert.Contains("Не удалось обработать кнопку.", source, StringComparison.Ordinal);
Assert.Contains("MessageFlags.Ephemeral", 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}'.");
}
}
@@ -71,6 +71,19 @@ public sealed class DiscordStartupTests
Assert.DoesNotContain("Token", logger);
}
[Fact]
public void Program_ShouldRegisterDiscordSessionHandlers()
{
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("JoinSessionHandler", program);
Assert.Contains("LeaveSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", program);
Assert.Contains("IPlatformMessenger", program);
}
private static string ReadProgram()
{
var repoRoot = GetRepoRoot();
@@ -1,6 +1,7 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
@@ -5,7 +5,7 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
[Fact]
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
@@ -16,10 +16,20 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
}
[Fact]
public async Task JoinSessionHandler_ShouldRejectCancelledSessionsBeforeInsert()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("Status", handler, StringComparison.Ordinal);
Assert.Contains("SessionStatus.IsCancelled(batchInfo.Status)", handler, StringComparison.Ordinal);
Assert.Contains("Сессия уже отменена.", handler, StringComparison.Ordinal);
}
[Fact]
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
@@ -31,8 +41,8 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
[Fact]
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
@@ -42,6 +52,16 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
}
[Fact]
public async Task SessionInteractionHandlers_ShouldSerializeScheduleMessageUpdates()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("scheduleUpdateLock.AcquireAsync(command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
Assert.Contains("scheduleUpdateLock.AcquireAsync(command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
@@ -0,0 +1,66 @@
using System.Xml.Linq;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class SharedDapperAotConfigurationTests
{
[Fact]
public void SharedProject_ShouldEnableDapperAotForSessionInteractionHandlers()
{
var repoRoot = FindRepositoryRoot();
var sharedProjectPath = Path.Combine(repoRoot, "src", "GmRelay.Shared", "GmRelay.Shared.csproj");
var joinHandler = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"JoinSessionHandler.cs"));
var leaveHandler = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"LeaveSessionHandler.cs"));
Assert.Contains("using Dapper;", joinHandler, StringComparison.Ordinal);
Assert.Contains("using Dapper;", leaveHandler, StringComparison.Ordinal);
var project = XDocument.Load(sharedProjectPath);
var packageReferences = project
.Descendants("PackageReference")
.Select(reference => reference.Attribute("Include")?.Value)
.ToArray();
var interceptorNamespaces = project
.Descendants("InterceptorsPreviewNamespaces")
.Select(element => element.Value)
.ToArray();
var moduleAttributeFiles = Directory
.EnumerateFiles(Path.GetDirectoryName(sharedProjectPath)!, "*.cs", SearchOption.AllDirectories)
.Select(File.ReadAllText)
.ToArray();
Assert.Contains("Dapper.AOT", packageReferences);
Assert.Contains(interceptorNamespaces, value => value.Contains("Dapper.AOT", StringComparison.Ordinal));
Assert.Contains(moduleAttributeFiles, source => source.Contains("[module: Dapper.DapperAot]", StringComparison.Ordinal));
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Directory.Build.props")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Could not locate repository root.");
}
}
@@ -1,4 +1,5 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -74,7 +74,7 @@ public sealed class PlatformIdentityMigrationTests
[Fact]
public async Task JoinSessionHandler_ShouldDualWritePlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("external_username", handler, StringComparison.Ordinal);
@@ -12,8 +12,8 @@ public sealed class TelegramPlatformMessengerSourceTests
}
[Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
[InlineData("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
[InlineData("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")]
@@ -54,7 +54,7 @@ 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(proposal.TelegramChatId, proposal.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
@@ -0,0 +1,41 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Platform;
public sealed class ScheduleMessageUpdateLockTests
{
[Fact]
public async Task AcquireAsync_ShouldSerializeSameScheduleMessage()
{
var updateLock = new ScheduleMessageUpdateLock();
var message = CreateMessage("message-1");
var first = await updateLock.AcquireAsync(message, CancellationToken.None);
var secondTask = updateLock.AcquireAsync(message, CancellationToken.None).AsTask();
Assert.False(secondTask.IsCompleted);
await first.DisposeAsync();
var second = await secondTask.WaitAsync(TimeSpan.FromSeconds(1));
await second.DisposeAsync();
}
[Fact]
public async Task AcquireAsync_ShouldNotBlockDifferentScheduleMessages()
{
var updateLock = new ScheduleMessageUpdateLock();
var first = await updateLock.AcquireAsync(CreateMessage("message-1"), CancellationToken.None);
var secondTask = updateLock.AcquireAsync(CreateMessage("message-2"), CancellationToken.None).AsTask();
Assert.True(secondTask.IsCompleted);
await first.DisposeAsync();
var second = await secondTask;
await second.DisposeAsync();
}
private static PlatformMessageRef CreateMessage(string messageId) =>
new(PlatformKind.Discord, "guild-1", null, messageId);
}
+13 -7
View File
@@ -392,8 +392,8 @@
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[2.1.1, )",
"GmRelay.Shared": "[2.1.1, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.5.3, )",
"dbup-postgresql": "[7.0.1, )"
@@ -403,10 +403,12 @@
"type": "Project",
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"GmRelay.ServiceDefaults": "[2.1.1, )",
"GmRelay.Shared": "[2.1.1, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"NetCord.Hosting": "[1.0.0-alpha.489, )",
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
"NetCord.Services": "[1.0.0-alpha.489, )",
"Npgsql": "[10.0.2, )"
}
},
@@ -423,15 +425,19 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Npgsql": "[10.0.2, )"
}
},
"gmrelay.web": {
"type": "Project",
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.1.1, )",
"GmRelay.Shared": "[2.1.1, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )"
}