Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad | |||
| 1f3fb6e89e | |||
| e3e6e841b8 | |||
| a0a84965b3 | |||
| 67e8d5b558 | |||
| 593f8a62fb | |||
| aee0ac1e6c | |||
| 68945d931f | |||
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 | |||
| 99a58d7835 | |||
| f491727cec | |||
| 2c9016a383 | |||
| 065e8011ee | |||
| f796b7d1e4 | |||
| 415c13bf00 | |||
| 85ff3a7faf | |||
| d034d6acb9 | |||
| c4a77d3d73 | |||
| 7cfb1968c0 | |||
| b1bd47f6c1 | |||
| f0952096f3 | |||
| b81d865832 | |||
| 8f0f2ef7e7 | |||
| 71080aeab6 | |||
| a843c8b278 | |||
| 186492a18d | |||
| 2819786f91 | |||
| 8c1bda73ed | |||
| af345ba765 | |||
| 4a04d7d723 | |||
| eeffae659f | |||
| ea567a36ee | |||
| be86a2a08a | |||
| 1b49211085 | |||
| 96a4807002 | |||
| cff4e48b57 | |||
| 384887a862 | |||
| 4d2aef637f | |||
| c45c46abcf | |||
| 2c7495cd8d | |||
| d5fdc19016 | |||
| 10410d758c | |||
| 771ff9be34 | |||
| 29f6f6a827 | |||
| 6951c72f3c | |||
| 22e9859fdf | |||
| 6cb2fbe610 | |||
| 992f71c0e4 | |||
| 21e29564f6 | |||
| 401653a4d1 | |||
| e970e94e00 | |||
| 242ff99a83 | |||
| f2c9f34ab4 | |||
| e5945288ac | |||
| 7d1489445e | |||
| 4af4e52778 | |||
| a20da4b1a0 | |||
| edf40c9a09 | |||
| 1a8161027c | |||
| 85918c1e5d | |||
| ea714480d3 | |||
| 1d62f69ff0 | |||
| d762ecc377 | |||
| a28b75dd5b | |||
| 2b725708ef | |||
| da0a306340 | |||
| f493836b77 | |||
| 6e7a0cb493 | |||
| 76b3ff7ddf | |||
| 536061f63c | |||
| f7a12d14d2 | |||
| 3c1a98bcc4 | |||
| d591e5ed5a | |||
| 5809a470b9 | |||
| ed842d2195 | |||
| a0040ec9fb | |||
| 67b8aafd97 | |||
| ac417731d6 | |||
| 991c7e1965 | |||
| 0d9df29f58 | |||
| d54950698a | |||
| 394bd19b95 | |||
| b52d4000b4 | |||
| b32f962f11 | |||
| 0c1d3abd7e | |||
| d81564c308 | |||
| accb3b2405 | |||
| a63e3bef1e | |||
| 9d9aca53df | |||
| 5b6971fda5 | |||
| b496a401fc | |||
| 76c6818952 | |||
| 633a020212 | |||
| ab38238fe8 | |||
| 4145cacc52 | |||
| 6d59737d07 | |||
| 71ffcce06b | |||
| 72f43dbef2 | |||
| a5f4a68c6a | |||
| b2497ed877 | |||
| 9b42ea034a | |||
| f94bea3e74 | |||
| cde1e4311f | |||
| 847a40815f | |||
| 6fd03ef836 | |||
| c2ccc35e50 | |||
| 3418d1a46c | |||
| fac5d75c7e | |||
| 7a2965b43f | |||
| a0df94fc91 | |||
| 79694f7de8 | |||
| 542f15f2d6 | |||
| 64216f5a26 | |||
| 383e2c1d8d | |||
| bfa979a224 | |||
| c69ebf6c03 | |||
| 040b0a3cdb | |||
| a5aed14dd2 | |||
| 9fc434b42b | |||
| c2cc7fd9a8 | |||
| 3447acd8c4 | |||
| 56aeca5288 | |||
| 6ed0a120a0 | |||
| 682dd3fdec | |||
| c955e1572f | |||
| a9aa84af0f | |||
| dcbd9bab41 | |||
| 92d5d9c2d3 | |||
| 47d106e288 | |||
| a5624897e9 | |||
| 11e75d036a | |||
| 2942da0c35 | |||
| 549c0c96ae | |||
| dd9337dd20 | |||
| 3cc3b373e5 | |||
| f6d5281af8 | |||
| fa63886195 | |||
| 9bd5fe75c9 | |||
| d931da37ec | |||
| 9375fa45b2 | |||
| 0b45aee96d | |||
| 80e346d6b5 | |||
| eff0128d29 | |||
| 8214e052af | |||
| 2a233b2b1e | |||
| 5e3028e470 | |||
| 63193310f2 | |||
| af37f3a8ec | |||
| 66228cf106 |
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
|
||||
|
||||
# Имя Docker volume для резервных копий БД
|
||||
BACKUP_VOLUME_NAME=game_pgbackups
|
||||
|
||||
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
|
||||
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.0.0
|
||||
VERSION: 3.9.7
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
@@ -72,7 +72,27 @@ jobs:
|
||||
steps:
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
# upstream install.sh. Rationale:
|
||||
# 1. install.sh resolves the positional tag against the
|
||||
# GitHub releases API; when a release is unpublished or
|
||||
# yanked, the script fails with
|
||||
# `unable to find '<tag>' - use 'latest' or see ...`
|
||||
# even when the release once existed. We hit this with
|
||||
# v0.71.0.
|
||||
# 2. Docker Hub tags are content-addressed and rarely
|
||||
# removed, so a pinned image tag is much more stable.
|
||||
# 3. The image is multi-arch (linux/amd64, linux/arm64,
|
||||
# linux/ppc64le, linux/s390x) so the same tag works on
|
||||
# the GitHub-hosted runner and on the ARM64 Pi runner.
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.70.0"
|
||||
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||
docker rm trivy-tmp >/dev/null
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Scan Bot image
|
||||
run: |
|
||||
|
||||
@@ -47,7 +47,19 @@ jobs:
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
# upstream install.sh. Rationale (see deploy.yml for the long
|
||||
# version): the GitHub release tag we pinned (v0.71.0) was
|
||||
# unpublished, and install.sh fails hard on missing tags.
|
||||
# Docker Hub images are content-addressed and rarely removed,
|
||||
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.70.0"
|
||||
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||
docker rm trivy-tmp >/dev/null
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Trivy filesystem security scan
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.0.0</Version>
|
||||
<Version>3.9.7</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v2.8.0`.
|
||||
**Текущая версия:** `v3.6.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **🧑🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
|
||||
- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров.
|
||||
- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице.
|
||||
- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
@@ -85,8 +90,10 @@ TELEGRAM_BOT_TOKEN=ваш_токен_здесь
|
||||
# Токен Discord application bot
|
||||
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
|
||||
|
||||
# Client ID Discord application (используется для slash-команд)
|
||||
DISCORD_BOT_CLIENT_ID=ваш_discord_client_id_здесь
|
||||
# Discord OAuth (для Web Dashboard)
|
||||
DISCORD_CLIENT_ID=ваш_discord_client_id_здесь
|
||||
DISCORD_CLIENT_SECRET=ваш_discord_client_secret_здесь
|
||||
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
|
||||
|
||||
# Имя бота без @ (для Telegram Login Widget)
|
||||
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
|
||||
@@ -119,9 +126,35 @@ docker compose up -d
|
||||
1. Напишите боту `/start`.
|
||||
2. Создайте группу через `/newgroup`.
|
||||
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` и `DISCORD_BOT_CLIENT_ID` в `.env`.
|
||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
||||
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
||||
|
||||
## 📚 Портфолио завершённых приключений
|
||||
|
||||
Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков.
|
||||
|
||||
### Возможности
|
||||
|
||||
- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`.
|
||||
- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами.
|
||||
- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена.
|
||||
- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу.
|
||||
- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы.
|
||||
|
||||
### Хранение обложек
|
||||
|
||||
Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`.
|
||||
|
||||
В `.env` можно переопределить имя volume:
|
||||
|
||||
```env
|
||||
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||
```
|
||||
|
||||
В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`).
|
||||
|
||||
Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов.
|
||||
|
||||
## 💾 Backup и восстановление
|
||||
|
||||
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
|
||||
|
||||
+81
-5
@@ -1,8 +1,84 @@
|
||||
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
|
||||
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
|
||||
|
||||
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync` → `PlatformNotSupportedException` → `GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
|
||||
|
||||
### 🩹 Что починено
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync` — `new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
|
||||
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
|
||||
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
|
||||
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
|
||||
|
||||
### ⚠️ Известные ограничения
|
||||
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
|
||||
|
||||
### 🧪 Тесты
|
||||
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
|
||||
|
||||
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
|
||||
|
||||
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator` → `PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
|
||||
|
||||
### 🩹 Что починено
|
||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада.
|
||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — `CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию.
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs` — `DateTimeOffset.UtcNow` → `DateTime.UtcNow` в новых драфтах.
|
||||
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1.
|
||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`).
|
||||
|
||||
### ⚠️ Известные ограничения
|
||||
- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады.
|
||||
|
||||
### 🧪 Тесты
|
||||
- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17<WizardDraft>` + `CommandFactory30<WizardDraft>`.
|
||||
|
||||
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
|
||||
|
||||
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
|
||||
|
||||
### 🧩 Что вошло в релиз
|
||||
|
||||
**Платформо-нейтральный рефакторинг (GmRelay.Shared)**
|
||||
- `Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` — стейт-машина визарда (один источник правды для обеих платформ)
|
||||
- `Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs` — контракт мессенджера (edit/send/answer/getOwnerClubs)
|
||||
- `Features/Sessions/CreateSession/Wizard/WizardInteraction.cs` — запись взаимодействия (OwnerId, Text, CallbackPayload, PhotoFileId, PhotoUrl, InteractionId)
|
||||
- `Features/Sessions/CreateSession/Wizard/WizardAction.cs`, `WizardKeyboard.cs`, `WizardStepLimits.cs` — модель кнопок и лимитов
|
||||
- `Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — добавлено поле `Platform`
|
||||
- `Migrations/V032__add_wizard_drafts_platform.sql` — `ALTER TABLE wizard_drafts ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
|
||||
|
||||
**Discord-адаптер (GmRelay.DiscordBot)**
|
||||
- `Features/Sessions/Wizard/DiscordWizardCommand.cs` — slash-команда `/newsession-wizard` с проверкой owner/co-GM через `DiscordPermissionLookup`
|
||||
- `Features/Sessions/Wizard/DiscordWizardStep.cs` — рендер 15 шагов в NetCord embed + buttons/StringSelectMenu/modals
|
||||
- `Features/Sessions/Wizard/DiscordWizardMessenger.cs` — реализация `IWizardMessenger` через NetCord REST (edit с fallback на re-send при 401/403/404)
|
||||
- `Features/Sessions/Wizard/DiscordWizardSubmitter.cs` — финализация с 3-retry циклом
|
||||
- `Features/Sessions/Wizard/DiscordWizardContextStore.cs` — in-memory кэш контекста (guild/channel/messageId) для 15-минутного interaction token
|
||||
- `Features/Sessions/Wizard/DiscordWizardInteractionModule.cs` — inbound handlers: 3 NetCord `ComponentInteractionModule<TContext>` (button/StringMenu/Modal) + `WizardInteractionDispatcher`
|
||||
- `Features/Sessions/Wizard/DiscordPermissionLookup.cs` — DB-хелпер для `group_managers`
|
||||
- `Program.cs` — DI-регистрации + 3 `AddComponentInteractions<TInteraction, TContext>`
|
||||
|
||||
**Тесты**
|
||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*` — обновлены под новый контракт
|
||||
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` — 12 source-level smoke-тестов на структуру interaction module
|
||||
|
||||
### 🗺 Что это даёт
|
||||
- Мастера (GM) могут пошагово создавать игры и пулы слотов прямо в Discord через slash-команду, кнопки, выпадающие меню и модальные окна.
|
||||
- UX адаптирован под Discord (нативные components), а не скопирован из Telegram.
|
||||
- Общая стейт-машина и валидация: Telegram и Discord визарды развиваются синхронно, баги фиксятся в одном месте.
|
||||
- PickClub-шаг использует реальный SQL-запрос к `club_memberships` с фильтром по роли Owner/CoGm.
|
||||
|
||||
### 📦 Версия и деплой
|
||||
- Версия обновлена до 3.9.0 (`NavMenu.razor`, `.gitea/workflows/deploy.yml`)
|
||||
- Docker-образы будут тегированы `3.9.0` при пуше в `main`
|
||||
- Миграция V032 применяется автоматически на старте Bot
|
||||
|
||||
## 🛠 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
|
||||
@@ -13,11 +89,11 @@
|
||||
- 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
|
||||
- Docker-образы используют тег 2.4.0
|
||||
|
||||
+11
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.0.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.7
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,11 +67,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.0.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.7
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
|
||||
@@ -84,11 +86,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.0.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.7
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||
@@ -97,10 +101,12 @@ services:
|
||||
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
|
||||
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
|
||||
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
|
||||
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||
ports:
|
||||
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- web_keys:/app/dataprotection-keys
|
||||
- portfolio_covers:/app/portfolio-covers
|
||||
networks:
|
||||
- gmrelay
|
||||
healthcheck:
|
||||
@@ -116,6 +122,8 @@ volumes:
|
||||
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
||||
pgbackups:
|
||||
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
|
||||
portfolio_covers:
|
||||
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||
|
||||
networks:
|
||||
gmrelay:
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# Discord wizard adapter — issue #112
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the Discord side of the platform-neutral game/pool creation
|
||||
wizard (`feat/issue-112-wizard-refactor`). Six adapter files in
|
||||
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound
|
||||
handler module turn the `/newsession-wizard` slash command into a
|
||||
fully clickable step-by-step wizard with button, StringSelectMenu, and
|
||||
modal handlers. The shared `GameCreationWizard` state machine in
|
||||
`GmRelay.Shared` is the single source of truth — the Discord adapter
|
||||
only translates between NetCord's interaction types and the
|
||||
platform-neutral `WizardInteraction` record.
|
||||
|
||||
## Files
|
||||
|
||||
Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
|
||||
|
||||
- `DiscordWizardContextStore.cs` — `IWizardContextStore` interface +
|
||||
thread-safe in-memory store. Keyed by `draft.Id`. Holds the
|
||||
`(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
|
||||
needs to re-send a draft after a 15-minute interaction token
|
||||
expires.
|
||||
- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
|
||||
an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
|
||||
`ActionRow` buttons with `StringMenu` select menus) and exposes
|
||||
`BuildModal` for the 8 modal-collecting steps.
|
||||
- `DiscordWizardMessenger.cs` — `IWizardMessenger` impl. Uses
|
||||
`RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls
|
||||
back to re-send on 401/403/404). Toast replies are stashed in the
|
||||
existing `DiscordInteractionReplyCache`.
|
||||
- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the
|
||||
shared `CreateSessionCommand` and calls `CreateSessionHandler`;
|
||||
on success edits the draft to "✅ Создано: N сессий", on failure
|
||||
shows retry/cancel buttons.
|
||||
- `DiscordWizardCommand.cs` — `/newsession-wizard` slash command.
|
||||
Owner/co-GM check via `group_managers` (DiscordPermissionLookup).
|
||||
- `DiscordPermissionLookup.cs` — small DB helper that loads
|
||||
`group_managers` rows for a guild.
|
||||
- `DiscordWizardInteractionModule.cs` — **inbound handlers** (this
|
||||
commit). Three NetCord `ComponentInteractionModule<TContext>`
|
||||
shells (button / StringSelectMenu / modal) share one
|
||||
`WizardInteractionDispatcher` that:
|
||||
1. parses the custom-id tail (`btn:choice:<step>:<value>`,
|
||||
`btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
|
||||
`btn:resume:restart`, `select:<step>`, `modal:<step>`);
|
||||
2. loads the active draft via
|
||||
`IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
|
||||
3. routes the callback through the shared
|
||||
`GameCreationWizard.HandleInteractionAsync` (or
|
||||
`DiscordWizardSubmitter.SubmitAsync` for the `create` button);
|
||||
4. opens a modal popup when the new step needs text input,
|
||||
otherwise acks with a deferred message so Discord doesn't show
|
||||
"Application did not respond".
|
||||
|
||||
DI / tests:
|
||||
|
||||
- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
|
||||
(`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
|
||||
`GameCreationWizard`, `DiscordWizardSubmitter`,
|
||||
`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
|
||||
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
|
||||
3 `AddComponentInteractions<TInteraction, TContext>` calls
|
||||
(Button, StringMenu, Modal).
|
||||
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
|
||||
— 12 source-level structural smoke tests: handler classes exist,
|
||||
all 3 derive from `ComponentInteractionModule<TContext>`, all 3
|
||||
register `[ComponentInteraction("wizard")]`, the dispatcher
|
||||
exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
|
||||
`HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
|
||||
`cancel` / `create` / `resume`) are routed, the dispatcher
|
||||
invokes `GameCreationWizard.HandleInteractionAsync` and
|
||||
`DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
|
||||
registers all 3 `AddComponentInteractions` and all 4 module
|
||||
classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
|
||||
walks `Components[0] → TextInput → .Value`, string menu reads
|
||||
`SelectedValues[0]`.
|
||||
|
||||
## Custom-id wire format
|
||||
|
||||
| Interaction | Custom-id | Handler |
|
||||
|------------------------|------------------------------------------|-------------------------------------------------|
|
||||
| Choice button | `wizard:btn:choice:<step>:<value>` | Wizard's `ApplyChoice` |
|
||||
| Back button | `wizard:btn:back` | Wizard's `ApplyBack` |
|
||||
| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" |
|
||||
| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
|
||||
| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger |
|
||||
| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run |
|
||||
| StringSelectMenu | `wizard:select:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
|
||||
| Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
|
||||
|
||||
The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
|
||||
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
|
||||
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
|
||||
parse the rest.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- ✅ `dotnet build` решения — 0 warnings, 0 errors
|
||||
- ✅ `dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing
|
||||
skipped). 12 source-level smoke tests cover the interaction module.
|
||||
- ✅ `dotnet format --verify-no-changes` — clean
|
||||
- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`,
|
||||
`f095209`).
|
||||
|
||||
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
|
||||
|
||||
## Open questions
|
||||
|
||||
- ~~**Club lookup at the PickClub step**~~ — **FIXED in commit `7cfb196`.**
|
||||
`WizardClubLookup.LoadClubsAsync` now queries `group_managers`
|
||||
directly via injected `NpgsqlDataSource` with the same
|
||||
`Owner | CoGm` role filter the messenger uses. The dispatcher
|
||||
reads the owner's real club list and renders them in the
|
||||
StringSelectMenu. Build green, 12 source-level smoke tests still
|
||||
pass.
|
||||
- ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.**
|
||||
The dispatcher now runs the wizard first (which edits the draft
|
||||
embed), then sends the response as either
|
||||
`InteractionCallback.Modal(modalProperties)` (when the new step
|
||||
needs text input) or `InteractionCallback.DeferredMessage()`
|
||||
(otherwise). NetCord locks the response type after the first
|
||||
`SendResponseAsync` call, so the fix is NOT to call
|
||||
`DeferredMessage` upfront.
|
||||
- **Modal handler's free-text mapping is a hack.** Modal steps like
|
||||
`SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText`
|
||||
are mapped to the canonical wizard step (`System`, `Duration`,
|
||||
`PoolSystemDuration`) in `MapModalStepToWizardStep`. This works
|
||||
because the wizard's `ApplyText` dispatches on the canonical step
|
||||
name, but a future refactor of `ApplyText` to know about the
|
||||
free-text step names would break this. The clean fix is to add
|
||||
dedicated "free text" steps to `WizardStepNames`.
|
||||
- **Resume:continue is a re-render, not a true resume.** The wizard
|
||||
has no special resume case; clicking "▶️ Продолжить" just re-emits
|
||||
the current step's embed. This is fine for the user (the embed is
|
||||
identical to the last one they saw before the click), but the
|
||||
underlying state isn't really "continued" — if the wizard's cleanup
|
||||
service expired the draft between the slash command and the
|
||||
click, the user gets a re-render of an empty step.
|
||||
- **One-draft-per-owner invariant.** The wizard's "one active draft
|
||||
per owner" rule means a single Discord user can't run two wizard
|
||||
sessions in parallel. Acceptable for now, but the wizard's state
|
||||
machine doesn't enforce this — only the dispatcher does, via
|
||||
`GetActiveAsync("Discord", ownerId)`.
|
||||
|
||||
## Final commit history on `feat/issue-112-wizard-refactor`
|
||||
|
||||
- `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types)
|
||||
- `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command)
|
||||
- `f095209` — Task 2: interaction module + modal popup fix
|
||||
- `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup
|
||||
|
||||
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
|
||||
@@ -8,17 +8,20 @@ C4Context
|
||||
|
||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||
Person(visitor, "Public visitor", "Views published club schedules, sessions, GM profiles, and completed-adventure portfolio pages without private player data")
|
||||
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile/portfolio pages, and shared scheduling logic")
|
||||
|
||||
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")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys")
|
||||
|
||||
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 and RSVP buttons")
|
||||
Rel(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios")
|
||||
Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -34,22 +37,26 @@ C4Container
|
||||
|
||||
Person(gm, "Game Master")
|
||||
Person(player, "Player")
|
||||
Person(visitor, "Public visitor")
|
||||
|
||||
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, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile/portfolio pages, portfolio review submission and moderation, editing and stats")
|
||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
System_Ext(discord, "Discord Gateway and REST API")
|
||||
SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)")
|
||||
|
||||
Rel(gm, telegram, "Commands")
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages")
|
||||
Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
@@ -60,6 +67,7 @@ C4Container
|
||||
Rel(bot, db, "Npgsql + Dapper.AOT")
|
||||
Rel(discordBot, db, "Npgsql + Dapper")
|
||||
Rel(web, db, "Npgsql + Dapper")
|
||||
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
|
||||
```
|
||||
|
||||
## Level 3: Component - Session Interactions
|
||||
@@ -121,3 +129,57 @@ C4Component
|
||||
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
|
||||
Rel(healthCheck, discord, "HTTP /health")
|
||||
```
|
||||
|
||||
## Level 3: Component - Completed-Adventure Portfolios
|
||||
|
||||
The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Completed-Adventure Portfolio Subsystem
|
||||
|
||||
Person(gm, "Game Master", "Curates completed adventures and moderates reviews")
|
||||
Person(player, "Player", "Submits one moderated review per completed adventure")
|
||||
Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews")
|
||||
|
||||
Container_Boundary(web, "GmRelay.Web") {
|
||||
Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration")
|
||||
Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks")
|
||||
Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary")
|
||||
Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants")
|
||||
Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue")
|
||||
}
|
||||
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads")
|
||||
|
||||
Rel(gm, editor, "Creates, edits, publishes, moderates reviews")
|
||||
Rel(player, pages, "Submits review")
|
||||
Rel(visitor, pages, "Reads public portfolio and approved reviews")
|
||||
Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync")
|
||||
Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync")
|
||||
Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync")
|
||||
Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization")
|
||||
Rel(authorized, covers, "Save, read, delete cover files")
|
||||
Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync")
|
||||
Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews")
|
||||
Rel(covers, coversVolume, "Filesystem reads/writes")
|
||||
Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||
Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||
```
|
||||
|
||||
### Portfolio tables (PostgreSQL)
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` |
|
||||
| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure |
|
||||
| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group |
|
||||
| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` |
|
||||
|
||||
### Cover storage boundary
|
||||
|
||||
- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`.
|
||||
- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`).
|
||||
- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`.
|
||||
- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Detailed code review plan for the Discord wizard feature branch.
|
||||
|
||||
Read this file FIRST. It has the full review scope. The original prompt
|
||||
in the spawn was truncated due to Windows CLI limits; this file is the
|
||||
canonical spec.
|
||||
"""
|
||||
|
||||
# Branch to review
|
||||
BRANCH = "feat/issue-112-wizard-refactor"
|
||||
BASE = "origin/main"
|
||||
|
||||
# Files of interest
|
||||
SHARED = "src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/*"
|
||||
BOT_WIZARD = "src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/*"
|
||||
BOT_CREATE = "src/GmRelay.Bot/Features/Sessions/CreateSession/*"
|
||||
MIGRATION = "src/GmRelay.Bot/Migrations/V032__add_wizard_drafts_platform.sql"
|
||||
DISCORD = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/*"
|
||||
PROG_CS = "src/GmRelay.DiscordBot/Program.cs"
|
||||
TESTS_WIZ = "tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*"
|
||||
TESTS_SMOKE = "tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs"
|
||||
DELIVERABLE = "deliverable.md"
|
||||
|
||||
REVIEW_FOCUS = [
|
||||
"Architecture: Shared/Bot/DiscordBot separation; no Telegram.Bot in Shared; no NetCord in Shared; single state machine source.",
|
||||
"Security: owner/co-GM checks everywhere; NRE on null Context.User; SQL injection; connection strings with passwords.",
|
||||
"Correctness: AOT-safety (no reflection, no dynamic); off-by-one in customId parsers; CancellationToken/Services.",
|
||||
"Style: naming consistent; Async/await by convention; logging at right levels.",
|
||||
"Tests: smoke tests are string-matching — where would real tests be useful?",
|
||||
"Migration safety: V032 DEFAULT value, will it fail on existing rows?",
|
||||
"Documentation: deliverable.md updated, open questions listed?",
|
||||
]
|
||||
|
||||
OUTPUT_FORMAT = """\
|
||||
## VERDICT: APPROVE / REQUEST_CHANGES / COMMENT
|
||||
|
||||
## Critical findings
|
||||
(file:line — what's wrong — how to fix)
|
||||
|
||||
## Important findings
|
||||
(file:line — what's wrong)
|
||||
|
||||
## Nits
|
||||
(quick observations)
|
||||
|
||||
## Summary
|
||||
(1-2 sentences)
|
||||
"""
|
||||
|
||||
COMMANDS_HINT = """\
|
||||
git fetch origin
|
||||
git diff origin/main..feat/issue-112-wizard-refactor --stat
|
||||
git diff origin/main..feat/issue-112-wizard-refactor
|
||||
dotnet build && dotnet test
|
||||
"""
|
||||
@@ -0,0 +1,362 @@
|
||||
# Code review — feat/issue-112-wizard-refactor (issue #112)
|
||||
|
||||
**Reviewer:** Verifier (mvs_86868b01387b492aae27ce6f77aca4cb)
|
||||
**Branch:** `feat/issue-112-wizard-refactor` (base `origin/main`)
|
||||
**Commits reviewed:** `8f0f2ef`, `b81d865`, `f095209`, `7cfb196`, `c4a77d3`
|
||||
**Build:** ✅ `dotnet build GM-Relay.slnx` — 0 warnings, 0 errors
|
||||
**Tests:** 580 passed / 2 skipped / 1 failed. 1 failure is the pre-existing
|
||||
`DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease`
|
||||
(uncommitted release work in working tree, not part of this branch).
|
||||
|
||||
## VERDICT: REQUEST_CHANGES
|
||||
|
||||
The branch is **NOT shippable in its current state.** Every choice button
|
||||
and every "Другое…" button in the wizard is silently broken at runtime
|
||||
due to a wire-format mismatch between the renderer and the dispatcher.
|
||||
A user who clicks "D&D 5e", "Pathfinder 2e", "Waitlist вкл", "Опубликовать",
|
||||
or any "Другое… ✏️" button will see "⚠️ Неизвестная кнопка" instead of
|
||||
the wizard advancing. The 12 source-level smoke tests don't catch this
|
||||
because they only check string presence in source code, not the actual
|
||||
button-click → dispatch flow.
|
||||
|
||||
The architecture is otherwise sound: no Telegram.Bot/NetCord leak into
|
||||
Shared, single state-machine source, all DI wired, AOT-safe, parameterized
|
||||
SQL, owner/co-GM permission check with null-safety, SecretRedactor on the
|
||||
connection string. The fix is small and surgical.
|
||||
|
||||
---
|
||||
|
||||
## Critical findings
|
||||
|
||||
### C-1. Choice-button custom-id is missing the `choice:` segment — wizard is unusable end-to-end
|
||||
|
||||
**Files:**
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:79-80`
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:174-226`
|
||||
|
||||
**What's wrong.** The dispatcher's button handler matches `parts[1]`
|
||||
against `"choice"`, `"back"`, `"cancel"`, `"create"`, `"resume"`, and
|
||||
falls through to "default → Неизвестная кнопка" for anything else. The
|
||||
dispatcher's own documentation and the deliverable's wire-format table
|
||||
both agree the canonical choice-button format is
|
||||
`wizard:btn:choice:<step>:<value>`. But `ButtonCustomId` emits
|
||||
`$"wizard:btn:{step}:{value}"` — **the literal `choice:` segment is
|
||||
missing**. So clicking "D&D 5e" on the System step produces
|
||||
`wizard:btn:System:Dnd5e`, which NetCord strips the `[ComponentInteraction("wizard")]`
|
||||
prefix from, arriving at the dispatcher as `args = "btn:System:Dnd5e"` →
|
||||
`parts = ["btn", "System", "Dnd5e"]` → `parts[1] = "System"` → default
|
||||
branch → "⚠️ Неизвестная кнопка".
|
||||
|
||||
The same bug hits:
|
||||
- `RenderType` — "Одну игру" / "Пул игр" buttons (emits
|
||||
`wizard:btn:Type:single`, `wizard:btn:Type:pool`)
|
||||
- `RenderSystem` — D&D/Pathfinder/CoC/GURPS/Fate ("wizard:btn:System:Dnd5e" etc.)
|
||||
**and** the "⏭ Пропустить" button (emits `wizard:btn:System:_skip`)
|
||||
- `RenderDuration` — "3 часа" / "4 часа" / "5 часов" / "6 часов" and
|
||||
"⏭ Пропустить"
|
||||
- `RenderCapacity` / `RenderPoolSlotCapacity` — "Waitlist вкл" / "Без waitlist"
|
||||
(emits `wizard:btn:Capacity:waitlist:on` etc.)
|
||||
- `RenderPublish` — "Опубликовать" / "Только в чате"
|
||||
- `RenderPoolAddSlots` — "Добавить слот" / "Готово, к превью"
|
||||
- `RenderPickClub` — back/cancel still work (parts[1] = "back"/"cancel")
|
||||
|
||||
Only back, cancel, create, resume, and the "Другое… ✏️" → modal buttons
|
||||
are unaffected by *this specific* bug (see C-2 for modal buttons).
|
||||
|
||||
The smoke test `Dispatcher_ShouldParseAllWizardActionKinds`
|
||||
(`tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs:85-97`)
|
||||
checks that the strings `"choice"`, `"back"`, `"cancel"`, `"create"`,
|
||||
`"resume"` appear in `DiscordWizardInteractionModule.cs`. It doesn't
|
||||
check the renderer's output, so the bug is invisible to the test suite.
|
||||
The same file's comment at line 69 documents the *expected* format as
|
||||
`"btn:choice:Type:single"` — which would be the correct fix.
|
||||
|
||||
**How to fix.** Change `ButtonCustomId` in `DiscordWizardStep.cs`:
|
||||
|
||||
```csharp
|
||||
public static string ButtonCustomId(string step, string value) =>
|
||||
$"wizard:btn:choice:{step}:{value}";
|
||||
```
|
||||
|
||||
This brings the renderer into alignment with the dispatcher's switch
|
||||
case `"choice"` (line 209) and with the deliverable's table at line 83.
|
||||
Re-verify with a manual click-through of every button on every step, or
|
||||
add a parser-side test (see I-3 below).
|
||||
|
||||
### C-2. "Другое… ✏️" modal trigger buttons route to "default" instead of opening a modal
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:190, 206, 314`
|
||||
**Read against:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:207-226`
|
||||
|
||||
**What's wrong.** The renderer emits modal triggers as
|
||||
`wizard:btn:modal:SystemFreeText` (the "Другое… ✏️" button on the System
|
||||
step), `wizard:btn:modal:DurationFreeText` (Duration step), and
|
||||
`wizard:btn:modal:PoolSystemDurationFreeText` (PoolSystemDuration step).
|
||||
The dispatcher's button switch handles `"choice"`, `"back"`, `"cancel"`
|
||||
but not `"modal"`. The user's click on "Другое…" hits the default branch
|
||||
and returns "⚠️ Неизвестная кнопка" — no modal pops up, the wizard
|
||||
doesn't advance. The open question in `deliverable.md:125-132` ("Modal
|
||||
handler's free-text mapping is a hack") implicitly assumes these buttons
|
||||
*work* in production, so the design intent is clear but the implementation
|
||||
didn't deliver it.
|
||||
|
||||
**How to fix.** Add a `"modal"` case in the dispatcher's switch (between
|
||||
`"create"` and the existing branches, mirroring the "create" / "resume"
|
||||
special-case pattern):
|
||||
|
||||
```csharp
|
||||
if (parts[1] == "modal" && parts.Length >= 3)
|
||||
{
|
||||
var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId);
|
||||
if (modal is not null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
|
||||
}
|
||||
else
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Модал недоступен");
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
This bypasses the wizard's state machine entirely (the user's intent is
|
||||
"open a modal for free-text input", not "advance the wizard"). When the
|
||||
user submits the modal, `HandleModalAsync` will run, which already knows
|
||||
how to map `SystemFreeText` → `WizardStepNames.System` (line 453-462).
|
||||
Add a click-through test for at least one of the three steps.
|
||||
|
||||
### C-3. `ex.Message` from `CreateSessionHandler` is shipped to the user's Discord
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:104`
|
||||
|
||||
**What's wrong.** On submit failure the submitter edits the draft
|
||||
message with `$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}."`
|
||||
and ships it to the user-visible draft embed. The exception originates
|
||||
in `CreateSessionHandler` which talks to PostgreSQL via Dapper. Postgres
|
||||
exception messages routinely include the constraint name, the conflicting
|
||||
key value, and sometimes the full SQL text. Even the connection-string
|
||||
DSN could leak if an `NpgsqlException` wraps a connection failure. A
|
||||
malicious user who can submit many sessions can probe DB schema and
|
||||
state by reading the error strings.
|
||||
|
||||
**How to fix.** Log the full `ex` to the server-side log (already done
|
||||
on line 86) but show the user a generic error:
|
||||
|
||||
```csharp
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"💥 Ошибка при создании сессии. Попытка {payload.RetryCount}/{MaxRetries}. "
|
||||
+ "Попробуйте повторить или обратитесь к администратору.",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
```
|
||||
|
||||
If you want to preserve a per-error recovery hint (e.g. "Duplicate
|
||||
title — pick a different name"), map known exception types to localized
|
||||
strings; never embed the raw `ex.Message`.
|
||||
|
||||
---
|
||||
|
||||
## Important findings
|
||||
|
||||
### I-1. `Owner`/`CoGm` permission lookup runs on every wizard invocation, no cache
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:85-87`
|
||||
|
||||
The slash command issues an `await DiscordPermissionLookup.LoadManagerUserIdsAsync(...)`
|
||||
on every `/newsession-wizard` invocation. This is a 3-table join that
|
||||
scales linearly with the number of clubs the user manages. With a 24-hour
|
||||
draft lifetime and a single draft per owner, the same query repeats
|
||||
frequently. Not critical for the v3.8.0 release, but a 30-second in-memory
|
||||
cache would cut DB load noticeably during heavy wizard use. Same query
|
||||
shape lives in `DiscordNewSessionHandler` already (per the file comment),
|
||||
so a shared cache would benefit both.
|
||||
|
||||
### I-2. `_skip` sentinel bypasses the wizard's own validation
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:191, 207`
|
||||
**Read against:** `src/GmRelay.Shared/Features/Sessions\CreateSession\Wizard\GameCreationWizard.cs:287-290`
|
||||
|
||||
`GameCreationWizard.ApplySystemChoice` matches `"_skip"` and accepts it
|
||||
without further checks. The renderer emits `wizard:btn:System:_skip`.
|
||||
This is correct *if* the choice-button wire format gets fixed (C-1); the
|
||||
"`_skip`" string is hard-coded in the wizard and the renderer uses the
|
||||
same constant. But there's no central constant — both files have their
|
||||
own copies of the magic string. A future refactor that renames the
|
||||
sentinel in one place will silently break the other. Suggest
|
||||
`public const string SkipSentinel = "_skip"` on a shared class.
|
||||
|
||||
### I-3. Smoke tests are string-matching only — no behaviour coverage of the adapter
|
||||
|
||||
**File:** `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
|
||||
|
||||
All 12 smoke tests in this file are `Assert.Contains` against the
|
||||
source text. They would all pass against a file full of `// choice`
|
||||
comments and dead code. The test class header at line 8-17 acknowledges
|
||||
this ("smoke gate"), and the broader Wizard test suite
|
||||
(`tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/`) does
|
||||
exercise the platform-neutral state machine — but the *adapter* (the
|
||||
mapping from `ButtonInteractionContext` → `_wizard.HandleInteractionAsync`)
|
||||
has zero behavioural coverage. C-1 and C-2 both bypass the entire
|
||||
smoke-test surface.
|
||||
|
||||
Minimum bar to add: a parser-roundtrip test that takes the renderer's
|
||||
output for each `RenderX()` step and feeds it through the dispatcher's
|
||||
button handler to verify it doesn't fall into the default branch. Even
|
||||
a hand-rolled `ButtonInteractionContext` fake (or a helper that mimics
|
||||
the dispatcher's `args.Split(':', 4)` parser) would catch both bugs.
|
||||
Cost: ~50 lines; payoff: catches the entire class of "renderer and
|
||||
dispatcher disagree on the wire format" regressions.
|
||||
|
||||
### I-4. `AddComponentInteractions<TInteraction, TContext>` is called for Modal but the renderer relies on `Label → TextInput` layout
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:436-444`
|
||||
**Read against:** `DiscordWizardInteractionModule.cs:440-451`
|
||||
|
||||
`BuildModal` always wraps a single `TextInput` in a `Label` (Discord's
|
||||
`IModalComponentProperties` API requires labels). The dispatcher reads
|
||||
`Components[0]` and assumes it is a `Label` (line 446). This is
|
||||
consistent *today*, but if a future step needs two inputs in one
|
||||
modal, the extraction logic needs to walk all components, not just
|
||||
`[0]`. Document the constraint on the dispatcher's
|
||||
`ExtractModalText` method ("current contract: exactly one Label,
|
||||
one TextInput") and the renderer's `BuildModal` ("emits one
|
||||
Label+TextInput, no exceptions").
|
||||
|
||||
### I-5. The 3-retry counter is bound to the in-memory `WizardPayload`, not the DB row
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:86-89`
|
||||
|
||||
`payload.RetryCount += 1; SavePayload(draft, payload);` is called *before*
|
||||
the `if (payload.RetryCount >= MaxRetries)` check. The counter is
|
||||
serialized into `draft.PayloadJson` and re-loaded on the next click, so
|
||||
the bound is correct across bot restarts. However, the in-memory
|
||||
`draft` object is shared with the dispatcher's `_wizard` after
|
||||
`HandleInteractionAsync` returns, and a future refactor that pulls the
|
||||
payload from the DB instead of the in-memory copy could see a stale
|
||||
count. Document the invariant: "RetryCount is read from the in-memory
|
||||
payload after this line; do not re-load from DB before the comparison."
|
||||
|
||||
### I-6. `BuildResumeRow` re-uses the same customId suffix scheme as the in-wizard buttons
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:193-209`
|
||||
|
||||
`BuildResumeRow` emits three buttons:
|
||||
- "▶️ Продолжить" → `wizard:btn:resume:continue` ✓ (works)
|
||||
- "🔄 Заново" → `wizard:btn:resume:restart` ✓ (works)
|
||||
- "❌ Отмена" → `wizard:btn:cancel:1` (uses `DiscordWizardStep.ButtonCustomId("cancel", "1")`)
|
||||
|
||||
The cancel button relies on the dispatcher's `parts[1] == "cancel"`
|
||||
match. With C-1 fixed, this still works because cancel is in the
|
||||
switch, not the new "choice" path. But the `ButtonCustomId` signature
|
||||
will change semantics after C-1: it will become
|
||||
`$"wizard:btn:choice:{step}:{value}"`. `BuildResumeRow` passing
|
||||
`"cancel"` as the step will then produce `wizard:btn:choice:cancel:1`,
|
||||
which the dispatcher's switch will not match (no `"choice"` in the
|
||||
parts). Fix C-1 must update `BuildResumeRow` to emit
|
||||
`wizard:btn:cancel:1` directly (not via `ButtonCustomId`).
|
||||
|
||||
---
|
||||
|
||||
## Nits
|
||||
|
||||
- `DiscordWizardInteractionModule.cs:308, 357` — the select and modal
|
||||
handlers split args with `Split(':', 2)` (max 2 parts), while the
|
||||
button handler uses `Split(':', 4)` (max 4 parts). Inconsistent. The
|
||||
net effect is correct (select has 2 segments, modal has 2, button has
|
||||
2–4), but a comment explaining the max-count rationale would help.
|
||||
- `DiscordWizardStep.cs:74` — `throw new InvalidOperationException` on
|
||||
unknown step is fine for now, but a future maintainer adding a step
|
||||
to `WizardStepNames` will get a runtime exception. A `switch` exhaustiveness
|
||||
check (e.g. a private static assert in tests) would catch this at
|
||||
build time.
|
||||
- `DiscordPermissionLookup.cs:28-30` — `g.platform = 'Discord'` is
|
||||
hard-coded in the SQL. A `g.platform = @Platform` parameter would
|
||||
mirror the dispatcher's parameterized style and make the helper
|
||||
reusable for any future platform. Not blocking.
|
||||
- `DiscordWizardCommand.cs:72-81` — fetching the guild via REST inside
|
||||
the slash command costs an extra round-trip. The
|
||||
`resolvedPermissions` from the interaction already includes
|
||||
`Administrator`; only the "guild owner" case needs the REST call.
|
||||
Consider short-circuiting when `(resolvedPermissions & Administrator)
|
||||
!= 0`.
|
||||
- `DiscordWizardMessenger.cs:188-192` — hard-coded
|
||||
`new Color(0x5865F2)` (Discord blurple). Extracting to a const
|
||||
`WizardEmbedColor` would let the Web/Telegram versions use the same
|
||||
brand color if they ever render wizards.
|
||||
|
||||
---
|
||||
|
||||
## Migration V032 sanity check
|
||||
|
||||
**File:** `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
|
||||
|
||||
- Line 8-9 `ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
|
||||
— DEFAULT literal makes this O(1) on PostgreSQL ≥ 11. Safe on
|
||||
existing rows.
|
||||
- Lines 13-30 — `ALTER COLUMN ... TYPE TEXT USING ...::TEXT` on the
|
||||
`chat_id`, `message_thread_id`, `draft_message_id`, and renamed
|
||||
`owner_id` columns. All conversions are lossless
|
||||
(BIGINT → decimal-string, INT → decimal-string). Safe.
|
||||
- Line 26-27 `RENAME COLUMN owner_telegram_id TO owner_id` — the
|
||||
rename happens mid-migration. Any DML hitting the table
|
||||
concurrently that uses the old name will fail. For a bot that
|
||||
processes both Telegram and Discord traffic, this is a brief
|
||||
exclusivity lock. Consider splitting into two migrations
|
||||
(rename + new index, then type change) so each lock is shorter.
|
||||
Not blocking for v3.8.0, but document the brief lock window.
|
||||
|
||||
No DEFAULT-cascade issue, no NOT NULL on existing-row failure. The
|
||||
deliverable's "Will this fail on existing rows?" question gets a
|
||||
"no, but plan a maintenance window" answer.
|
||||
|
||||
---
|
||||
|
||||
## Architecture sanity (re-confirmed)
|
||||
|
||||
- `src/GmRelay.Shared/` — only references to Telegram.Bot/NetCord are
|
||||
in doc comments warning the developer not to add them. csproj has
|
||||
no Telegram.Bot or NetCord package references. `GameCreationWizard`,
|
||||
`IWizardMessenger`, `WizardCallbackData`, `WizardStepLimits`,
|
||||
`WizardStepNames`, `WizardPayload`, `WizardDraft` are all in exactly
|
||||
one place (Shared). ✓
|
||||
- `src/GmRelay.DiscordBot/Program.cs:87-102` — all 7 wizard services
|
||||
registered as singleton. All 3 `AddComponentInteractions<...>`
|
||||
calls present (Button, StringMenu, Modal). All 4 module/dispatcher
|
||||
classes (`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
|
||||
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`)
|
||||
registered. ✓
|
||||
- AOT-safety: no `System.Reflection`, no `dynamic`, no
|
||||
`Activator.CreateInstance`, no `Type.GetType` in the new Discord
|
||||
or Shared code. ✓
|
||||
- `DiscordPermissionLookup.cs:23-31` and
|
||||
`DiscordWizardMessenger.cs:154-165` and the inline
|
||||
`WizardClubLookup` in `DiscordWizardInteractionModule.cs:508-519`
|
||||
all use parameterized queries (`@GuildId`, `@Platform`,
|
||||
`@ExternalId`, `@OwnerId`). No SQL string interpolation. ✓
|
||||
- `Program.cs:54` — `SecretRedactor.RedactConnectionString` on the
|
||||
startup log. ✓
|
||||
- `DiscordWizardCommand.cs:51-94` — DM invocations rejected
|
||||
(`GuildId` null check), channel null-checked, member type-checked
|
||||
via `as GuildInteractionUser`, owner/admin/DB-manager permission
|
||||
check via `DiscordPermissionChecker.CanManageSchedule`. No NRE
|
||||
on `Context.User`. ✓
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Strong foundation: the platform-neutral refactor is well-executed, the
|
||||
state machine has solid test coverage, the Discord adapter's DI graph
|
||||
is clean, and security primitives (parameterized SQL, permission check,
|
||||
secret redaction) are in place. But the Discord adapter's runtime path
|
||||
is untested, and a single oversight in the renderer's button custom-id
|
||||
format (missing the `choice:` segment) breaks every choice button in
|
||||
the wizard at click time. The "Другое… ✏️" modal triggers are also
|
||||
unrouted in the dispatcher, leaving the free-text input path
|
||||
unreachable. The 3-attempt finalize loop works but leaks `ex.Message`
|
||||
to the user. After fixing C-1 / C-2 / C-3, adding I-3 (behavioural
|
||||
test of the adapter), and re-running the manual click-through checklist
|
||||
(System → Duration → DateTime → Capacity → Visibility → Publish →
|
||||
Confirm for Single; full pool flow), this branch is ready to merge.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
# Game Catalog and One-Shot Showcase — Design Spec
|
||||
|
||||
> Issue #39: feat: добавить каталог игр и витрину ваншотов
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
|
||||
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
|
||||
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### New Fields on `sessions` (Migration V027)
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
|
||||
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
|
||||
| `description` | `TEXT` | nullable | Short description for card |
|
||||
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
|
||||
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
|
||||
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
|
||||
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
|
||||
|
||||
### `GameSystem` Enum
|
||||
|
||||
```csharp
|
||||
public enum GameSystem
|
||||
{
|
||||
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
|
||||
OldSchoolEssentials, Dragonbane, BladesInTheDark,
|
||||
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
|
||||
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
|
||||
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
|
||||
Ironsworn, Other
|
||||
}
|
||||
```
|
||||
|
||||
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
|
||||
|
||||
### DTOs
|
||||
|
||||
```csharp
|
||||
public sealed record ShowcaseSessionDto(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
bool AllowDirectRegistration);
|
||||
|
||||
public sealed record ShowcaseFilter(
|
||||
DateFilter Date = DateFilter.All,
|
||||
SeatFilter Seats = SeatFilter.Any,
|
||||
GameSystem? System = null,
|
||||
bool? IsOneShot = null,
|
||||
string? Format = null);
|
||||
|
||||
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
|
||||
public enum SeatFilter { Available, Waitlist, Any }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### `/showcase` — Catalog Page
|
||||
|
||||
**Layout:**
|
||||
- Hero with title "Каталог игр"
|
||||
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
|
||||
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
|
||||
- Pagination (page + pageSize = 12)
|
||||
|
||||
**Filters:**
|
||||
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
|
||||
- Seats: "Есть места" | "Waitlist" | "Любое"
|
||||
- System: dropdown with all `GameSystem` values
|
||||
- Type: "Ваншот" | "Кампания" | "Любое"
|
||||
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
|
||||
|
||||
**Card Design:**
|
||||
- Cover image (fallback: colored placeholder with initials)
|
||||
- Title
|
||||
- System badge
|
||||
- Date + time (MSK)
|
||||
- Duration (e.g. "3 часа")
|
||||
- Format badge
|
||||
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
|
||||
- Club name (link to `/club/{slug}`)
|
||||
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
|
||||
|
||||
### `/s/{id}` — Public Session Detail (Updated)
|
||||
|
||||
New fields added to existing page:
|
||||
- Cover image (full-width hero)
|
||||
- System badge
|
||||
- Description block
|
||||
- Duration + format
|
||||
- GM contact (always visible: Telegram username or Discord tag)
|
||||
- If `allow_direct_registration`:
|
||||
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
|
||||
- Direct registration into `session_participants` via `SessionCapacityRules`
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### ISessionStore Methods
|
||||
|
||||
```csharp
|
||||
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
|
||||
ShowcaseFilter filter, int page, int pageSize);
|
||||
|
||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||
|
||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
|
||||
```
|
||||
|
||||
`GetShowcaseSessionsAsync` query:
|
||||
- Cross-group (all clubs with `public_schedule_enabled = true`)
|
||||
- Only `is_public = true` sessions
|
||||
- `scheduled_at > now() - interval '4 hours'`
|
||||
- `status <> 'Cancelled'`
|
||||
- Apply filters in SQL WHERE clause
|
||||
- Order by `scheduled_at ASC`
|
||||
- Offset/limit pagination
|
||||
|
||||
`RegisterFromShowcaseAsync`:
|
||||
- Check `allow_direct_registration = true`
|
||||
- Load session with `FOR UPDATE`
|
||||
- Count active + waitlisted participants
|
||||
- Use `SessionCapacityRules.DecideJoinStatus`
|
||||
- Insert participant with appropriate `registration_status`
|
||||
- Return true on success, false if full and no waitlist allowed
|
||||
|
||||
---
|
||||
|
||||
## Bot Integration
|
||||
|
||||
### Telegram Bot
|
||||
|
||||
During `CreateSessionCommand` flow, after title/link/time input:
|
||||
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
|
||||
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
|
||||
3. "Описание игры (краткое):" — text input, optional (skip button)
|
||||
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
|
||||
5. "Продолжительность (в часах):" — int input, optional
|
||||
6. "Обложка (URL или пропустить):" — text input, optional
|
||||
|
||||
During `/publish` flow:
|
||||
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
|
||||
|
||||
### Discord Bot
|
||||
|
||||
Same flow adapted for Discord interactions:
|
||||
- Slash command options or button menus for system/format
|
||||
- Modal input for description, duration, cover URL
|
||||
- Fuzzy matching on free-text system input
|
||||
|
||||
---
|
||||
|
||||
## Migration V027
|
||||
|
||||
```sql
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN system VARCHAR(50),
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN cover_image_url TEXT,
|
||||
ADD COLUMN duration_minutes INTEGER,
|
||||
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
|
||||
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX ix_sessions_showcase
|
||||
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
|
||||
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
|
||||
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
|
||||
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
|
||||
|
||||
---
|
||||
|
||||
## Version Bump
|
||||
|
||||
Issue label: `type:feature` → **minor bump**
|
||||
Current: `3.3.0` → Next: `3.4.0`
|
||||
|
||||
Files to sync:
|
||||
- `Directory.Build.props`
|
||||
- `compose.yaml` (bot, discord, web image tags)
|
||||
- `.gitea/workflows/deploy.yml` (`VERSION` env)
|
||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (from Issue #39)
|
||||
|
||||
- [ ] User can find a published game without accessing a private dashboard
|
||||
- [ ] Registration does not bypass existing seat/waitlist limits
|
||||
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
|
||||
- [ ] Filters work: date, seats, system, type, format
|
||||
- [ ] GM contact is always visible on public session detail
|
||||
- [ ] Direct registration respects `SessionCapacityRules`
|
||||
@@ -0,0 +1,424 @@
|
||||
# Completed Game Portfolio - Design Spec
|
||||
|
||||
> Issue #108: feat: добавить портфолио прошедших игр в витрину мастера
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add a public portfolio of completed tabletop adventures. A club owner or co-GM can group one or more completed sessions into an adventure card, publish it in selected GM profiles, optionally show it on a public club page, upload a cover image, and moderate player reviews. The existing `/showcase` catalog remains focused on recruitment for upcoming games.
|
||||
|
||||
---
|
||||
|
||||
## Product Decisions
|
||||
|
||||
- A portfolio item is an independent adventure entity, not a flag on one session.
|
||||
- One adventure can reference multiple completed sessions from the same club.
|
||||
- Reviews are submitted by authenticated players, not entered manually by a GM.
|
||||
- A player can review an adventure after being actively registered as a non-GM participant for at least one linked completed session. Waitlisted players are not eligible.
|
||||
- Each player can submit one review per adventure.
|
||||
- A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it.
|
||||
- Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links.
|
||||
- Adventure visibility in a public GM profile does not depend on club-page visibility.
|
||||
- The public club page shows its portfolio block only when that club page is enabled.
|
||||
- Club owners and co-GMs create, edit, publish, and moderate portfolio items. They select one or more GMs whose public profiles display the adventure.
|
||||
- Creation is available from the club page and through a quick action from a completed session.
|
||||
- Every published adventure has a dedicated public page at `/portfolio/{slug}`.
|
||||
- Cover images are uploaded to application-managed storage. The first implementation uses a persistent Docker volume behind a replaceable storage interface so an S3-compatible implementation can be added later without changing pages or database tables.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Add a bounded portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters.
|
||||
|
||||
Keep portfolio persistence separate from the already large scheduling store. `IPortfolioStore` and `PortfolioService` own portfolio reads, writes, and review submission. `AuthorizedPortfolioService` wraps protected management operations and reuses `ISessionStore.IsGroupManagerAsync` plus the existing current-user identity model for owner/co-GM authorization. Public Razor pages inject `IPortfolioStore` directly for sanitized reads.
|
||||
|
||||
Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Migration V029
|
||||
|
||||
Create `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`.
|
||||
|
||||
### `portfolio_games`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `UUID` | primary key, generated | Adventure identifier |
|
||||
| `group_id` | `UUID` | not null, FK to `game_groups(id)` with cascade delete | Owning club |
|
||||
| `public_slug` | `VARCHAR(160)` | unique case-insensitive when non-null | Public route segment |
|
||||
| `title` | `VARCHAR(255)` | not null | Adventure title |
|
||||
| `description` | `TEXT` | nullable for drafts | Public description |
|
||||
| `cover_storage_key` | `TEXT` | nullable for drafts | Storage-provider-neutral cover key |
|
||||
| `system` | `VARCHAR(50)` | nullable | Game system |
|
||||
| `format` | `VARCHAR(20)` | nullable, checked against `Online`, `Offline`, `Hybrid` | Play format |
|
||||
| `completed_at` | `TIMESTAMPTZ` | not null | Portfolio ordering date |
|
||||
| `is_public` | `BOOLEAN` | not null, default false | Public visibility |
|
||||
| `published_at` | `TIMESTAMPTZ` | nullable | First publication timestamp |
|
||||
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
|
||||
Constraints and indexes:
|
||||
|
||||
```sql
|
||||
CHECK (NOT is_public OR (
|
||||
public_slug IS NOT NULL
|
||||
AND description IS NOT NULL
|
||||
AND cover_storage_key IS NOT NULL
|
||||
AND published_at IS NOT NULL
|
||||
))
|
||||
```
|
||||
|
||||
- Unique index on `lower(public_slug)` when `public_slug IS NOT NULL`.
|
||||
- Index on `(group_id, completed_at DESC)`.
|
||||
- Partial public index on `(completed_at DESC)` where `is_public = true`.
|
||||
|
||||
Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links.
|
||||
|
||||
Immediate statement triggers acquire one transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`, before any invariant-affecting rows are changed: publication transitions and deletes, required-link edits, session deletes and scheduled-date changes, and parent deletes that can cascade into required links. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Portfolio and schedule mutations are low volume, so this intentionally global lock establishes one advisory-lock then row-lock protocol, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card, card/advisory, and session/advisory deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
|
||||
|
||||
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then re-acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Session mutation paths use advisory-lock then `sessions` then linked `portfolio_games`; normal session-deletion handlers explicitly acquire the mutation lock, lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
|
||||
|
||||
### `portfolio_game_sessions`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||
| `session_id` | `UUID` | not null, unique, FK to `sessions(id)` with cascade delete | Linked completed session |
|
||||
|
||||
Primary key: `(portfolio_game_id, session_id)`.
|
||||
|
||||
The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure.
|
||||
|
||||
### `portfolio_game_masters`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||
| `player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Displayed GM |
|
||||
|
||||
Primary key: `(portfolio_game_id, player_id)`.
|
||||
|
||||
Add an index on `(player_id, portfolio_game_id)` for public GM profile reads.
|
||||
|
||||
### `portfolio_game_reviews`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `UUID` | primary key, generated | Review identifier |
|
||||
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||
| `author_player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Private author reference |
|
||||
| `author_display_name` | `VARCHAR(255)` | not null | Public snapshot |
|
||||
| `body` | `TEXT` | not null | Review text |
|
||||
| `publication_consent_at` | `TIMESTAMPTZ` | not null | Player consent timestamp |
|
||||
| `moderation_status` | `VARCHAR(20)` | not null, default `Pending`, checked | Moderation state |
|
||||
| `moderated_by_player_id` | `UUID` | nullable, FK to `players(id)` with set null on delete | Private moderator reference |
|
||||
| `moderated_at` | `TIMESTAMPTZ` | nullable | Moderation timestamp |
|
||||
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
|
||||
Constraints and indexes:
|
||||
|
||||
```sql
|
||||
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))
|
||||
UNIQUE (portfolio_game_id, author_player_id)
|
||||
```
|
||||
|
||||
- Author lookup index `ix_portfolio_game_reviews_author` on `(author_player_id)`.
|
||||
- Partial moderator lookup index `ix_portfolio_game_reviews_moderator` on `(moderated_by_player_id)` where `moderated_by_player_id IS NOT NULL`.
|
||||
- Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`.
|
||||
- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`.
|
||||
|
||||
---
|
||||
|
||||
## Cover Storage
|
||||
|
||||
### Contract
|
||||
|
||||
Add a small storage abstraction:
|
||||
|
||||
```csharp
|
||||
public interface IPortfolioCoverStorage
|
||||
{
|
||||
Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteIfExistsAsync(
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
string GetPublicPath(string storageKey);
|
||||
}
|
||||
```
|
||||
|
||||
`PortfolioCoverUploadResult` carries the generated storage key and normalized content type.
|
||||
|
||||
### Local Implementation
|
||||
|
||||
- Store covers below a configured `PortfolioCovers:StoragePath`.
|
||||
- Mount that path from a dedicated Docker volume, `portfolio_covers`.
|
||||
- Serve files through a dedicated `/portfolio-covers/{storageKey}` route.
|
||||
- Generate random names. Never use the uploaded filename as the storage key.
|
||||
- Accept `image/jpeg`, `image/png`, and `image/webp`.
|
||||
- Limit uploads to 5 MiB.
|
||||
- Validate file signatures server-side before writing the final file.
|
||||
- Write to a temporary file, validate, then atomically move into place.
|
||||
- On successful replacement, delete the old file.
|
||||
- On database failure after upload, delete the newly uploaded file.
|
||||
- Deleting an adventure deletes its current cover after successful database deletion.
|
||||
|
||||
The storage key remains provider-neutral. A future S3-compatible implementation can replace the local service registration and use the same stored key.
|
||||
|
||||
---
|
||||
|
||||
## Service Contracts
|
||||
|
||||
Add sanitized DTOs to `IPortfolioStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links.
|
||||
|
||||
Representative contracts:
|
||||
|
||||
```csharp
|
||||
public sealed record PublicPortfolioGame(
|
||||
string Slug,
|
||||
string Title,
|
||||
string Description,
|
||||
string CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt,
|
||||
string? ClubName,
|
||||
string? ClubSlug,
|
||||
IReadOnlyList<PublicPortfolioMaster> Masters,
|
||||
IReadOnlyList<PublicPortfolioReview> Reviews);
|
||||
|
||||
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
|
||||
|
||||
public sealed record PublicPortfolioReview(
|
||||
string AuthorDisplayName,
|
||||
string Body,
|
||||
DateTime CreatedAt);
|
||||
```
|
||||
|
||||
Protected DTOs may carry IDs needed for editing and moderation.
|
||||
|
||||
### Public Reads
|
||||
|
||||
- Load one public adventure by slug for `/portfolio/{slug}`.
|
||||
- Load public adventures for a public GM profile regardless of club-page visibility.
|
||||
- Load public adventures for a public club page only when the club page is enabled.
|
||||
- Return only reviews with explicit consent and `Approved` moderation state.
|
||||
|
||||
### Protected Management
|
||||
|
||||
Through `AuthorizedPortfolioService`:
|
||||
|
||||
- Load draft and published adventure cards for a managed club.
|
||||
- Load eligible completed sessions for a managed club.
|
||||
- Create a draft, optionally preselecting one completed session from the quick action.
|
||||
- Update title, slug, description, system, format, linked sessions, and displayed GMs.
|
||||
- Upload and replace the cover.
|
||||
- Publish or unpublish a card.
|
||||
- Load pending and historical reviews for moderation.
|
||||
- Approve, reject, or hide a review.
|
||||
|
||||
All management operations require the current user to be an owner or co-GM of the owning club.
|
||||
|
||||
### Review Submission
|
||||
|
||||
An authenticated user can submit a review from `/portfolio/{slug}` only when:
|
||||
|
||||
- The adventure is public.
|
||||
- The user explicitly checks publication consent.
|
||||
- The user is registered in `session_participants` as a non-GM participant with `registration_status = 'Active'` for at least one linked session.
|
||||
- The linked session is in the past.
|
||||
- The user has not submitted a review for this adventure before.
|
||||
|
||||
The created review starts in `Pending`. The public page does not display it until moderation changes the status to `Approved`.
|
||||
|
||||
---
|
||||
|
||||
## User Interface
|
||||
|
||||
### Protected Club Page
|
||||
|
||||
Extend `GroupDetails.razor` with a completed-adventures section:
|
||||
|
||||
- List draft and published portfolio cards.
|
||||
- Show title, publication state, linked-session count, displayed-GM count, and review moderation count.
|
||||
- Provide a create action, edit links, and a link to the club's completed-session list.
|
||||
|
||||
### Completed Session Quick Action
|
||||
|
||||
Add a protected `/group/{groupId}/completed` page that lists past sessions for a managed club. Extend that page and session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected.
|
||||
|
||||
### Adventure Editor
|
||||
|
||||
Add a protected editor page:
|
||||
|
||||
- Title and public slug.
|
||||
- Description.
|
||||
- System and format.
|
||||
- Multi-select of completed sessions from the same club.
|
||||
- Multi-select of displayed GMs.
|
||||
- Cover upload and replacement.
|
||||
- Draft save and publish/unpublish actions.
|
||||
- Review moderation list with approve, reject, and hide actions.
|
||||
|
||||
The editor surfaces validation errors without publishing partial data.
|
||||
|
||||
### Public GM Profile
|
||||
|
||||
Extend `/gm/{slug}` with a "Проведённые приключения" portfolio section. Cards show cover, title, completion date, system, format, and a link to `/portfolio/{slug}`. This list is independent of club-page visibility.
|
||||
|
||||
### Public Club Page
|
||||
|
||||
Extend `/club/{slug}` with the same compact cards when the public club page is enabled.
|
||||
|
||||
### Public Adventure Page
|
||||
|
||||
Add `/portfolio/{slug}`:
|
||||
|
||||
- Cover hero.
|
||||
- Title, description, completion date, system, and format.
|
||||
- Optional public club link.
|
||||
- Public links to selected GM profiles.
|
||||
- Approved reviews with display-name snapshots.
|
||||
- For an eligible authenticated player without an existing review: review form with text area and required publication-consent checkbox.
|
||||
- For an authenticated ineligible player or a player who already submitted: a short non-sensitive status message.
|
||||
- For an anonymous visitor: a sign-in prompt instead of the form.
|
||||
|
||||
---
|
||||
|
||||
## Privacy And Security
|
||||
|
||||
- Public DTOs and rendered HTML never expose platform identifiers, player IDs, moderator IDs, linked session IDs, join links, or physical storage paths.
|
||||
- Cover upload validation uses content signatures, not only the browser-provided MIME type or filename.
|
||||
- Random storage keys prevent filename guessing and path traversal.
|
||||
- Review text is rendered as encoded text through normal Razor rendering.
|
||||
- Authorization is checked in the service layer for every management operation.
|
||||
- Eligibility is checked in the database-backed service when submitting a review; hiding the form is not treated as authorization.
|
||||
- The `/showcase` query keeps its current future-session condition and does not include completed adventures.
|
||||
|
||||
---
|
||||
|
||||
## Docker And Configuration
|
||||
|
||||
Add:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
discord:
|
||||
depends_on:
|
||||
bot:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
depends_on:
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||
volumes:
|
||||
- portfolio_covers:/app/portfolio-covers
|
||||
|
||||
volumes:
|
||||
portfolio_covers:
|
||||
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||
```
|
||||
|
||||
Development configuration uses a local directory under the application content root or an explicitly configured path.
|
||||
|
||||
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
|
||||
|
||||
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Update:
|
||||
|
||||
- `README.md` with public portfolio capability and local cover-storage configuration.
|
||||
- `docs/c4-system-context.md` with the portfolio slice and persistent cover volume.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Follow TDD for production changes.
|
||||
|
||||
### Schema And Contracts
|
||||
|
||||
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, immediate statement-level mutation locks, completed-session validator, deferred future-reschedule unpublish trigger, advisory-lock then session-row deletion locks, and the AppHost HTTP health gate.
|
||||
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit mutation-lock then session-lock then unpublish then session deletion, delete/reschedule mutation-gate ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, required-link insertion and final-future reschedule mutation locks before rows, opposing-order batch future reschedules serialized before session rows, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion.
|
||||
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
|
||||
- Existing showcase tests continue to assert the future-session catalog boundary.
|
||||
|
||||
### Authorization And Eligibility
|
||||
|
||||
- Owner and co-GM can manage a club adventure.
|
||||
- A manager of another club cannot manage it.
|
||||
- Only registered players from linked past sessions can submit.
|
||||
- A registered player can submit only once.
|
||||
- Consent is required.
|
||||
- A new review is pending and not public.
|
||||
- Only approved reviews are returned publicly.
|
||||
|
||||
### Cover Storage
|
||||
|
||||
- Accept valid JPEG, PNG, and WebP signatures.
|
||||
- Reject unsupported types, mismatched signatures, oversized files, and unsafe names.
|
||||
- Replacement deletes the old file only after the new file is stored.
|
||||
- Cleanup removes a newly uploaded file when persistence fails.
|
||||
|
||||
### UI Source Contracts
|
||||
|
||||
- Protected club and session-history pages expose management entry points.
|
||||
- Public GM and club pages render compact portfolio sections.
|
||||
- The public adventure page renders approved reviews and the conditional review form.
|
||||
- CSS defines responsive portfolio cards, cover hero, editor layout, and review states.
|
||||
|
||||
### Regression
|
||||
|
||||
- Run the full test suite.
|
||||
- Run `dotnet build`.
|
||||
- Run `dotnet format --verify-no-changes`.
|
||||
- Visually inspect the protected editor and public portfolio pages in the browser.
|
||||
|
||||
---
|
||||
|
||||
## Version Bump
|
||||
|
||||
Issue label: `type:feature` -> minor bump.
|
||||
|
||||
Current: `3.5.1` -> Next: `3.6.0`.
|
||||
|
||||
Synchronize:
|
||||
|
||||
- `Directory.Build.props`
|
||||
- `compose.yaml` (`bot`, `discord`, and `web` image tags)
|
||||
- `.gitea/workflows/deploy.yml` (`VERSION`)
|
||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Mapping
|
||||
|
||||
- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description.
|
||||
- [ ] A portfolio adventure can group one or more completed sessions from the same club.
|
||||
- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp.
|
||||
- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility.
|
||||
- [ ] A public club page shows portfolio cards when enabled.
|
||||
- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews.
|
||||
- [ ] A registered participant of a linked completed session can submit one review with explicit publication consent.
|
||||
- [ ] Reviews remain non-public until owner/co-GM moderation approves them.
|
||||
- [ ] Public DTOs and HTML do not expose private identifiers.
|
||||
- [ ] Uploaded covers survive container replacement through a persistent Docker volume.
|
||||
- [ ] Storage is isolated behind a replaceable interface for a later S3-compatible implementation.
|
||||
- [ ] The existing `/showcase` catalog remains focused on upcoming recruitment games.
|
||||
@@ -2,18 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithPgAdmin()
|
||||
.AddDatabase("gmrelay-db");
|
||||
.AddDatabase("gmrelaydb");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
|
||||
.WithHttpHealthCheck("/health", endpointName: "health");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Web>("web")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем wget для healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libgssapi-krb5-2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем только AOT-результаты из билда
|
||||
|
||||
@@ -42,12 +42,13 @@ public sealed class CancelSessionHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
@@ -89,7 +90,7 @@ public sealed class CancelSessionHandler(
|
||||
|
||||
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
|
||||
@@ -1,308 +1,261 @@
|
||||
using Dapper;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot.Types;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient,
|
||||
ILogger<CreateSessionHandler> logger)
|
||||
/// <summary>
|
||||
/// Telegram-side entry point for the wizard-driven session creation
|
||||
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
|
||||
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
|
||||
/// platform glue (mapping <c>Message</c> to draft fields, rendering
|
||||
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
|
||||
/// </summary>
|
||||
public sealed class CreateSessionHandler
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
private const int MaxRetries = 3;
|
||||
private const string PlatformName = "Telegram";
|
||||
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly SharedCreateSessionHandler _shared;
|
||||
private readonly IWizardMessenger _messenger;
|
||||
private readonly ILogger<CreateSessionHandler> _log;
|
||||
|
||||
public CreateSessionHandler(
|
||||
IWizardDraftRepository drafts,
|
||||
SharedCreateSessionHandler shared,
|
||||
IWizardMessenger messenger,
|
||||
ILogger<CreateSessionHandler> log)
|
||||
{
|
||||
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
|
||||
_drafts = drafts;
|
||||
_shared = shared;
|
||||
_messenger = messenger;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
foreach (var timeInput in parseResult.PastTimeInputs)
|
||||
/// <summary>
|
||||
/// Entry point for <c>/newsession</c>. If a non-expired draft
|
||||
/// already exists for this owner, returns <c>null</c> so the caller
|
||||
/// can render a "Continue / Start over / Cancel" menu.
|
||||
/// </summary>
|
||||
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
message.Chat.Id,
|
||||
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
||||
cancellationToken: cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
message.Chat.Id,
|
||||
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
|
||||
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
|
||||
OwnerId = ownerId,
|
||||
Platform = PlatformName,
|
||||
Step = WizardStepNames.Type,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
};
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
|
||||
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
message.Chat.Id,
|
||||
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||
draft.DraftMessageId = msgId;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
return draft;
|
||||
}
|
||||
|
||||
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
message.Chat.Id,
|
||||
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
/// <summary>
|
||||
/// Resume an existing draft — returns the draft row so the caller
|
||||
/// can re-render the resume/reset menu.
|
||||
/// </summary>
|
||||
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||
}
|
||||
|
||||
if (!parseResult.IsValid)
|
||||
/// <summary>
|
||||
/// Finalize: build shared command(s), call the shared handler, edit
|
||||
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
|
||||
/// times before deleting the draft.
|
||||
/// </summary>
|
||||
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
var payload = LoadPayload(draft);
|
||||
if (!IsComplete(payload, out var missing))
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
|
||||
cancellationToken: cancellationToken);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var title = parseResult.Title!;
|
||||
var link = parseResult.Link!;
|
||||
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
|
||||
var gmId = message.From!.Id;
|
||||
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
||||
var gmUsername = message.From.Username;
|
||||
|
||||
var chatId = message.Chat.Id;
|
||||
var chatTitle = message.Chat.Title ?? "Private Chat";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var commands = BuildCommands(draft, payload);
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username,
|
||||
platform = COALESCE(players.platform, 'Telegram'),
|
||||
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
|
||||
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username);
|
||||
""",
|
||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||
transaction);
|
||||
|
||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = g.id
|
||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
||||
) AS CanManage
|
||||
FROM game_groups g
|
||||
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
|
||||
""",
|
||||
new { ChatId = chatId, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
Guid groupId;
|
||||
if (existingGroup is null)
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
|
||||
RETURNING id;
|
||||
""",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!existingGroup.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
groupId = existingGroup.GroupId;
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
|
||||
new { ChatName = chatTitle, GroupId = groupId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||
message.Chat.IsForum,
|
||||
message.MessageThreadId);
|
||||
var messageThreadId = topicDestination.MessageThreadId;
|
||||
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||||
if (topicDestination.ShouldCreateForumTopic)
|
||||
{
|
||||
try
|
||||
{
|
||||
var topic = await botClient.CreateForumTopic(
|
||||
chatId: chatId,
|
||||
name: $"🎲 Игры: {title}",
|
||||
cancellationToken: cancellationToken);
|
||||
messageThreadId = topic.MessageThreadId;
|
||||
}
|
||||
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
|
||||
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessions = new List<SessionBatchDto>();
|
||||
|
||||
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
|
||||
{
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
{
|
||||
BatchId = batchId,
|
||||
GroupId = groupId,
|
||||
Title = title,
|
||||
Link = link,
|
||||
ScheduledAt = scheduledAt,
|
||||
ThreadId = messageThreadId,
|
||||
TopicCreatedByBot = topicCreatedByBot,
|
||||
MaxPlayers = parseResult.MaxPlayers,
|
||||
Status = SessionStatus.Planned
|
||||
},
|
||||
transaction);
|
||||
|
||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Message batchMessage;
|
||||
|
||||
if (imageReference is not null && renderResult.Text.Length <= 1024)
|
||||
{
|
||||
// Картинка + расписание умещаются в одном Telegram-фото с подписью
|
||||
try
|
||||
{
|
||||
batchMessage = await botClient.SendPhoto(
|
||||
chatId: chatId,
|
||||
messageThreadId: messageThreadId,
|
||||
photo: InputFile.FromString(imageReference),
|
||||
caption: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
|
||||
batchMessage = await botClient.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: messageThreadId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Текст слишком длинный для caption — fallback на два сообщения
|
||||
if (imageReference is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendPhoto(
|
||||
chatId: chatId,
|
||||
messageThreadId: messageThreadId,
|
||||
photo: InputFile.FromString(imageReference),
|
||||
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
|
||||
}
|
||||
}
|
||||
|
||||
batchMessage = await botClient.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: messageThreadId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
||||
|
||||
try
|
||||
{
|
||||
await botClient.DeleteMessage(
|
||||
chatId: chatId,
|
||||
messageId: message.MessageId,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
|
||||
await _shared.HandleAsync(cmd, ct);
|
||||
}
|
||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка при создании сессии");
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
|
||||
_log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
|
||||
payload.RetryCount += 1;
|
||||
SavePayload(draft, payload);
|
||||
if (payload.RetryCount >= MaxRetries)
|
||||
{
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
return;
|
||||
}
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? GetBatchImageReference(Message message, string? parsedImageUrl)
|
||||
// ── Build shared commands ────────────────────────────────────────
|
||||
// The shared handler creates one session per scheduled time in a
|
||||
// single transaction and assigns the same batch_id to all of them.
|
||||
// A wizard pool therefore produces ONE command with N times; a
|
||||
// single-game wizard produces ONE command with one time.
|
||||
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
||||
{
|
||||
var attachedPhotoFileId = message.Photo?
|
||||
.OrderByDescending(photo => photo.FileSize ?? 0)
|
||||
.ThenByDescending(photo => photo.Width * photo.Height)
|
||||
.FirstOrDefault()
|
||||
?.FileId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
|
||||
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||||
{
|
||||
return attachedPhotoFileId;
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||||
MaxPlayersForPool(pool),
|
||||
isOneShot: false),
|
||||
};
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim();
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
draft.OwnerId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalUsername: null);
|
||||
var group = new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
draft.ChatId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalChannelId: null,
|
||||
ExternalThreadId: draft.MessageThreadId);
|
||||
return new CreateSessionCommand(
|
||||
User: user,
|
||||
Group: group,
|
||||
Title: p.Title ?? string.Empty,
|
||||
Link: string.Empty,
|
||||
ScheduledTimes: scheduledTimes,
|
||||
MaxPlayers: maxPlayers,
|
||||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||
System: ParseSystem(p.System),
|
||||
Description: p.Description,
|
||||
Format: null,
|
||||
DurationMinutes: p.DurationMinutes,
|
||||
IsOneShot: isOneShot);
|
||||
}
|
||||
|
||||
private static GameSystem? ParseSystem(string? code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return null;
|
||||
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
private static bool IsComplete(WizardPayload p, out string missing)
|
||||
{
|
||||
var missingFields = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||||
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
||||
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
||||
if (p.Visibility is null) missingFields.Add("видимость");
|
||||
|
||||
if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||
// MaxPlayers = null is a valid "♾ Без лимита" choice
|
||||
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
|
||||
}
|
||||
else
|
||||
{
|
||||
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
|
||||
}
|
||||
missing = string.Join(", ", missingFields);
|
||||
return missingFields.Count == 0;
|
||||
}
|
||||
|
||||
// ── Payload I/O ──────────────────────────────────────────────────
|
||||
private static WizardPayload LoadPayload(WizardDraft draft)
|
||||
{
|
||||
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
||||
return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
}
|
||||
|
||||
private static void SavePayload(WizardDraft draft, WizardPayload p)
|
||||
{
|
||||
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
|
||||
}
|
||||
|
||||
// ── Keyboards ────────────────────────────────────────────────────
|
||||
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||||
{
|
||||
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
internal sealed record NewSessionParseResult(
|
||||
string? Title,
|
||||
string? Link,
|
||||
string? ImageUrl,
|
||||
int? MaxPlayers,
|
||||
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||
IReadOnlyList<string> PastTimeInputs,
|
||||
IReadOnlyList<string> InvalidTimeInputs,
|
||||
IReadOnlyList<string> InvalidSeatLimitInputs,
|
||||
IReadOnlyList<string> InvalidRecurringInputs)
|
||||
{
|
||||
public bool IsValid =>
|
||||
!string.IsNullOrWhiteSpace(Title) &&
|
||||
!string.IsNullOrWhiteSpace(Link) &&
|
||||
ScheduledTimes.Count > 0 &&
|
||||
InvalidSeatLimitInputs.Count == 0 &&
|
||||
InvalidRecurringInputs.Count == 0;
|
||||
}
|
||||
|
||||
internal static class NewSessionCommandParser
|
||||
{
|
||||
private const int MaxRecurringSessionCount = 52;
|
||||
private const int MaxRecurringIntervalDays = 365;
|
||||
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
|
||||
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
|
||||
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
|
||||
private static readonly string[] ImagePrefixes =
|
||||
[
|
||||
"\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:",
|
||||
"\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:",
|
||||
"\u041e\u0431\u043b\u043e\u0436\u043a\u0430:"
|
||||
];
|
||||
private static readonly string[] SeatLimitPrefixes =
|
||||
[
|
||||
"\u041c\u0435\u0441\u0442:",
|
||||
"\u041b\u0438\u043c\u0438\u0442:",
|
||||
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
|
||||
];
|
||||
private static readonly string[] RecurringCountPrefixes =
|
||||
[
|
||||
"\u0418\u0433\u0440:",
|
||||
"\u0421\u0435\u0441\u0441\u0438\u0439:",
|
||||
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
|
||||
];
|
||||
private static readonly string[] RecurringIntervalPrefixes =
|
||||
[
|
||||
"\u0428\u0430\u0433:",
|
||||
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
|
||||
];
|
||||
|
||||
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
|
||||
{
|
||||
string? title = null;
|
||||
string? link = null;
|
||||
string? imageUrl = null;
|
||||
int? maxPlayers = null;
|
||||
int? recurringCount = null;
|
||||
var recurringIntervalDays = 7;
|
||||
var scheduledTimes = new List<DateTimeOffset>();
|
||||
var pastTimeInputs = new List<string>();
|
||||
var invalidTimeInputs = new List<string>();
|
||||
var invalidSeatLimitInputs = new List<string>();
|
||||
var invalidRecurringInputs = new List<string>();
|
||||
|
||||
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
title = line[TitlePrefix.Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
link = line[LinkPrefix.Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
var imagePrefix = ImagePrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (imagePrefix is not null)
|
||||
{
|
||||
imageUrl = line[imagePrefix.Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (seatLimitPrefix is not null)
|
||||
{
|
||||
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
|
||||
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
|
||||
{
|
||||
maxPlayers = parsedMaxPlayers;
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidSeatLimitInputs.Add(seatLimitInput);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (recurringCountPrefix is not null)
|
||||
{
|
||||
var recurringInput = line[recurringCountPrefix.Length..].Trim();
|
||||
if (int.TryParse(recurringInput, out var parsedCount) &&
|
||||
parsedCount is >= 1 and <= MaxRecurringSessionCount)
|
||||
{
|
||||
recurringCount = parsedCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidRecurringInputs.Add(recurringInput);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (recurringIntervalPrefix is not null)
|
||||
{
|
||||
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
|
||||
if (int.TryParse(recurringInput, out var parsedInterval) &&
|
||||
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
|
||||
{
|
||||
recurringIntervalDays = parsedInterval;
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidRecurringInputs.Add(recurringInput);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var timeInput = line[TimePrefix.Length..].Trim();
|
||||
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
|
||||
{
|
||||
invalidTimeInputs.Add(timeInput);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scheduledAt <= nowUtc)
|
||||
{
|
||||
pastTimeInputs.Add(timeInput);
|
||||
continue;
|
||||
}
|
||||
|
||||
scheduledTimes.Add(scheduledAt);
|
||||
}
|
||||
|
||||
if (recurringCount.HasValue && scheduledTimes.Count == 1)
|
||||
{
|
||||
var firstScheduledTime = scheduledTimes[0];
|
||||
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
|
||||
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return new NewSessionParseResult(
|
||||
title,
|
||||
link,
|
||||
imageUrl,
|
||||
maxPlayers,
|
||||
scheduledTimes,
|
||||
pastTimeInputs,
|
||||
invalidTimeInputs,
|
||||
invalidSeatLimitInputs,
|
||||
invalidRecurringInputs);
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId },
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
|
||||
transaction);
|
||||
|
||||
if (session is null)
|
||||
@@ -150,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
|
||||
/// Translates the platform-neutral wizard contracts into the
|
||||
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
|
||||
/// (message editing, callback ack, group lookup) lives behind the
|
||||
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
|
||||
/// </summary>
|
||||
public sealed class TelegramWizardMessenger(
|
||||
ITelegramBotClient bot,
|
||||
NpgsqlDataSource dataSource) : IWizardMessenger
|
||||
{
|
||||
public async Task<string> EditDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||
}
|
||||
if (!TryParseMessageId(draft.DraftMessageId, out var messageId))
|
||||
{
|
||||
// No draft message recorded yet — fall back to sending a new one.
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
var msg = await bot.EditMessageText(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
text: text,
|
||||
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||
cancellationToken: ct);
|
||||
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public async Task<string> SendDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||
}
|
||||
int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread)
|
||||
? parsedThread
|
||||
: null;
|
||||
|
||||
var msg = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: text,
|
||||
messageThreadId: threadId,
|
||||
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||
cancellationToken: ct);
|
||||
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||
{
|
||||
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
||||
{
|
||||
// Adjusted from the plan: this codebase models "clubs" as game_groups
|
||||
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
|
||||
// and game_groups has no `club_id` FK). The picker therefore returns the
|
||||
// game_groups the owner manages as a GM (via group_managers), matching
|
||||
// the WizardClubOption contract (UUID id, name) used downstream.
|
||||
//
|
||||
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
|
||||
// (sql, object?) extension overload — not the (CommandDefinition) overload.
|
||||
// The wizard reaches this method on the PickClub visibility step
|
||||
// (issue #112 follow-up); using CommandDefinition here would fall back
|
||||
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
|
||||
// and throws PlatformNotSupportedException on AOT. Same root cause as
|
||||
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
|
||||
const string sql = """
|
||||
SELECT g.id AS ClubId,
|
||||
g.name AS Name
|
||||
FROM game_groups g
|
||||
JOIN group_managers gm ON gm.group_id = g.id
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalId
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<WizardClubOption>(
|
||||
sql,
|
||||
new { Platform = "Telegram", ExternalId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
private static bool TryParseChatId(string raw, out long chatId)
|
||||
{
|
||||
if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
chatId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseMessageId(string? raw, out int messageId)
|
||||
{
|
||||
if (raw is not null &&
|
||||
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
messageId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseThreadId(string? raw, out int threadId)
|
||||
{
|
||||
if (raw is not null &&
|
||||
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
threadId = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
public sealed class WizardDraftCleanupService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly ILogger<WizardDraftCleanupService> _log;
|
||||
|
||||
public WizardDraftCleanupService(
|
||||
IWizardDraftRepository drafts,
|
||||
ILogger<WizardDraftCleanupService> log)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TickInterval);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await RunOnceAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// graceful shutdown
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _drafts.DeleteExpiredAsync(ct);
|
||||
if (deleted > 0)
|
||||
{
|
||||
_log.LogInformation("Wizard cleanup deleted {Count} expired drafts", deleted);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard cleanup tick failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Telegram <see cref="Update"/> into the
|
||||
/// platform-neutral <see cref="WizardInteraction"/> consumed by
|
||||
/// <see cref="GameCreationWizard"/>. The mapping is the only place in
|
||||
/// the bot that knows about both <c>Telegram.Bot.Types</c> and the
|
||||
/// shared wizard contract, so a future Discord adapter can do the same
|
||||
/// for its native event without changing the wizard core.
|
||||
/// </summary>
|
||||
public static class WizardInteractionMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if <paramref name="update"/> carries a
|
||||
/// wizard-relevant interaction (text message, photo, or
|
||||
/// callback). Side-effect-free: the wizard state is not touched.
|
||||
/// </summary>
|
||||
public static bool TryMap(Update update, out WizardInteraction interaction)
|
||||
{
|
||||
interaction = default!;
|
||||
if (update.CallbackQuery is { } cb && cb.From is not null)
|
||||
{
|
||||
interaction = new WizardInteraction(
|
||||
OwnerId: cb.From.Id.ToString(CultureInfo.InvariantCulture),
|
||||
Text: null,
|
||||
CallbackPayload: cb.Data,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: cb.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (update.Message is { From: not null } msg)
|
||||
{
|
||||
// The original Telegram wizard dispatched on
|
||||
// `msg.Text is null` to identify a non-text update (photo,
|
||||
// document, sticker, …) and only ran the text pipeline
|
||||
// otherwise. We preserve that semantic: a message that
|
||||
// carries a photo is a photo interaction even if it has a
|
||||
// caption. Text is null for photos; the wizard checks
|
||||
// PhotoFileId separately when Text is null.
|
||||
//
|
||||
// Note: `Message.MessageId` is exposed as a read-only
|
||||
// property in Telegram.Bot, so the mapper cannot embed the
|
||||
// numeric id in the interaction. Text interactions never
|
||||
// need an ack, so the InteractionId is unused for them —
|
||||
// we just emit a stable sentinel.
|
||||
var hasPhoto = msg.Photo is { Length: > 0 };
|
||||
var text = hasPhoto ? null : msg.Text;
|
||||
var photoFileId = hasPhoto ? msg.Photo![^1].FileId : null;
|
||||
interaction = new WizardInteraction(
|
||||
OwnerId: msg.From!.Id.ToString(CultureInfo.InvariantCulture),
|
||||
Text: text,
|
||||
CallbackPayload: null,
|
||||
PhotoFileId: photoFileId,
|
||||
PhotoUrl: null,
|
||||
InteractionId: "msg");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-side renderer for wizard keyboards. Acts as the adapter
|
||||
/// between the platform-neutral <see cref="WizardAction"/> list
|
||||
/// produced by <see cref="WizardStepViewBuilder"/> and Telegram's
|
||||
/// <see cref="InlineKeyboardMarkup"/>. Each <see cref="WizardAction"/>
|
||||
/// becomes its own row (matching the pre-refactor Telegram layout).
|
||||
/// <see cref="WizardActionStyle"/> is currently ignored by Telegram
|
||||
/// because the platform has no native primary/danger/success button
|
||||
/// colours.
|
||||
/// </summary>
|
||||
public static class WizardStep
|
||||
{
|
||||
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
|
||||
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
|
||||
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
|
||||
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
|
||||
public const int MinCapacity = WizardStepLimits.MinCapacity;
|
||||
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
|
||||
public const int MaxDurationHours = WizardStepLimits.MaxDurationHours;
|
||||
|
||||
/// <summary>
|
||||
/// Render the platform-neutral view into a (text, Telegram keyboard)
|
||||
/// pair. Used by the wizard's surrounding code (router, create
|
||||
/// handler) when it needs to send a fresh draft message or render
|
||||
/// the resume/reset menu.
|
||||
/// </summary>
|
||||
public static (string Text, InlineKeyboardMarkup Keyboard) Render(
|
||||
WizardDraft draft,
|
||||
WizardPayload payload,
|
||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||
{
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||
return (text, ToInlineKeyboard(actions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a flat list of <see cref="WizardAction"/>s into a
|
||||
/// Telegram keyboard. Each action is placed in its own row to
|
||||
/// preserve the pre-refactor visual layout.
|
||||
/// </summary>
|
||||
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
|
||||
}
|
||||
var rows = new InlineKeyboardButton[actions.Count][];
|
||||
for (var i = 0; i < actions.Count; i++)
|
||||
{
|
||||
rows[i] = new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
|
||||
};
|
||||
}
|
||||
return new InlineKeyboardMarkup(rows);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +1,25 @@
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||
|
||||
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||
|
||||
public sealed class ExportCalendarHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
IConfiguration configuration)
|
||||
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
public Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
|
||||
new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
message.Chat.Id.ToString(),
|
||||
message.Chat.Title ?? "Private Chat",
|
||||
message.MessageThreadId?.ToString()),
|
||||
new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
message.From?.Id.ToString() ?? string.Empty,
|
||||
message.From?.FirstName ?? string.Empty,
|
||||
message.From?.Username));
|
||||
|
||||
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
||||
+ " FROM sessions s"
|
||||
+ " JOIN game_groups g ON s.group_id = g.id"
|
||||
+ " WHERE g.telegram_chat_id = @ChatId"
|
||||
+ " AND s.status = @Planned"
|
||||
+ " AND s.scheduled_at > NOW()"
|
||||
+ " ORDER BY s.scheduled_at ASC",
|
||||
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
||||
"📭 У этой группы нет запланированных сессий для экспорта.",
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("BEGIN:VCALENDAR");
|
||||
sb.AppendLine("VERSION:2.0");
|
||||
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
||||
|
||||
foreach (var s in sessionsList)
|
||||
{
|
||||
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
||||
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
||||
|
||||
sb.AppendLine("BEGIN:VEVENT");
|
||||
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
||||
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
||||
sb.AppendLine($"DTSTART:{dtStart}");
|
||||
sb.AppendLine($"DTEND:{dtEnd}");
|
||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
||||
sb.AppendLine("END:VEVENT");
|
||||
}
|
||||
|
||||
sb.AppendLine("END:VCALENDAR");
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
|
||||
|
||||
// Create calendar subscription
|
||||
string? subscriptionUrl = null;
|
||||
var baseUrl = configuration["Web:BaseUrl"];
|
||||
var senderId = message.From?.Id;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
||||
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
|
||||
new { ChatId = message.Chat.Id });
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
|
||||
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
|
||||
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
||||
|
||||
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-critical: if subscription creation fails, still send the file
|
||||
}
|
||||
}
|
||||
|
||||
var actions = subscriptionUrl is not null
|
||||
? new[]
|
||||
{
|
||||
new PlatformMessageAction(
|
||||
"calendar-subscription",
|
||||
"🔗 Подписаться на календарь",
|
||||
subscriptionUrl)
|
||||
}
|
||||
: Array.Empty<PlatformMessageAction>();
|
||||
|
||||
await messenger.SendCalendarFileAsync(
|
||||
new PlatformCalendarFile(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
||||
"schedule.ics",
|
||||
bytes,
|
||||
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||
actions),
|
||||
cancellationToken);
|
||||
return sharedHandler.HandleAsync(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
@@ -13,138 +10,88 @@ public sealed record DeleteSessionCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record DeleteSessionInfoDto(
|
||||
string Title,
|
||||
Guid BatchId,
|
||||
Guid GroupId,
|
||||
bool CanManage,
|
||||
int? ThreadId,
|
||||
bool TopicCreatedByBot);
|
||||
|
||||
public sealed class DeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
|
||||
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<DeleteSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var platformUser = new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
command.TelegramUserId.ToString(),
|
||||
string.Empty,
|
||||
null);
|
||||
|
||||
// 1. Fetch session and verify group manager.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.group_id AS GroupId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||
var platformGroup = new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
command.ChatId.ToString(),
|
||||
string.Empty);
|
||||
|
||||
if (session == null)
|
||||
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
|
||||
|
||||
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
|
||||
command.SessionId,
|
||||
platformUser,
|
||||
platformGroup,
|
||||
scheduleMessage);
|
||||
|
||||
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
var remainingInTopic = session.ThreadId.HasValue
|
||||
? await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sessions
|
||||
WHERE group_id = @GroupId
|
||||
AND thread_id = @ThreadId
|
||||
""",
|
||||
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||
transaction)
|
||||
: 0;
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
||||
if (session.ThreadId.HasValue &&
|
||||
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
|
||||
if (result.ThreadId.HasValue &&
|
||||
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
|
||||
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
|
||||
await messenger.DeleteThreadAsync(
|
||||
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
ct);
|
||||
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
|
||||
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
|
||||
ct);
|
||||
|
||||
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
|
||||
// A simple way is to re-render the list:
|
||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
||||
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
|
||||
@"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,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
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.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = command.ChatId,
|
||||
command.TelegramUserId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
// 5. Update the /listsessions message
|
||||
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
|
||||
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
if (listResult.Sessions.Count == 0)
|
||||
{
|
||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
|
||||
try
|
||||
{
|
||||
await messenger.UpdateGroupMessageAsync(
|
||||
scheduleMessage,
|
||||
"📭 В этой группе нет предстоящих игр.",
|
||||
[],
|
||||
ct);
|
||||
}
|
||||
catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
var renderResult = SessionListMessageRenderer.Render(sessionsList);
|
||||
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
|
||||
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
command.ChatId,
|
||||
command.MessageId,
|
||||
renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,114 +1,37 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||
|
||||
internal static class SessionListMessageRenderer
|
||||
{
|
||||
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
|
||||
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
|
||||
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
var canManage = sessions.Count > 0 && sessions.First().CanManage;
|
||||
if (!canManage)
|
||||
{
|
||||
return (text, null);
|
||||
}
|
||||
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
buttons.Add(
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
|
||||
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
|
||||
]);
|
||||
|
||||
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
||||
{
|
||||
buttons.Add(
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
|
||||
]);
|
||||
}
|
||||
|
||||
buttons.Add(
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
|
||||
]);
|
||||
}
|
||||
|
||||
return (text, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient)
|
||||
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
|
||||
IPlatformMessenger messenger)
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
|
||||
new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
message.Chat.Id.ToString(),
|
||||
message.Chat.Title ?? "Private Chat",
|
||||
message.MessageThreadId?.ToString()),
|
||||
new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
message.From?.Id.ToString() ?? string.Empty,
|
||||
message.From?.FirstName ?? string.Empty,
|
||||
message.From?.Username));
|
||||
|
||||
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||
@"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,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
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.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = message.Chat.Id,
|
||||
TelegramUserId = message.From?.Id,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
var result = await sharedHandler.HandleAsync(command, cancellationToken);
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
if (result.Sessions.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "📭 В этой группе нет предстоящих игр.",
|
||||
cancellationToken: cancellationToken);
|
||||
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var renderResult = SessionListMessageRenderer.Render(sessionsList);
|
||||
var text = SessionListMessageRenderer.RenderText(result.Sessions);
|
||||
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
|
||||
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: cancellationToken);
|
||||
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
internal static class SessionListMessageRenderer
|
||||
{
|
||||
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
|
||||
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
|
||||
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
if (sessions.Count == 0 || !sessions.First().CanManage)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var actions = new List<PlatformMessageAction>();
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"cancel_session:{session.Id}",
|
||||
$"❌ {dateTitle}",
|
||||
$"cancel_session:{session.Id}"));
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"reschedule_session:{session.Id}",
|
||||
$"⏰ {dateTitle}",
|
||||
$"reschedule_session:{session.Id}"));
|
||||
|
||||
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"promote_waitlist:{session.Id}",
|
||||
$"⬆️ Из ожидания {dateTitle}",
|
||||
$"promote_waitlist:{session.Id}"));
|
||||
}
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"delete_session:{session.Id}",
|
||||
$"🗑 Удалить {dateTitle}",
|
||||
$"delete_session:{session.Id}"));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
+80
-211
@@ -12,241 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record AwaitingProposalDto(
|
||||
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
||||
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles text input from the GM who has an AwaitingTime proposal.
|
||||
/// Parses reschedule options with a voting deadline, creates a voting message,
|
||||
/// and tags all participants.
|
||||
/// If no participants are registered, reschedules immediately.
|
||||
/// Telegram adapter for reschedule time input.
|
||||
/// Delegates core logic to the shared handler, then performs Telegram-specific
|
||||
/// message sending, DM notifications, vote_message_id storage, and cleanup.
|
||||
/// </summary>
|
||||
public sealed class HandleRescheduleTimeInputHandler(
|
||||
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to handle a text message as reschedule time input.
|
||||
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
|
||||
/// </summary>
|
||||
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
||||
return false;
|
||||
|
||||
var gmTelegramId = message.From.Id;
|
||||
var chatId = message.Chat.Id;
|
||||
var text = message.Text.Trim();
|
||||
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
|
||||
new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
message.From.Id.ToString(),
|
||||
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
|
||||
message.From.Username),
|
||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
|
||||
message.Text.Trim());
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Check if this GM has an AwaitingTime proposal in this chat
|
||||
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
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.proposed_by = @GmId
|
||||
AND rp.status = 'AwaitingTime'
|
||||
AND g.telegram_chat_id = @ChatId
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.telegram_id = @GmId
|
||||
)
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { GmId = gmTelegramId, ChatId = chatId });
|
||||
|
||||
if (proposal is null)
|
||||
var result = await sharedHandler.HandleAsync(command, ct);
|
||||
if (!result.Handled)
|
||||
return false;
|
||||
|
||||
// 2. Parse voting input
|
||||
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
||||
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
||||
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
||||
command.Group,
|
||||
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Load participants (non-GM) signed up for this session
|
||||
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
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
|
||||
// 4. If no participants — reschedule immediately
|
||||
if (participants.Count == 0)
|
||||
if (result.IsRescheduledImmediately)
|
||||
{
|
||||
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
|
||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
|
||||
{
|
||||
await TryUpdateBatchMessage(
|
||||
command.Group,
|
||||
result.UpdatedView,
|
||||
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
|
||||
ct);
|
||||
}
|
||||
|
||||
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
|
||||
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Create voting message
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var options = votingInput.Options
|
||||
.Select((proposedAt, index) => new RescheduleOptionDto(
|
||||
Guid.NewGuid(),
|
||||
index + 1,
|
||||
proposedAt))
|
||||
.ToList();
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE reschedule_proposals
|
||||
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
|
||||
WHERE id = @Id
|
||||
""",
|
||||
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
foreach (var option in options)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
||||
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
||||
""",
|
||||
new
|
||||
{
|
||||
option.OptionId,
|
||||
ProposalId = proposal.Id,
|
||||
option.ProposedAt,
|
||||
option.DisplayOrder
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// Voting mode
|
||||
var voteText = BuildVotingMessage(
|
||||
proposal.Title,
|
||||
proposal.CurrentScheduledAt,
|
||||
votingInput.Deadline,
|
||||
options,
|
||||
participants,
|
||||
result.Title!,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt!.Value,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
[]);
|
||||
var keyboard = BuildVotingKeyboard(options);
|
||||
|
||||
var keyboard = BuildVotingKeyboard(result.Options);
|
||||
|
||||
var voteMsg = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: proposal.ThreadId,
|
||||
chatId: message.Chat.Id,
|
||||
messageThreadId: message.MessageThreadId,
|
||||
text: voteText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
||||
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
var optionsText = string.Join(
|
||||
"\n",
|
||||
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||
var directText = $"""
|
||||
🔄 <b>Голосование за перенос сессии</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
🗳 Варианты:
|
||||
{optionsText}
|
||||
|
||||
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
|
||||
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
|
||||
|
||||
Проголосуйте кнопкой в групповом сообщении.
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(
|
||||
participants.Select(p => new DirectNotificationRecipient(
|
||||
result.Participants.Select(p => new DirectNotificationRecipient(
|
||||
p.TelegramId,
|
||||
p.DisplayName)),
|
||||
directText,
|
||||
"reschedule-vote",
|
||||
proposal.SessionId,
|
||||
result.ProposalId.Value,
|
||||
ct);
|
||||
}
|
||||
|
||||
// Store vote message ID
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
||||
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
|
||||
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
|
||||
|
||||
logger.LogInformation(
|
||||
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
||||
proposal.SessionId,
|
||||
proposal.Id,
|
||||
options.Count,
|
||||
votingInput.Deadline);
|
||||
|
||||
// Delete GM's time input message
|
||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||
result.ProposalId.Value,
|
||||
result.ProposalId.Value,
|
||||
result.Options.Count,
|
||||
result.VotingDeadlineAt.Value);
|
||||
|
||||
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RescheduleImmediately(
|
||||
NpgsqlConnection connection, AwaitingProposalDto proposal,
|
||||
DateTimeOffset newTime, long chatId, CancellationToken ct)
|
||||
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
|
||||
{
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET scheduled_at = @NewTime,
|
||||
status = @Status,
|
||||
confirmation_message_id = NULL,
|
||||
confirmation_sent_at = NULL,
|
||||
one_hour_reminder_processed_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
SELECT s.notification_mode
|
||||
FROM sessions s
|
||||
JOIN reschedule_proposals rp ON rp.session_id = s.id
|
||||
WHERE rp.id = @Id
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||
transaction);
|
||||
new { Id = proposalId });
|
||||
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
||||
new { NewTime = newTime, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
||||
$"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
||||
ct);
|
||||
|
||||
// Re-render batch message with updated time
|
||||
await TryUpdateBatchMessage(proposal, ct);
|
||||
|
||||
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
||||
private async Task TryUpdateBatchMessage(
|
||||
PlatformGroup group,
|
||||
SessionBatchViewModel view,
|
||||
PlatformMessageRef scheduleMessage,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(group, view, scheduleMessage),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
|
||||
}
|
||||
}
|
||||
|
||||
internal static string BuildVotingMessage(
|
||||
@@ -268,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
||||
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
|
||||
"",
|
||||
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
||||
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
||||
@@ -349,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
"dd.MM HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var batchSessions = (await conn.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();
|
||||
|
||||
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.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, sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
if (proposal.BatchMessageId.HasValue)
|
||||
{
|
||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
||||
view,
|
||||
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
+35
-118
@@ -1,8 +1,7 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
@@ -15,130 +14,49 @@ public sealed record HandleRescheduleVoteCommand(
|
||||
int MessageId);
|
||||
|
||||
public sealed class HandleRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<HandleRescheduleVoteHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var platformUser = new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
command.TelegramUserId.ToString(),
|
||||
string.Empty,
|
||||
null);
|
||||
|
||||
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 { command.OptionId },
|
||||
transaction);
|
||||
var platformGroup = new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
command.ChatId.ToString(),
|
||||
string.Empty);
|
||||
|
||||
if (proposal is null)
|
||||
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
|
||||
command.OptionId,
|
||||
platformUser,
|
||||
platformGroup,
|
||||
command.CallbackQueryId,
|
||||
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
|
||||
|
||||
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
|
||||
return;
|
||||
}
|
||||
|
||||
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.telegram_id = @TelegramUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
if (playerId is null)
|
||||
{
|
||||
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
command.OptionId
|
||||
},
|
||||
transaction);
|
||||
|
||||
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();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
proposal.Title,
|
||||
proposal.CurrentScheduledAt,
|
||||
proposal.VotingDeadlineAt,
|
||||
options,
|
||||
participants,
|
||||
votes);
|
||||
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
||||
result.Title!,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
result.Votes);
|
||||
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -152,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
|
||||
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
|
||||
}
|
||||
|
||||
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
|
||||
ct);
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,13 @@ public sealed class InitiateRescheduleHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
@@ -83,10 +84,10 @@ public sealed class InitiateRescheduleHandler(
|
||||
// 3. Create proposal in AwaitingTime status
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
|
||||
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
|
||||
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
|
||||
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
|
||||
""",
|
||||
new { command.SessionId, GmId = command.TelegramUserId });
|
||||
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
|
||||
|
||||
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
"""
|
||||
SELECT rp.vote_message_id AS VoteMessageId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.thread_id AS ThreadId
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
@@ -169,7 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
|
||||
@@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger(
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(group.Platform);
|
||||
await bot.SendMessage(
|
||||
chatId: ParseLong(group.ExternalGroupId),
|
||||
messageThreadId: ParseNullableInt(group.ExternalThreadId),
|
||||
text: htmlText,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: BuildActionsMarkup(actions),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(messageRef.Platform);
|
||||
await bot.EditMessageText(
|
||||
chatId: ParseLong(messageRef.ExternalGroupId),
|
||||
messageId: ParseInt(messageRef.ExternalMessageId),
|
||||
text: htmlText,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: BuildActionsMarkup(actions),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(group.Platform);
|
||||
var topic = await bot.CreateForumTopic(
|
||||
chatId: ParseLong(group.ExternalGroupId),
|
||||
name: title,
|
||||
cancellationToken: ct);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Telegram,
|
||||
group.ExternalGroupId,
|
||||
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(group.Platform);
|
||||
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return bot.DeleteForumTopic(
|
||||
ParseLong(group.ExternalGroupId),
|
||||
ParseInt(group.ExternalThreadId),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(messageRef.Platform);
|
||||
return bot.DeleteMessage(
|
||||
ParseLong(messageRef.ExternalGroupId),
|
||||
ParseInt(messageRef.ExternalMessageId),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(message.Recipient.Platform);
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||
using System.Globalization;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
|
||||
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
@@ -20,7 +27,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
/// </summary>
|
||||
public sealed class UpdateRouter(
|
||||
HandleRsvpHandler rsvpHandler,
|
||||
CreateSessionHandler createSessionHandler,
|
||||
BotCreateSessionHandler createSessionHandler,
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
||||
@@ -29,14 +36,49 @@ public sealed class UpdateRouter(
|
||||
ListSessionsHandler listSessionsHandler,
|
||||
ExportCalendarHandler exportCalendarHandler,
|
||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
||||
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||
SharedWizard wizard,
|
||||
IWizardDraftRepository drafts,
|
||||
ITelegramBotClient bot,
|
||||
IConfiguration configuration,
|
||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||
{
|
||||
public async Task RouteAsync(Update update, CancellationToken ct)
|
||||
{
|
||||
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
|
||||
// (chat, thread, owner), every update routes to the wizard. The wizard is
|
||||
// responsible for both text input and callback handling.
|
||||
if (TryGetWizardContext(update, out _, out _, out var ownerId))
|
||||
{
|
||||
var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct);
|
||||
if (draft is not null)
|
||||
{
|
||||
// Resume / Reset / Cancel menu callbacks live in the router because
|
||||
// they cross draft boundaries (reset deletes + recreates a fresh
|
||||
// draft, which the wizard instance doesn't know how to do).
|
||||
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (WizardInteractionMapper.TryMap(update, out var interaction))
|
||||
{
|
||||
await wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
}
|
||||
|
||||
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
|
||||
// acknowledges the callback; the actual session creation lives in
|
||||
// CreateSessionHandler.
|
||||
if (update.CallbackQuery?.Data is { } data &&
|
||||
data == WizardCallbackData.Create())
|
||||
{
|
||||
await createSessionHandler.SubmitDraftAsync(draft, ct);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (update)
|
||||
{
|
||||
case { CallbackQuery: { } query }:
|
||||
@@ -60,9 +102,106 @@ public sealed class UpdateRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
|
||||
/// if the update was consumed and the wizard should be skipped. The wizard still
|
||||
/// owns "cancel" and "create".
|
||||
/// </summary>
|
||||
private async Task<bool> TryHandleDraftControlCallbackAsync(
|
||||
Update update, WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
|
||||
return false;
|
||||
|
||||
switch (data)
|
||||
{
|
||||
case WizardControlCallbacks.Resume:
|
||||
// Re-render the current step of the existing draft. We answer the
|
||||
// callback here because the wizard will not be called.
|
||||
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
|
||||
await bot.EditMessageText(
|
||||
chatId: cbMessage.Chat.Id,
|
||||
messageId: cbMessage.MessageId,
|
||||
text: text,
|
||||
replyMarkup: kb,
|
||||
cancellationToken: ct);
|
||||
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
|
||||
return true;
|
||||
|
||||
case WizardControlCallbacks.Reset:
|
||||
// Delete the existing draft and start a fresh one. The wizard is
|
||||
// bypassed entirely because the active draft is now gone.
|
||||
await drafts.DeleteAsync(draft.Id, ct);
|
||||
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
|
||||
var newDraft = await createSessionHandler.StartWizardAsync(
|
||||
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
|
||||
if (newDraft is null)
|
||||
{
|
||||
// Race: another wizard just started for the same owner. The
|
||||
// user can simply re-run /newsession. We don't loop.
|
||||
await bot.SendMessage(
|
||||
chatId: cbMessage.Chat.Id,
|
||||
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a synthetic <see cref="Message"/> carrying just the fields
|
||||
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
|
||||
/// </summary>
|
||||
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
|
||||
{
|
||||
Chat = new Chat { Id = chatId },
|
||||
MessageThreadId = messageThreadId,
|
||||
From = new User { Id = fromId },
|
||||
};
|
||||
|
||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||
SharedWizard.LoadPayload(draft);
|
||||
|
||||
internal static string GetCommandText(Message message)
|
||||
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
|
||||
/// Returns false for updates that carry no usable origin (e.g. inline queries).
|
||||
/// </summary>
|
||||
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId)
|
||||
{
|
||||
chatId = 0;
|
||||
messageThreadId = null;
|
||||
ownerId = string.Empty;
|
||||
|
||||
switch (update)
|
||||
{
|
||||
case { Message: { From: not null, Chat: { } chat } msg }:
|
||||
chatId = chat.Id;
|
||||
messageThreadId = msg.MessageThreadId;
|
||||
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
|
||||
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
||||
chatId = cbmChat.Id;
|
||||
messageThreadId = cb.Message?.MessageThreadId;
|
||||
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
|
||||
case { CallbackQuery: { From: not null } cb2 }:
|
||||
// Callback arrived without a message (e.g. from a Mini App). No chat
|
||||
// context → wizard cannot run on this update.
|
||||
ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
|
||||
{
|
||||
if (query.Data is not { } data || query.Message is not { } message)
|
||||
@@ -210,7 +349,7 @@ public sealed class UpdateRouter(
|
||||
break;
|
||||
|
||||
case "/newsession":
|
||||
await createSessionHandler.HandleAsync(message, ct);
|
||||
await HandleNewSessionCommandAsync(message, ct);
|
||||
break;
|
||||
|
||||
case "/listsessions":
|
||||
@@ -253,6 +392,45 @@ public sealed class UpdateRouter(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
// Try to start a fresh wizard. StartWizardAsync returns null when a
|
||||
// non-expired draft already exists for this (chat, thread, owner).
|
||||
var draft = await createSessionHandler.StartWizardAsync(message, ct);
|
||||
if (draft is not null)
|
||||
{
|
||||
// New draft was created and its first step has been rendered.
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing draft. Look it up so we can describe the current step and offer
|
||||
// a Continue / Start over / Cancel menu.
|
||||
var existing = await createSessionHandler.TryResumeAsync(message, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
// Race: the draft expired between the two calls (or the user lacks
|
||||
// ownership metadata). Fall back to silently starting a new wizard.
|
||||
await createSessionHandler.StartWizardAsync(message, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "У вас уже есть незавершённый мастер. Продолжить?",
|
||||
replyMarkup: ContinueResetCancelKeyboard(),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
|
||||
{
|
||||
// "Продолжить" re-renders the existing draft's current step (router-level).
|
||||
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
|
||||
// "Отмена" delegates to the wizard's normal cancel handler.
|
||||
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
|
||||
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
|
||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
||||
});
|
||||
|
||||
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
|
||||
@@ -273,3 +451,14 @@ public sealed class UpdateRouter(
|
||||
cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Router-level callback data for the Continue / Start over / Cancel menu shown
|
||||
/// when /newsession detects an existing wizard draft. Distinct from
|
||||
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
|
||||
/// </summary>
|
||||
internal static class WizardControlCallbacks
|
||||
{
|
||||
public const string Resume = "wizard:resume";
|
||||
public const string Reset = "wizard:reset";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- =============================================================
|
||||
-- V021: Add avatar_url column to players table
|
||||
-- =============================================================
|
||||
-- Scope: Support storing avatar URLs for Discord and other platforms.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE players
|
||||
ADD COLUMN avatar_url VARCHAR(500);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- =============================================================
|
||||
-- V022: Fix incorrectly oriented player_links for Discord↔Telegram
|
||||
-- =============================================================
|
||||
-- Scope: Reverse player_links where Discord was incorrectly made primary
|
||||
-- and Telegram secondary. Telegram (with historical group/session data)
|
||||
-- must always be the primary account.
|
||||
-- =============================================================
|
||||
|
||||
UPDATE player_links pl
|
||||
SET primary_player_id = pl.secondary_player_id,
|
||||
secondary_player_id = pl.primary_player_id
|
||||
FROM players p1, players p2
|
||||
WHERE pl.primary_player_id = p1.id
|
||||
AND pl.secondary_player_id = p2.id
|
||||
AND p1.platform = 'Discord'
|
||||
AND p2.platform = 'Telegram';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- =============================================================
|
||||
-- V023: Make legacy Telegram columns nullable for multi-platform
|
||||
-- =============================================================
|
||||
-- Scope: Allow Discord (and future platforms) to create players
|
||||
-- and game_groups without legacy telegram_* values.
|
||||
-- Existing Telegram data was backfilled in V016.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE game_groups
|
||||
ALTER COLUMN telegram_chat_id DROP NOT NULL,
|
||||
ALTER COLUMN gm_telegram_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE players
|
||||
ALTER COLUMN telegram_id DROP NOT NULL;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- =============================================================
|
||||
-- V024: Deprecate legacy Telegram-specific columns
|
||||
-- =============================================================
|
||||
-- Scope: Complete platform migration by backfilling any remaining
|
||||
-- external_* gaps and officially deprecating telegram_* columns.
|
||||
-- No columns are dropped — rollback-safe.
|
||||
-- =============================================================
|
||||
|
||||
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
|
||||
UPDATE players
|
||||
SET platform = 'Telegram',
|
||||
external_user_id = telegram_id::TEXT,
|
||||
external_username = telegram_username
|
||||
WHERE platform IS NULL;
|
||||
|
||||
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
|
||||
UPDATE game_groups
|
||||
SET platform = 'Telegram',
|
||||
external_group_id = telegram_chat_id::TEXT
|
||||
WHERE platform IS NULL;
|
||||
|
||||
-- 3. Add platform identity to calendar_subscriptions
|
||||
ALTER TABLE calendar_subscriptions
|
||||
ADD COLUMN user_platform VARCHAR(50),
|
||||
ADD COLUMN user_external_id VARCHAR(255);
|
||||
|
||||
UPDATE calendar_subscriptions
|
||||
SET user_external_id = user_telegram_id::TEXT,
|
||||
user_platform = 'Telegram'
|
||||
WHERE user_platform IS NULL;
|
||||
|
||||
-- 4. Migrate calendar subscription index
|
||||
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
|
||||
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
|
||||
|
||||
-- 5. Deprecation comments on legacy columns
|
||||
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
|
||||
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
|
||||
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
|
||||
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
|
||||
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- =============================================================
|
||||
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
|
||||
-- =============================================================
|
||||
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
|
||||
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
|
||||
-- =============================================================
|
||||
|
||||
UPDATE reschedule_proposals
|
||||
SET proposed_by_external_user_id = proposed_by::TEXT
|
||||
WHERE proposed_by_external_user_id IS NULL
|
||||
AND proposed_by IS NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Public club pages and read-only schedule publication controls.
|
||||
|
||||
ALTER TABLE game_groups
|
||||
ADD COLUMN public_slug VARCHAR(120),
|
||||
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE UNIQUE INDEX ux_game_groups_public_slug
|
||||
ON game_groups (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_sessions_public_schedule
|
||||
ON sessions (group_id, scheduled_at)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Showcase fields for game catalog / public session browsing.
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN system VARCHAR(50),
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN cover_image_url TEXT,
|
||||
ADD COLUMN duration_minutes INTEGER,
|
||||
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX ix_sessions_showcase
|
||||
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Public GM profiles for catalog and club trust pages.
|
||||
|
||||
CREATE TABLE master_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
|
||||
public_slug VARCHAR(120),
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
bio TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX ux_master_profiles_public_slug
|
||||
ON master_profiles (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_master_profiles_public
|
||||
ON master_profiles (lower(public_slug))
|
||||
WHERE is_public = true AND public_slug IS NOT NULL;
|
||||
@@ -0,0 +1,261 @@
|
||||
-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews.
|
||||
|
||||
CREATE TABLE portfolio_games (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
public_slug VARCHAR(160),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
cover_storage_key TEXT,
|
||||
system VARCHAR(50),
|
||||
format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
NOT is_public
|
||||
OR (
|
||||
public_slug IS NOT NULL
|
||||
AND description IS NOT NULL
|
||||
AND cover_storage_key IS NOT NULL
|
||||
AND published_at IS NOT NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX ux_portfolio_games_public_slug
|
||||
ON portfolio_games (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_games_group
|
||||
ON portfolio_games (group_id, completed_at DESC);
|
||||
|
||||
CREATE INDEX ix_portfolio_games_public
|
||||
ON portfolio_games (completed_at DESC)
|
||||
WHERE is_public = true;
|
||||
|
||||
CREATE TABLE portfolio_game_sessions (
|
||||
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (portfolio_game_id, session_id),
|
||||
UNIQUE (session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE portfolio_game_masters (
|
||||
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (portfolio_game_id, player_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_masters_player
|
||||
ON portfolio_game_masters (player_id, portfolio_game_id);
|
||||
|
||||
CREATE FUNCTION lock_portfolio_publication_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
|
||||
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
|
||||
BEFORE DELETE ON game_groups
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
|
||||
BEFORE DELETE ON players
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE FUNCTION validate_public_portfolio_game_required_links()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
target_portfolio_game_id UUID;
|
||||
target_portfolio_game_ids UUID[];
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
|
||||
IF TG_TABLE_NAME = 'portfolio_games' THEN
|
||||
target_portfolio_game_ids := ARRAY[NEW.id];
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
|
||||
ELSE
|
||||
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
|
||||
END IF;
|
||||
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION
|
||||
'portfolio publication validation requires read committed isolation'
|
||||
USING ERRCODE = '0A000';
|
||||
END IF;
|
||||
|
||||
SELECT pg.id
|
||||
INTO target_portfolio_game_id
|
||||
FROM portfolio_games pg
|
||||
WHERE pg.id = ANY(target_portfolio_game_ids)
|
||||
AND pg.is_public = true
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_masters pgm
|
||||
WHERE pgm.portfolio_game_id = pg.id
|
||||
)
|
||||
)
|
||||
LIMIT 1;
|
||||
|
||||
IF target_portfolio_game_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'published portfolio game % must have at least one linked session and at least one linked master',
|
||||
target_portfolio_game_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
final_scheduled_at TIMESTAMPTZ;
|
||||
BEGIN
|
||||
SELECT s.scheduled_at
|
||||
INTO final_scheduled_at
|
||||
FROM sessions s
|
||||
WHERE s.id = NEW.id;
|
||||
|
||||
IF final_scheduled_at >= now() THEN
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION
|
||||
'portfolio future reschedule requires read committed isolation'
|
||||
USING ERRCODE = '0A000';
|
||||
END IF;
|
||||
|
||||
PERFORM pg.id
|
||||
FROM portfolio_games pg
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
)
|
||||
ORDER BY pg.id
|
||||
FOR UPDATE OF pg;
|
||||
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
WHERE pg.is_public = true
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
|
||||
AFTER UPDATE OF scheduled_at ON sessions
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
|
||||
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
|
||||
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
|
||||
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE TABLE portfolio_game_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
author_display_name VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
publication_consent_at TIMESTAMPTZ NOT NULL,
|
||||
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
||||
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
|
||||
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||
moderated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (portfolio_game_id, author_player_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_author
|
||||
ON portfolio_game_reviews (author_player_id);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_moderator
|
||||
ON portfolio_game_reviews (moderated_by_player_id)
|
||||
WHERE moderated_by_player_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_public
|
||||
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_pending
|
||||
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||
WHERE moderation_status = 'Pending';
|
||||
@@ -0,0 +1,66 @@
|
||||
-- V030: Private club showcases. Adds club_memberships (member access control)
|
||||
-- and replaces sessions.is_public with a 4-state publication_mode enum.
|
||||
-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'.
|
||||
-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows).
|
||||
|
||||
-- 1. club_memberships
|
||||
CREATE TABLE club_memberships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
||||
CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'Member'
|
||||
CHECK (role IN ('Member')),
|
||||
message TEXT,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
decided_at TIMESTAMPTZ,
|
||||
decided_by UUID REFERENCES players(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Only one Active row per (group, player).
|
||||
-- Re-application after Rejected/Left creates a new row.
|
||||
CREATE UNIQUE INDEX ux_club_memberships_one_active
|
||||
ON club_memberships (group_id, player_id)
|
||||
WHERE status = 'Active';
|
||||
|
||||
CREATE INDEX ix_club_memberships_group_status
|
||||
ON club_memberships (group_id, status);
|
||||
|
||||
CREATE INDEX ix_club_memberships_player_status
|
||||
ON club_memberships (player_id, status);
|
||||
|
||||
-- 2. sessions.publication_mode (replaces is_public)
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None';
|
||||
|
||||
-- Backfill before constraint so existing data maps cleanly.
|
||||
UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true;
|
||||
UPDATE sessions SET publication_mode = 'None' WHERE is_public = false;
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD CONSTRAINT ck_sessions_publication_mode
|
||||
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
|
||||
|
||||
ALTER TABLE sessions DROP COLUMN is_public;
|
||||
|
||||
DROP INDEX IF EXISTS ix_sessions_public_schedule;
|
||||
DROP INDEX IF EXISTS ix_sessions_showcase;
|
||||
|
||||
CREATE INDEX ix_sessions_public_schedule
|
||||
ON sessions (group_id, scheduled_at)
|
||||
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
|
||||
|
||||
CREATE INDEX ix_sessions_showcase
|
||||
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
|
||||
|
||||
-- 3. portfolio_games.publication_mode
|
||||
-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors.
|
||||
ALTER TABLE portfolio_games
|
||||
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both'
|
||||
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
|
||||
|
||||
CREATE INDEX ix_portfolio_games_showcase
|
||||
ON portfolio_games (created_at DESC)
|
||||
WHERE publication_mode IN ('Catalog', 'Both');
|
||||
@@ -0,0 +1,21 @@
|
||||
-- V031: Per-(chat, thread, owner) wizard drafts for the game-creation wizard (issue #111).
|
||||
-- Stores in-progress wizard state in JSONB with a 24h TTL managed by WizardDraftCleanupService.
|
||||
|
||||
CREATE TABLE wizard_drafts (
|
||||
id UUID PRIMARY KEY,
|
||||
chat_id BIGINT NOT NULL,
|
||||
message_thread_id INT,
|
||||
owner_telegram_id BIGINT NOT NULL,
|
||||
step TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
draft_message_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wizard_drafts_owner
|
||||
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
|
||||
|
||||
CREATE INDEX idx_wizard_drafts_expires
|
||||
ON wizard_drafts(expires_at);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- V032: Platform-neutral wizard drafts (issue #112).
|
||||
-- Adds the platform discriminator and switches owner/chat/thread/message
|
||||
-- columns from numeric to TEXT so the same table can hold both Telegram
|
||||
-- ids (long) and Discord snowflakes (ulong). All conversions are safe:
|
||||
-- the affected columns are nullable except chat_id/owner_telegram_id
|
||||
-- which we cast via TEXT.
|
||||
|
||||
ALTER TABLE wizard_drafts
|
||||
ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram';
|
||||
|
||||
-- Convert chat_id: BIGINT → TEXT. Existing rows hold Telegram chat ids
|
||||
-- which convert losslessly to their decimal string form.
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN chat_id TYPE TEXT USING chat_id::TEXT;
|
||||
|
||||
-- Convert message_thread_id: INT (nullable) → TEXT (nullable).
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN message_thread_id TYPE TEXT USING message_thread_id::TEXT;
|
||||
|
||||
-- Convert draft_message_id: BIGINT (nullable) → TEXT (nullable).
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN draft_message_id TYPE TEXT USING draft_message_id::TEXT;
|
||||
|
||||
-- Rename owner_telegram_id → owner_id (now platform-agnostic) and
|
||||
-- convert from BIGINT to TEXT.
|
||||
ALTER TABLE wizard_drafts
|
||||
RENAME COLUMN owner_telegram_id TO owner_id;
|
||||
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN owner_id TYPE TEXT USING owner_id::TEXT;
|
||||
|
||||
-- Replace the old owner lookup index with one that uses the new column
|
||||
-- names and the platform discriminator.
|
||||
DROP INDEX IF EXISTS idx_wizard_drafts_owner;
|
||||
|
||||
CREATE INDEX idx_wizard_drafts_owner
|
||||
ON wizard_drafts(platform, owner_id);
|
||||
|
||||
CREATE INDEX idx_wizard_drafts_platform
|
||||
ON wizard_drafts(platform);
|
||||
@@ -1,5 +1,6 @@
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Database;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
@@ -12,6 +13,7 @@ using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
@@ -66,18 +68,29 @@ builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||
builder.Services.AddSingleton<CreateSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
|
||||
|
||||
// Wizard services (issue #111)
|
||||
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
|
||||
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||
builder.Services.AddSingleton<CancelSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
||||
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
||||
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||
|
||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||
@@ -95,6 +108,7 @@ builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
// ── Session scheduler ────────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
||||
builder.Services.AddHostedService<WizardDraftCleanupService>();
|
||||
|
||||
// ── Health check server ──────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<BotHealthCheckHostedService>();
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordDeleteSessionResult(
|
||||
string ReplyText,
|
||||
SessionBatchViewModel? UpdatedView,
|
||||
string? EmptyMessage = null);
|
||||
|
||||
public sealed class DiscordDeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
DiscordListSessionsHandler listSessionsHandler,
|
||||
ILogger<DiscordDeleteSessionHandler> logger)
|
||||
{
|
||||
public async Task<DiscordDeleteSessionResult> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
Guid sessionId,
|
||||
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 p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Только owner, администратор или manager могут удалять сессии.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)",
|
||||
transaction: transaction);
|
||||
_ = await connection.QuerySingleOrDefaultAsync<Guid?>(
|
||||
"""
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
FOR UPDATE OF s
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
AND pg.is_public = true
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
var deletedRows = await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM sessions s
|
||||
USING game_groups g
|
||||
WHERE s.group_id = g.id
|
||||
AND s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
if (deletedRows == 0)
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Сессия не найдена или уже удалена.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var updatedView = await listSessionsHandler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
userId,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
cancellationToken);
|
||||
|
||||
return updatedView is null
|
||||
? new DiscordDeleteSessionResult(
|
||||
"Сессия удалена.",
|
||||
UpdatedView: null,
|
||||
EmptyMessage: "В этом сервере нет предстоящих игр.")
|
||||
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using NetCord;
|
||||
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;
|
||||
@@ -13,13 +13,23 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
var guildId = Context.Guild?.Id.ToString()
|
||||
var guildId = Context.Interaction.GuildId?.ToString()
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
var channelId = Context.Channel.Id.ToString();
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
var guildOwnerId = 0UL;
|
||||
|
||||
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
|
||||
var view = await _handler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
Context.User.Id,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
CancellationToken.None);
|
||||
|
||||
if (view is null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
@@ -9,11 +10,22 @@ 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 sealed class DiscordListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker)
|
||||
{
|
||||
public Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
CancellationToken cancellationToken) =>
|
||||
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
|
||||
|
||||
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
@@ -21,15 +33,15 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
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
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active)::int as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted)::int 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()
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
@@ -44,11 +56,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
if (sessionList.Count == 0)
|
||||
return null;
|
||||
|
||||
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 });
|
||||
|
||||
var canManage = permissionChecker.CanManageSchedule(
|
||||
guildOwnerId,
|
||||
userId,
|
||||
dbManagerUserIds,
|
||||
resolvedPermissions);
|
||||
|
||||
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,
|
||||
p.external_username as TelegramUsername,
|
||||
sp.registration_status as RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
||||
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
||||
|
||||
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||
return canManage ? AddManagerActions(view) : view;
|
||||
}
|
||||
|
||||
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
|
||||
view with
|
||||
{
|
||||
Sessions = view.Sessions
|
||||
.Select(session =>
|
||||
{
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
return session;
|
||||
|
||||
var actions = session.AvailableActions
|
||||
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
|
||||
.ToList();
|
||||
|
||||
return session with { AvailableActions = actions };
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using NetCord;
|
||||
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;
|
||||
@@ -16,15 +16,54 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[SlashCommand("newsession", "Create a new game session")]
|
||||
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
|
||||
_logger.LogInformation(
|
||||
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
|
||||
Context.User.Id,
|
||||
Context.User.GetType().Name,
|
||||
Context.Interaction.GuildId,
|
||||
Context.Channel?.Id);
|
||||
|
||||
var guildId = Context.Interaction.GuildId
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
if (member is null)
|
||||
{
|
||||
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
|
||||
throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||
}
|
||||
|
||||
var resolvedPermissions = (ulong)member.Permissions;
|
||||
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||
|
||||
ulong guildOwnerId = 0;
|
||||
var guildName = guildId.ToString();
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
guildName = guild.Name;
|
||||
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
|
||||
guildId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
|
||||
}
|
||||
|
||||
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
|
||||
if (!timeResult.IsSuccess)
|
||||
{
|
||||
@@ -33,55 +72,57 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
|
||||
// Defer the response to avoid Discord 3-second interaction timeout
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
|
||||
|
||||
var view = await _handler.HandleAsync(
|
||||
guildId: guild.Id.ToString(),
|
||||
channelId: Context.Channel.Id.ToString(),
|
||||
guildId: guildId.ToString(),
|
||||
channelId: Context.Channel!.Id.ToString(),
|
||||
groupName: guildName,
|
||||
userId: Context.User.Id,
|
||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: guild.OwnerId,
|
||||
guildOwnerId: guildOwnerId,
|
||||
title: title,
|
||||
scheduledAt: timeResult.Value,
|
||||
maxPlayers: seats is null ? null : (int)seats.Value,
|
||||
joinLink: link,
|
||||
CancellationToken.None);
|
||||
|
||||
_logger.LogInformation("Session created successfully. Building render.");
|
||||
|
||||
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)));
|
||||
|
||||
_logger.LogInformation("Sending success response.");
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":white_check_mark: **Session created successfully!**";
|
||||
message.Embeds = embeds;
|
||||
message.Components = actionRows;
|
||||
});
|
||||
|
||||
_logger.LogInformation("Success response sent.");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message($":no_entry: {ex.Message}"));
|
||||
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $":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."));
|
||||
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using System.Globalization;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
@@ -12,35 +12,40 @@ public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, strin
|
||||
public sealed class DiscordNewSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<DiscordNewSessionHandler> logger)
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
public static TimeParseResult ParseTimeInput(string input)
|
||||
{
|
||||
if (DateTimeOffset.TryParseExact(
|
||||
input.Trim(),
|
||||
var trimmed = input.Trim();
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"yyyy-MM-dd HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
||||
out var result))
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt1))
|
||||
{
|
||||
if (result < DateTimeOffset.UtcNow)
|
||||
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, result.ToUniversalTime(), null);
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParseExact(
|
||||
input.Trim(),
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"dd.MM.yyyy HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
||||
out var altResult))
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt2))
|
||||
{
|
||||
if (altResult < DateTimeOffset.UtcNow)
|
||||
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
||||
@@ -49,6 +54,7 @@ public sealed class DiscordNewSessionHandler(
|
||||
public async Task<SessionBatchViewModel> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
string groupName,
|
||||
ulong userId,
|
||||
string userDisplayName,
|
||||
ulong resolvedPermissions,
|
||||
@@ -60,13 +66,18 @@ public sealed class DiscordNewSessionHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
|
||||
? title
|
||||
: groupName.Trim();
|
||||
|
||||
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",
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
@@ -75,6 +86,7 @@ public sealed class DiscordNewSessionHandler(
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
var transactionCommitted = false;
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
@@ -89,13 +101,13 @@ public sealed class DiscordNewSessionHandler(
|
||||
|
||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
|
||||
VALUES (@GroupName, '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 },
|
||||
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@@ -125,23 +137,19 @@ public sealed class DiscordNewSessionHandler(
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
transactionCommitted = true;
|
||||
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;
|
||||
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
|
||||
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordRescheduleHandler _handler;
|
||||
@@ -15,6 +15,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
|
||||
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,
|
||||
@@ -22,9 +23,44 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
||||
[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
|
||||
_logger.LogInformation(
|
||||
"reschedule called by user {UserId} ({UserType}) in guild {GuildId}",
|
||||
Context.User.Id,
|
||||
Context.User.GetType().Name,
|
||||
Context.Interaction.GuildId);
|
||||
|
||||
var guildId = Context.Interaction.GuildId
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
if (member is null)
|
||||
{
|
||||
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
|
||||
throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||
}
|
||||
|
||||
var resolvedPermissions = (ulong)member.Permissions;
|
||||
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||
|
||||
ulong guildOwnerId = 0;
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
|
||||
guildId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(sessionIdText, out var sessionId))
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
@@ -64,54 +100,55 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
|
||||
// Defer the response to avoid Discord 3-second interaction timeout
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Initiating reschedule for session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var result = await _handler.HandleAsync(
|
||||
guildId: guild.Id.ToString(),
|
||||
channelId: Context.Channel.Id.ToString(),
|
||||
guildId: guildId.ToString(),
|
||||
channelId: Context.Channel!.Id.ToString(),
|
||||
userId: Context.User.Id,
|
||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: guild.OwnerId,
|
||||
guildOwnerId: guildOwnerId,
|
||||
sessionId: sessionId,
|
||||
options: parsedOptions,
|
||||
deadline: deadlineResult.Value,
|
||||
CancellationToken.None);
|
||||
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message(
|
||||
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC."));
|
||||
_logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, result.ProposalId);
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.";
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message($":no_entry: {ex.Message}"));
|
||||
_logger.LogWarning(ex, "Unauthorized reschedule attempt by user {UserId}", Context.User.Id);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $":no_entry: {ex.Message}";
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message($":warning: {ex.Message}"));
|
||||
_logger.LogWarning(ex, "Invalid reschedule request by user {UserId}", Context.User.Id);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $":warning: {ex.Message}";
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message(":boom: Ошибка при запуске голосования."));
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,46 @@
|
||||
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;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordRescheduleVoteInput(
|
||||
Guid OptionId, ulong UserId, string InteractionId,
|
||||
string GuildId, string ChannelId, string MessageId);
|
||||
Guid OptionId,
|
||||
ulong UserId,
|
||||
string InteractionId,
|
||||
string GuildId,
|
||||
string ChannelId,
|
||||
string MessageId);
|
||||
|
||||
public sealed class DiscordRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||
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);
|
||||
var command = new HandleRescheduleVoteCommand(
|
||||
input.OptionId,
|
||||
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
|
||||
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
|
||||
input.InteractionId,
|
||||
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
|
||||
|
||||
// 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);
|
||||
var result = await sharedHandler.HandleAsync(command, ct);
|
||||
|
||||
if (proposal is null)
|
||||
return "Голосование уже завершено или не найдено.";
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.ReplyText!;
|
||||
}
|
||||
|
||||
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);
|
||||
result.Title!,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
result.Votes);
|
||||
|
||||
var channelIdUlong = ulong.Parse(input.ChannelId);
|
||||
var messageIdUlong = ulong.Parse(input.MessageId);
|
||||
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
|
||||
}
|
||||
|
||||
return "Ваш голос учтён. До дедлайна его можно изменить.";
|
||||
return result.ReplyText!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
HandleRsvpHandler rsvpHandler,
|
||||
DiscordDeleteSessionHandler deleteSessionHandler,
|
||||
DiscordRescheduleVoteHandler voteHandler,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
await joinSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateJoinCommand(input),
|
||||
result = await joinSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("leave_session")]
|
||||
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
await leaveSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
|
||||
result = await leaveSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("delete_session")]
|
||||
public async Task DeleteAsync(string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
try
|
||||
{
|
||||
var result = await deleteSessionHandler.HandleAsync(
|
||||
guildId: input.GuildId,
|
||||
channelId: input.ChannelId,
|
||||
userId: input.UserId,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: 0,
|
||||
sessionId: parsedSessionId,
|
||||
CancellationToken.None);
|
||||
|
||||
await CompleteDeleteResponseAsync(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
|
||||
await FollowupEphemeralAsync("Не удалось удалить сессию.");
|
||||
}
|
||||
}
|
||||
|
||||
[ComponentInteraction("rsvp")]
|
||||
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,7 +207,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
|
||||
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
|
||||
{
|
||||
var guild = Context.Guild
|
||||
var guildId = Context.Interaction.GuildId?.ToString(CultureInfo.InvariantCulture)
|
||||
?? 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.");
|
||||
@@ -176,7 +215,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
return new DiscordSessionInteractionInput(
|
||||
SessionId: sessionId,
|
||||
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
GuildId: guild.Id.ToString(CultureInfo.InvariantCulture),
|
||||
GuildId: guildId,
|
||||
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
|
||||
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
|
||||
UserId: Context.User.Id,
|
||||
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
|
||||
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
||||
}
|
||||
|
||||
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
|
||||
{
|
||||
var updatedView = result.UpdatedView;
|
||||
if (updatedView is not null && SourceMessageHasDeleteAction())
|
||||
{
|
||||
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
|
||||
}
|
||||
|
||||
if (updatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
|
||||
var reply = interactionReplies.Take(interactionId);
|
||||
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
|
||||
}
|
||||
|
||||
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
|
||||
{
|
||||
if (result.UpdatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
else if (result.EmptyMessage is not null)
|
||||
{
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Content = result.EmptyMessage;
|
||||
options.Embeds = [];
|
||||
options.Components = [];
|
||||
});
|
||||
}
|
||||
|
||||
await FollowupEphemeralAsync(result.ReplyText);
|
||||
}
|
||||
|
||||
private Task CompleteResponseAsync(string text) =>
|
||||
ModifyResponseAsync(options => options.Content = text);
|
||||
|
||||
private Task FollowupEphemeralAsync(string text) =>
|
||||
FollowupAsync(new InteractionMessageProperties()
|
||||
.WithContent(text)
|
||||
.WithFlags(MessageFlags.Ephemeral));
|
||||
|
||||
private bool SourceMessageHasDeleteAction() =>
|
||||
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
|
||||
|
||||
private static bool ComponentContainsDeleteAction(object? component)
|
||||
{
|
||||
if (component is null)
|
||||
return false;
|
||||
|
||||
if (component is IInteractiveComponent interactive
|
||||
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
|
||||
if (nestedComponents is null)
|
||||
return false;
|
||||
|
||||
foreach (var nestedComponent in nestedComponents)
|
||||
{
|
||||
if (ComponentContainsDeleteAction(nestedComponent))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
||||
InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Small lookup helper for Discord permission checks. The
|
||||
/// <see cref="DiscordNewSessionHandler"/> already runs the same SQL
|
||||
/// inline; this class is here so the wizard slash command can do the
|
||||
/// same check without duplicating the query string.
|
||||
/// </summary>
|
||||
internal static class DiscordPermissionLookup
|
||||
{
|
||||
public static async Task<IReadOnlyList<ulong>> LoadManagerUserIdsAsync(
|
||||
NpgsqlDataSource dataSource,
|
||||
ulong guildId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
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 p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||
var rows = await connection.QueryAsync<ulong>(
|
||||
sql,
|
||||
new { GuildId = guildId.ToString() });
|
||||
return rows.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Slash entry point for the Discord wizard. Mirrors the Telegram
|
||||
/// <c>/newsession-wizard</c> command: a fresh draft is created on
|
||||
/// first invocation, the persisted first-step message is re-shown
|
||||
/// when the user already has an active draft, and the owner/co-GM
|
||||
/// permission check from <see cref="DiscordPermissionChecker"/> is
|
||||
/// applied before any draft is created.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordWizardMessenger _messenger;
|
||||
private readonly DiscordPermissionChecker _permissions;
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<DiscordWizardCommand> _log;
|
||||
|
||||
public DiscordWizardCommand(
|
||||
DiscordWizardMessenger messenger,
|
||||
DiscordPermissionChecker permissions,
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<DiscordWizardCommand> log)
|
||||
{
|
||||
_messenger = messenger;
|
||||
_permissions = permissions;
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_dataSource = dataSource;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
|
||||
public async Task ExecuteAsync(
|
||||
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
|
||||
{
|
||||
var guildId = Context.Interaction.GuildId
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
|
||||
var channel = Context.Channel
|
||||
?? throw new InvalidOperationException("Channel data not available in interaction.");
|
||||
var channelId = channel.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var member = Context.User as GuildInteractionUser
|
||||
?? throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||
var resolvedPermissions = (ulong)member.Permissions;
|
||||
var userId = Context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Slash commands don't expose a CancellationToken on the context;
|
||||
// the REST call already has its own per-request cancellation. We
|
||||
// pass CancellationToken.None for the DB calls — they're cheap and
|
||||
// the host's shutdown will tear them down.
|
||||
var ct = CancellationToken.None;
|
||||
var ownerId = userId;
|
||||
ulong guildOwnerId = 0;
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_log.LogWarning(
|
||||
ex,
|
||||
"Bot is not a REST member of guild {GuildId}; falling back to permissions from interaction payload.",
|
||||
guildId);
|
||||
}
|
||||
|
||||
// Permission check: server owner, guild admin, or DB manager
|
||||
// for the Discord game_group.
|
||||
var dbManagerIds = await DiscordPermissionLookup.LoadManagerUserIdsAsync(
|
||||
_dataSource, guildId, ct);
|
||||
if (!_permissions.CanManageSchedule(guildOwnerId, Context.User.Id, dbManagerIds, resolvedPermissions))
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("⛔ Только owner, администратор или manager могут создавать сессии.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's already a draft, offer Continue / Start over.
|
||||
var existing = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (existing is not null && existing.Id != Guid.Empty)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📝 У вас уже есть активный мастер. Продолжить?")
|
||||
.WithFlags(MessageFlags.Ephemeral)
|
||||
.WithComponents(BuildResumeRow(existing.Id))));
|
||||
return;
|
||||
}
|
||||
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = guildId.ToString(CultureInfo.InvariantCulture),
|
||||
MessageThreadId = null,
|
||||
OwnerId = ownerId,
|
||||
Platform = "Discord",
|
||||
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
|
||||
? WizardStepNames.Title
|
||||
: WizardStepNames.Type,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
};
|
||||
// If the user passed `mode=pool` we pre-seed the payload so the
|
||||
// wizard's own branching lands on the pool flow.
|
||||
if (NormalizeMode(mode) is { } mt)
|
||||
{
|
||||
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new WizardPayload { Type = mt },
|
||||
WizardPayloadJsonContext.Default.WizardPayload);
|
||||
}
|
||||
|
||||
// Stash the context BEFORE sending so the messenger can both
|
||||
// send the message and persist the returned message id back.
|
||||
_contextStore.Set(draft.Id, new DiscordWizardContext(
|
||||
GuildId: guildId.ToString(CultureInfo.InvariantCulture),
|
||||
ChannelId: channelId,
|
||||
MessageId: string.Empty,
|
||||
ThreadId: null));
|
||||
|
||||
// Render + send the first step. Defer the response so we can
|
||||
// show the wizard message in the channel.
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
|
||||
try
|
||||
{
|
||||
var payload = LoadPayload(draft);
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
|
||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||
draft.DraftMessageId = msgId;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(msg =>
|
||||
{
|
||||
msg.Content = "🎲 Мастер запущен. См. сообщение ниже.";
|
||||
msg.Flags = MessageFlags.Ephemeral;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Failed to start wizard for user {OwnerId} in guild {GuildId}", ownerId, guildId);
|
||||
_contextStore.Remove(draft.Id);
|
||||
try
|
||||
{
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* best effort */
|
||||
}
|
||||
await Context.Interaction.ModifyResponseAsync(msg =>
|
||||
{
|
||||
msg.Content = "💥 Не удалось запустить мастер. Попробуйте позже.";
|
||||
msg.Flags = MessageFlags.Ephemeral;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static WizardCreationType? NormalizeMode(string? mode) =>
|
||||
mode?.ToLowerInvariant() switch
|
||||
{
|
||||
"single" or "одну" or "one" => WizardCreationType.Single,
|
||||
"pool" or "пул" => WizardCreationType.Pool,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||
string.IsNullOrEmpty(draft.PayloadJson)
|
||||
? new WizardPayload()
|
||||
: System.Text.Json.JsonSerializer.Deserialize(
|
||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
|
||||
private static IReadOnlyList<IMessageComponentProperties> BuildResumeRow(Guid draftId)
|
||||
{
|
||||
// Direct format strings (not ChoiceButtonCustomId) — these
|
||||
// are control actions (resume:cancel), not wizard-step choices.
|
||||
// The dispatcher's switch matches parts[1] as "resume" or
|
||||
// "cancel" directly, not as a "choice" prefix.
|
||||
var row = new ActionRowProperties();
|
||||
row.Add(new ButtonProperties(
|
||||
"wizard:btn:resume:continue",
|
||||
"▶️ Продолжить",
|
||||
ButtonStyle.Primary));
|
||||
row.Add(new ButtonProperties(
|
||||
"wizard:btn:resume:restart",
|
||||
"🔄 Заново",
|
||||
ButtonStyle.Secondary));
|
||||
row.Add(new ButtonProperties(
|
||||
"wizard:btn:cancel:1",
|
||||
"❌ Отмена",
|
||||
ButtonStyle.Danger));
|
||||
return new IMessageComponentProperties[] { row };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of where the wizard's draft message lives. The messenger
|
||||
/// needs this to re-send / re-edit the message after a 15-minute
|
||||
/// interaction token has expired.
|
||||
/// </summary>
|
||||
/// <param name="GuildId">Discord guild (server) id as a decimal string.</param>
|
||||
/// <param name="ChannelId">Channel id where the draft message was posted.</param>
|
||||
/// <param name="MessageId">Id of the currently active draft message.</param>
|
||||
/// <param name="ThreadId">Optional thread id; <c>null</c> for top-level channel posts.</param>
|
||||
public sealed record DiscordWizardContext(
|
||||
string GuildId,
|
||||
string ChannelId,
|
||||
string MessageId,
|
||||
string? ThreadId);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store of draft → context lookups. Lives for the lifetime of
|
||||
/// the process; the wizard's 24-hour expiry is enforced by
|
||||
/// <c>WizardDraftCleanupService</c>, so this cache is allowed to hold
|
||||
/// entries until the draft is finalized or explicitly removed.
|
||||
/// </summary>
|
||||
public interface IWizardContextStore
|
||||
{
|
||||
void Set(Guid draftId, DiscordWizardContext context);
|
||||
|
||||
bool TryGet(Guid draftId, out DiscordWizardContext context);
|
||||
|
||||
void Remove(Guid draftId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory implementation. Concurrent dictionary keyed by
|
||||
/// <see cref="Guid"/> is sufficient for a single-process Discord bot.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardContextStore : IWizardContextStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, DiscordWizardContext> store = new();
|
||||
|
||||
public void Set(Guid draftId, DiscordWizardContext context) =>
|
||||
store[draftId] = context;
|
||||
|
||||
public bool TryGet(Guid draftId, out DiscordWizardContext context) =>
|
||||
store.TryGetValue(draftId, out context!);
|
||||
|
||||
public void Remove(Guid draftId) => store.TryRemove(draftId, out _);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound component-interaction handler for the Discord wizard.
|
||||
///
|
||||
/// One class per interaction context type — NetCord's
|
||||
/// <c>ComponentInteractionModule<TContext></c> is single-context. All
|
||||
/// three classes share the same dispatch table (parse customId → load
|
||||
/// draft → call the shared
|
||||
/// <see cref="SharedWizard.HandleInteractionAsync"/>) implemented in
|
||||
/// <see cref="WizardInteractionDispatcher"/>.
|
||||
///
|
||||
/// Custom-id wire format (see <see cref="DiscordWizardStep"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>wizard:btn:choice:<step>:<value></c> — choice buttons</item>
|
||||
/// <item><c>wizard:btn:cancel</c>, <c>wizard:btn:back</c>, <c>wizard:btn:create</c></item>
|
||||
/// <item><c>wizard:btn:resume:<continue|restart></c></item>
|
||||
/// <item><c>wizard:select:<step></c> — StringSelectMenu</item>
|
||||
/// <item><c>wizard:modal:<step></c> — Modal submit</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The active draft is looked up by (platform="Discord", ownerId=userId);
|
||||
/// the custom-id never carries a draft id because the wizard assumes one
|
||||
/// active draft per owner.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardButtonModule : ComponentInteractionModule<ButtonInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardButtonModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleButtonAsync(Context, args);
|
||||
}
|
||||
|
||||
public sealed class DiscordWizardStringMenuModule : ComponentInteractionModule<StringMenuInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardStringMenuModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleStringMenuAsync(Context, args);
|
||||
}
|
||||
|
||||
public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardModalModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleModalAsync(Context, args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared dispatch table for the three wizard interaction modules.
|
||||
/// Owns all the stateful collaborators (drafts, context store, wizard
|
||||
/// state machine, submitter, messenger, reply cache) so the three
|
||||
/// NetCord module shells can stay trivially thin.
|
||||
/// </summary>
|
||||
public sealed class WizardInteractionDispatcher
|
||||
{
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly SharedWizard _wizard;
|
||||
private readonly DiscordWizardSubmitter _submitter;
|
||||
private readonly DiscordWizardMessenger _messenger;
|
||||
private readonly DiscordInteractionReplyCache _replies;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<WizardInteractionDispatcher> _log;
|
||||
|
||||
public WizardInteractionDispatcher(
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
SharedWizard wizard,
|
||||
DiscordWizardSubmitter submitter,
|
||||
DiscordWizardMessenger messenger,
|
||||
DiscordInteractionReplyCache replies,
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<WizardInteractionDispatcher> log)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_wizard = wizard;
|
||||
_submitter = submitter;
|
||||
_messenger = messenger;
|
||||
_replies = replies;
|
||||
_dataSource = dataSource;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steps that, after the wizard's state advance, expect the user
|
||||
/// to fill a popup. The dispatcher uses this to decide whether
|
||||
/// the interaction response is a Modal() (the user sees a popup)
|
||||
/// or a DeferredMessage() (the wizard's edit is the only visible
|
||||
/// feedback). Keep in sync with <see cref="DiscordWizardStep.OpenModalStep"/>
|
||||
/// returns.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlySet<string> StepsThatOpenModal = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
WizardStepNames.Title,
|
||||
WizardStepNames.Description,
|
||||
WizardStepNames.Cover,
|
||||
WizardStepNames.DateTime,
|
||||
WizardStepNames.Capacity,
|
||||
WizardStepNames.PoolSlotDateTime,
|
||||
WizardStepNames.PoolSlotCapacity,
|
||||
"SystemFreeText",
|
||||
"DurationFreeText",
|
||||
"PoolSystemDurationFreeText",
|
||||
};
|
||||
|
||||
// ── Button handler ────────────────────────────────────────────────
|
||||
public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
|
||||
{
|
||||
// NetCord only allows one response per interaction. The
|
||||
// previous implementation deferred too early and then
|
||||
// tried to "swap" the deferred response for a Modal — which
|
||||
// NetCord forbids. The new flow is: do the wizard work
|
||||
// (which is a separate REST call to edit the draft message),
|
||||
// THEN send the interaction response. The response is
|
||||
// either a Modal popup (when the new step needs text input)
|
||||
// or a plain DeferredMessage ack.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like one of:
|
||||
// "btn:choice:<step>:<value>" (a choice)
|
||||
// "btn:cancel"
|
||||
// "btn:back"
|
||||
// "btn:create"
|
||||
// "btn:resume:continue"
|
||||
// "btn:resume:restart"
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = args.Split(':', 4);
|
||||
if (parts.Length < 2 || parts[0] != "btn")
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестная команда");
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: "create" doesn't go through the wizard — the
|
||||
// submitter edits the draft message directly with the result
|
||||
// embed ("✅ Создано" or retry buttons). After the submitter
|
||||
// returns, ack the click so the user doesn't see "Application
|
||||
// did not respond".
|
||||
if (parts[1] == "create")
|
||||
{
|
||||
await _submitter.SubmitAsync(draft, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: "modal:<step>" — the renderer emits this for the
|
||||
// "Другое…" free-text buttons on System, Duration, and
|
||||
// PoolSystemDuration. The click intent is "open a modal for
|
||||
// free-text input" — NOT "advance the wizard". The wizard's
|
||||
// state advance happens when the user submits the modal.
|
||||
if (parts[1] == "modal" && parts.Length >= 3)
|
||||
{
|
||||
var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId);
|
||||
if (modal is not null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
|
||||
}
|
||||
else
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Не удалось открыть форму");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: "resume" — the slash command's resume row
|
||||
// gives the user a chance to keep or restart their active
|
||||
// draft. The wizard has no built-in resume case, so we
|
||||
// handle the two resume kinds directly.
|
||||
if (parts[1] == "resume")
|
||||
{
|
||||
await HandleResumeAsync(context, parts, draft, ownerId, interactionId, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Choice / back / cancel — route through the shared wizard.
|
||||
string callback;
|
||||
switch (parts[1])
|
||||
{
|
||||
case "choice":
|
||||
if (parts.Length < 4)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
|
||||
return;
|
||||
}
|
||||
callback = WizardCallbackData.Choice(parts[2], parts[3]);
|
||||
break;
|
||||
case "back":
|
||||
callback = WizardCallbackData.Back();
|
||||
break;
|
||||
case "cancel":
|
||||
callback = WizardCallbackData.Cancel();
|
||||
break;
|
||||
default:
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
|
||||
return;
|
||||
}
|
||||
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: null,
|
||||
CallbackPayload: callback,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: interactionId);
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
|
||||
// After the wizard's state advance, decide the response.
|
||||
// The wizard's EditDraftMessageAsync already updated the
|
||||
// draft embed; we just need the interaction response to
|
||||
// either pop a modal or quietly ack.
|
||||
if (parts[1] == "cancel")
|
||||
{
|
||||
_contextStore.Remove(draft.Id);
|
||||
}
|
||||
await RespondAfterWizardAsync(context, draft, ct);
|
||||
}
|
||||
|
||||
private async Task HandleResumeAsync(
|
||||
ButtonInteractionContext context,
|
||||
string[] parts,
|
||||
WizardDraft draft,
|
||||
string ownerId,
|
||||
string interactionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// resume:continue → re-render the current step (the wizard
|
||||
// itself doesn't know about resume, so we just edit the
|
||||
// draft message via the messenger).
|
||||
// resume:restart → delete the draft and prompt the user to
|
||||
// re-run /newsession-wizard.
|
||||
if (parts.Length >= 3 && parts[2] == "restart")
|
||||
{
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
// continue
|
||||
var payload = LoadPayload(draft);
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
|
||||
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
}
|
||||
|
||||
// ── StringSelectMenu handler ───────────────────────────────────────
|
||||
public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args)
|
||||
{
|
||||
// NetCord's interaction response type is locked after the
|
||||
// first SendResponse call. For menu selections we don't
|
||||
// need a popup, so we always defer. The wizard's edit is
|
||||
// a separate REST call.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like "select:<step>" (e.g. "select:Visibility" or
|
||||
// "select:PoolSystemDuration"). The chosen value lives in
|
||||
// SelectedValues[0].
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
// NetCord strips the matching ComponentInteraction "wizard"
|
||||
// prefix from the custom id and passes the remainder as `args`.
|
||||
// For `wizard:select:Visibility` the args arrive as
|
||||
// `select:Visibility` (2 parts when split on `:`), so the
|
||||
// length check uses `< 2` and the step lives at parts[1].
|
||||
var parts = args.Split(':', 2);
|
||||
if (parts.Length < 2 || parts[0] != "select" || context.Interaction.Data.SelectedValues.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестный выбор");
|
||||
return;
|
||||
}
|
||||
var step = parts[1];
|
||||
var value = context.Interaction.Data.SelectedValues[0];
|
||||
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredModifyMessage);
|
||||
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: null,
|
||||
CallbackPayload: WizardCallbackData.Choice(step, value),
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: interactionId);
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
}
|
||||
|
||||
// ── Modal submit handler ──────────────────────────────────────────
|
||||
public async Task HandleModalAsync(ModalInteractionContext context, string args)
|
||||
{
|
||||
// The modal text becomes the user's input. The wizard's
|
||||
// ApplyText dispatcher consumes it as either a text-input
|
||||
// step (Title, Description, etc.) or, via the helper, a
|
||||
// free-text variant of System/Duration/PoolSystemDuration.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like "modal:<step>" (e.g. "modal:Title" or
|
||||
// "modal:SystemFreeText"). The text value lives in
|
||||
// Data.Components[0].Component.Value (the wizard sends a
|
||||
// single Label wrapping a single TextInput).
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Same NetCord prefix-stripping as the select handler:
|
||||
// for `wizard:modal:Title` the args arrive as `modal:Title`
|
||||
// (2 parts when split on `:`).
|
||||
var parts = args.Split(':', 2);
|
||||
if (parts.Length < 2 || parts[0] != "modal" || context.Interaction.Data.Components.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Некорректный модал");
|
||||
return;
|
||||
}
|
||||
var step = parts[1];
|
||||
|
||||
var text = ExtractModalText(context);
|
||||
if (text is null)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Модал без ввода");
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal values are routed by step name. The shared wizard knows
|
||||
// how to apply Title, Description, etc.; the free-text variants
|
||||
// (SystemFreeText, DurationFreeText, PoolSystemDurationFreeText)
|
||||
// are mapped to the canonical step here so the wizard's existing
|
||||
// ApplyText dispatcher handles them.
|
||||
var wizardStep = MapModalStepToWizardStep(step);
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: text,
|
||||
CallbackPayload: null,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: interactionId);
|
||||
|
||||
// For free-text modal steps the wizard's "current step" is the
|
||||
// canonical step (System, Duration, etc.), but the user just
|
||||
// submitted via the free-text modal. Temporarily set the
|
||||
// draft.Step to the canonical step so the wizard's ApplyText
|
||||
// runs the right branch. The wizard then advances draft.Step
|
||||
// to the NEXT step (e.g. Duration) and persists that via
|
||||
// _drafts.UpsertAsync. We must NOT restore draft.Step to
|
||||
// the original value afterwards — the DB has already been
|
||||
// updated to the new step, and restoring locally would only
|
||||
// mask the truth from the next interaction's GetActiveAsync.
|
||||
if (draft.Step != wizardStep)
|
||||
{
|
||||
draft.Step = wizardStep;
|
||||
}
|
||||
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
}
|
||||
|
||||
// ── Response helper ───────────────────────────────────────────────
|
||||
private async Task RespondAfterWizardAsync(
|
||||
ButtonInteractionContext context,
|
||||
WizardDraft draft,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// The wizard's state machine has advanced draft.Step. Re-render
|
||||
// the new step locally to discover whether it expects a popup,
|
||||
// then send the appropriate response.
|
||||
try
|
||||
{
|
||||
if (StepsThatOpenModal.Contains(draft.Step))
|
||||
{
|
||||
var modal = DiscordWizardStep.BuildModal(draft.Step, draft.ChatId);
|
||||
if (modal is not null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Modal popup failed for step {Step}; falling back to ack.", draft.Step);
|
||||
}
|
||||
// No popup needed — the wizard's edit is the only visible
|
||||
// feedback. Acknowledge with a deferred message so Discord
|
||||
// doesn't show "Application did not respond".
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
private static string ExtractModalText(ModalInteractionContext context)
|
||||
{
|
||||
// The wizard builds each modal with a single Label wrapping a
|
||||
// single TextInput. We walk the (Component → Component) chain.
|
||||
if (context.Interaction.Data.Components.Count == 0) return null!;
|
||||
var first = context.Interaction.Data.Components[0];
|
||||
if (first is Label label && label.Component is TextInput input)
|
||||
{
|
||||
return input.Value ?? string.Empty;
|
||||
}
|
||||
return null!;
|
||||
}
|
||||
|
||||
private static string MapModalStepToWizardStep(string modalStep) => modalStep switch
|
||||
{
|
||||
// Free-text modals map back to the canonical wizard step that
|
||||
// knows how to apply the text.
|
||||
"SystemFreeText" => WizardStepNames.System,
|
||||
"DurationFreeText" => WizardStepNames.Duration,
|
||||
"PoolSystemDurationFreeText" => WizardStepNames.PoolSystemDuration,
|
||||
// Direct mappings.
|
||||
_ => modalStep,
|
||||
};
|
||||
|
||||
private async Task<IReadOnlyList<WizardClubOption>?> LoadClubsAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
// Inline the same query the messenger would run, so the
|
||||
// dispatcher's PickClub step sees the owner's real club list
|
||||
// instead of an empty array.
|
||||
return await WizardClubLookup.LoadClubsAsync(_dataSource, draft.OwnerId, ct);
|
||||
}
|
||||
|
||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||
string.IsNullOrEmpty(draft.PayloadJson)
|
||||
? new WizardPayload()
|
||||
: System.Text.Json.JsonSerializer.Deserialize(
|
||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
|
||||
private static async Task AckWithErrorAsync(Interaction interaction, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
await interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent($"⚠️ {text}")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standalone helper that queries the owner-club list without going
|
||||
/// through <see cref="DiscordWizardMessenger"/>. The dispatcher needs
|
||||
/// the list at the PickClub step; reusing the messenger's GetOwnerClubsAsync
|
||||
/// would create a circular DI graph (the messenger depends on
|
||||
/// IWizardContextStore which the dispatcher also needs).
|
||||
/// </summary>
|
||||
internal static class WizardClubLookup
|
||||
{
|
||||
public static async Task<IReadOnlyList<WizardClubOption>> LoadClubsAsync(
|
||||
NpgsqlDataSource dataSource, string ownerId, CancellationToken ct)
|
||||
{
|
||||
// Same SQL the messenger runs for the wizard's render path.
|
||||
// Filter by Owner|CoGm role and group by club to dedupe.
|
||||
const string sql = """
|
||||
SELECT g.id AS ClubId,
|
||||
g.name AS Name
|
||||
FROM game_groups g
|
||||
JOIN group_managers gm ON gm.group_id = g.id
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE p.platform = @Platform
|
||||
AND p.external_user_id = @OwnerId
|
||||
AND gm.role IN ('Owner', 'CoGm')
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name
|
||||
""";
|
||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||
sql,
|
||||
new { Platform = "Discord", OwnerId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Discord-side implementation of <see cref="IWizardMessenger"/>.
|
||||
/// Translates the platform-neutral wizard contract into NetCord REST
|
||||
/// calls and ephemeral follow-ups. The messenger has no access to the
|
||||
/// live interaction context — that lives on the inbound handler — so
|
||||
/// <see cref="AnswerInteractionAsync"/> stashes the toast text in
|
||||
/// <see cref="DiscordInteractionReplyCache"/>; the inbound module
|
||||
/// drains the cache and ships the actual <c>SendResponseAsync</c> call
|
||||
/// via the existing helper used by <c>DiscordPlatformMessenger</c>.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardMessenger : IWizardMessenger
|
||||
{
|
||||
private readonly RestClient _rest;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly DiscordInteractionReplyCache _replies;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly ILogger<DiscordWizardMessenger>? _log;
|
||||
|
||||
public DiscordWizardMessenger(
|
||||
RestClient rest,
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordInteractionReplyCache replies,
|
||||
IWizardContextStore contextStore)
|
||||
: this(rest, dataSource, replies, contextStore, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public DiscordWizardMessenger(
|
||||
RestClient rest,
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordInteractionReplyCache replies,
|
||||
IWizardContextStore contextStore,
|
||||
ILogger<DiscordWizardMessenger>? logger)
|
||||
{
|
||||
_rest = rest;
|
||||
_dataSource = dataSource;
|
||||
_replies = replies;
|
||||
_contextStore = contextStore;
|
||||
_log = logger;
|
||||
}
|
||||
|
||||
public async Task<string> EditDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_contextStore.TryGet(draft.Id, out var ctx))
|
||||
{
|
||||
// No stored context (e.g. service restart, draft from another
|
||||
// process). Fall back to sending a brand new message — the
|
||||
// caller will persist the returned id.
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
|
||||
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
|
||||
!ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
|
||||
{
|
||||
// Context is corrupt — recreate the message.
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
|
||||
await _rest.ModifyMessageAsync(
|
||||
channelId,
|
||||
messageId,
|
||||
options =>
|
||||
{
|
||||
options.Embeds = embed is null ? null : new[] { embed };
|
||||
options.Components = rows;
|
||||
});
|
||||
return ctx.MessageId;
|
||||
}
|
||||
catch (RestException ex) when (IsExpiredOrUnknownMessage(ex))
|
||||
{
|
||||
// Message was deleted or interaction token expired —
|
||||
// recreate the message in the original channel.
|
||||
_log?.LogWarning(
|
||||
ex,
|
||||
"Edit failed for draft {DraftId} (channel={ChannelId}, message={MessageId}); re-sending.",
|
||||
draft.Id,
|
||||
ctx.ChannelId,
|
||||
ctx.MessageId);
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SendDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_contextStore.TryGet(draft.Id, out var ctx))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot send wizard message: no context for draft {draft.Id}.");
|
||||
}
|
||||
|
||||
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard draft {draft.Id} has un-parseable channel id '{ctx.ChannelId}'.");
|
||||
}
|
||||
|
||||
var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
|
||||
var message = await _rest.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds(embed is null ? null : new[] { embed })
|
||||
.WithComponents(rows));
|
||||
|
||||
var newMessageId = message.Id.ToString(CultureInfo.InvariantCulture);
|
||||
_contextStore.Set(draft.Id, ctx with { MessageId = newMessageId });
|
||||
return newMessageId;
|
||||
}
|
||||
|
||||
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||
{
|
||||
// The wizard's "answer" is just a toast shown to the user.
|
||||
// Stash it in the existing reply cache; the inbound interaction
|
||||
// module drains it once the wizard returns. ShowAlert=false so
|
||||
// it appears as a quiet follow-up rather than a popup.
|
||||
_replies.Store(new PlatformInteractionReply(
|
||||
InteractionId: interactionId,
|
||||
Text: text ?? string.Empty,
|
||||
ShowAlert: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(
|
||||
string ownerId, CancellationToken ct)
|
||||
{
|
||||
// The Telegram messenger enumerates game_groups the owner
|
||||
// manages as a GM (V008 added group_managers with role
|
||||
// 'Owner'|'CoGm'). Discord follows the same convention.
|
||||
const string sql = """
|
||||
SELECT g.id AS ClubId,
|
||||
g.name AS Name
|
||||
FROM game_groups g
|
||||
JOIN group_managers gm ON gm.group_id = g.id
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalId
|
||||
AND gm.role IN ('Owner', 'CoGm')
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
// NativeAOT: direct (sql, params) overload — see
|
||||
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
|
||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||
sql,
|
||||
new { Platform = "Discord", ExternalId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
// ── Embed + component construction ────────────────────────────────
|
||||
private (EmbedProperties? embed, IReadOnlyList<IMessageComponentProperties> rows) BuildEmbedAndRows(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard)
|
||||
{
|
||||
// Embeds have a hard 4096-char limit — truncate to 3900 so the
|
||||
// wizard's own prefix/suffix additions still fit.
|
||||
var safeText = Truncate(text, 3900);
|
||||
var rows = BuildActionRowsFromActions(keyboard);
|
||||
return (BuildEmbed(safeText), rows);
|
||||
}
|
||||
|
||||
private static EmbedProperties BuildEmbed(string description) =>
|
||||
new EmbedProperties()
|
||||
.WithTitle("Мастер создания сессии")
|
||||
.WithDescription(description)
|
||||
.WithColor(new Color(0x5865F2));
|
||||
|
||||
private static string Truncate(string text, int max) =>
|
||||
text.Length <= max ? text : text[..max];
|
||||
|
||||
private static IReadOnlyList<IMessageComponentProperties> BuildActionRowsFromActions(
|
||||
IReadOnlyList<WizardAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return Array.Empty<IMessageComponentProperties>();
|
||||
}
|
||||
var rows = new List<IMessageComponentProperties>();
|
||||
// Discord allows up to 5 buttons per ActionRow. Lay them out
|
||||
// left-to-right, 5 per row.
|
||||
foreach (var chunk in actions.Chunk(5))
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var action in chunk)
|
||||
{
|
||||
var style = action.Style switch
|
||||
{
|
||||
WizardActionStyle.Primary => ButtonStyle.Primary,
|
||||
WizardActionStyle.Success => ButtonStyle.Success,
|
||||
WizardActionStyle.Danger => ButtonStyle.Danger,
|
||||
_ => ButtonStyle.Secondary,
|
||||
};
|
||||
var cid = action.Payload;
|
||||
if (cid.Length > DiscordWizardStep.MaxCustomIdLength)
|
||||
{
|
||||
// Truncate-by-omission isn't safe (customId must be
|
||||
// unique). The wizard's callback format is already
|
||||
// bounded — if we hit this, it's a bug.
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard action custom id '{cid}' exceeds Discord's 100-char limit.");
|
||||
}
|
||||
row.Add(new ButtonProperties(cid, action.Label, style));
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static bool IsExpiredOrUnknownMessage(RestException ex) =>
|
||||
ex.StatusCode == System.Net.HttpStatusCode.NotFound
|
||||
|| ex.StatusCode == System.Net.HttpStatusCode.Unauthorized
|
||||
|| ex.StatusCode == System.Net.HttpStatusCode.Forbidden;
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a wizard step into a Discord embed + components row + an
|
||||
/// optional modal popup. Lives in the DiscordBot project because the
|
||||
/// platform-neutral <see cref="WizardStepViewBuilder"/> doesn't know
|
||||
/// about embeds, action rows, or modals.
|
||||
///
|
||||
/// The renderer also exposes <see cref="OpenModal"/> so the slash
|
||||
/// command can return a "popup" response to the user (Telegram's
|
||||
/// "ForceReply" equivalent). Discord modals are limited to 5 components
|
||||
/// (typically labels wrapping text inputs) and a 45-character title.
|
||||
/// </summary>
|
||||
public static class DiscordWizardStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Discord custom-id budget is 100 characters. We pad our own
|
||||
/// identifiers to stay under it.
|
||||
/// </summary>
|
||||
public const int MaxCustomIdLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel returned in <see cref="DiscordWizardRender.OpenModalStep"/>
|
||||
/// when the renderer wants the interaction module to open a modal
|
||||
/// for the given step. The step name is the <see cref="WizardStepNames"/>
|
||||
/// value (e.g. <c>Title</c>, <c>DateTime</c>).
|
||||
/// </summary>
|
||||
public sealed record DiscordWizardRender(
|
||||
string EmbedTitle,
|
||||
string EmbedDescription,
|
||||
IReadOnlyList<IMessageComponentProperties> Components,
|
||||
string? OpenModalStep);
|
||||
|
||||
/// <summary>
|
||||
/// Build the embed + components for a wizard step. The caller is
|
||||
/// responsible for sending the message via
|
||||
/// <see cref="DiscordWizardMessenger"/>.
|
||||
/// </summary>
|
||||
public static DiscordWizardRender Render(
|
||||
WizardDraft draft,
|
||||
WizardPayload payload,
|
||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||
{
|
||||
return draft.Step switch
|
||||
{
|
||||
WizardStepNames.Type => RenderType(),
|
||||
WizardStepNames.Title => RenderTitle(),
|
||||
WizardStepNames.Description => RenderDescription(),
|
||||
WizardStepNames.Cover => RenderCover(),
|
||||
WizardStepNames.System => RenderSystem(),
|
||||
WizardStepNames.Duration => RenderDuration(),
|
||||
WizardStepNames.DateTime => RenderDateTime(),
|
||||
WizardStepNames.Capacity => RenderCapacity(),
|
||||
WizardStepNames.Visibility => RenderVisibility(),
|
||||
WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty<WizardClubOption>()),
|
||||
WizardStepNames.Publish => RenderPublish(),
|
||||
WizardStepNames.Confirm => RenderConfirm(payload),
|
||||
|
||||
WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
|
||||
WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
|
||||
WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
|
||||
WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
|
||||
WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
|
||||
|
||||
_ => throw new System.InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Custom-id helpers ─────────────────────────────────────────────
|
||||
// Three custom-id shapes for buttons, all with the literal "wizard" prefix
|
||||
// that the NetCord [ComponentInteraction("wizard")] matcher strips off.
|
||||
// After prefix-strip the dispatcher receives the suffix as `args`.
|
||||
//
|
||||
// Choice : wizard:btn:choice:<step>:<value> → wizard's ApplyChoice
|
||||
// Control : wizard:btn:<action>:1 → dispatcher special case
|
||||
// Modal trig. : wizard:btn:modal:<modalStep> → dispatcher opens modal
|
||||
//
|
||||
// The renderer's helpers below enforce these shapes so the dispatcher
|
||||
// parser and the wizard callbacks stay in lockstep.
|
||||
public static string ChoiceButtonCustomId(string step, string value) =>
|
||||
$"wizard:btn:choice:{step}:{value}";
|
||||
|
||||
public static string ControlButtonCustomId(string action) =>
|
||||
$"wizard:btn:{action}:1";
|
||||
|
||||
public static string ModalTriggerButtonCustomId(string modalStep) =>
|
||||
$"wizard:btn:modal:{modalStep}";
|
||||
|
||||
public static string SelectCustomId(string step) => $"wizard:select:{step}";
|
||||
|
||||
public static string ModalCustomId(string step) => $"wizard:modal:{step}";
|
||||
|
||||
public static bool TryParseButtonCustomId(string customId, out string step, out string value)
|
||||
{
|
||||
step = value = string.Empty;
|
||||
var parts = customId.Split(':', 5);
|
||||
if (parts.Length < 5 || parts[0] != "wizard" || parts[1] != "btn" || parts[2] != "choice")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
step = parts[3];
|
||||
value = parts[4];
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryParseSelectCustomId(string customId, out string step)
|
||||
{
|
||||
step = string.Empty;
|
||||
var parts = customId.Split(':', 3);
|
||||
return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "select" && (step = parts[2]) is not null;
|
||||
}
|
||||
|
||||
public static bool TryParseModalCustomId(string customId, out string step)
|
||||
{
|
||||
step = string.Empty;
|
||||
var parts = customId.Split(':', 3);
|
||||
return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "modal" && (step = parts[2]) is not null;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
// Three button factories, one per custom-id shape (see ChoiceButtonCustomId
|
||||
// comment above). RenderX() uses these to build rows; the call site
|
||||
// determines which kind of button each row needs.
|
||||
private static ButtonProperties ChoiceBtn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary)
|
||||
{
|
||||
var cid = ChoiceButtonCustomId(step, value);
|
||||
EnsureCustomIdFits(cid);
|
||||
return new ButtonProperties(cid, label, style);
|
||||
}
|
||||
|
||||
private static ButtonProperties ControlBtn(string label, string action, ButtonStyle style = ButtonStyle.Secondary)
|
||||
{
|
||||
var cid = ControlButtonCustomId(action);
|
||||
EnsureCustomIdFits(cid);
|
||||
return new ButtonProperties(cid, label, style);
|
||||
}
|
||||
|
||||
private static ButtonProperties ModalTriggerBtn(string label, string modalStep, ButtonStyle style = ButtonStyle.Secondary)
|
||||
{
|
||||
var cid = ModalTriggerButtonCustomId(modalStep);
|
||||
EnsureCustomIdFits(cid);
|
||||
return new ButtonProperties(cid, label, style);
|
||||
}
|
||||
|
||||
private static ActionRowProperties Row(params IActionRowComponentProperties[] components)
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var c in components)
|
||||
{
|
||||
row.Add(c);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a list of top-level message components (action rows and
|
||||
/// select menus) into a single <see cref="IReadOnlyList{IMessageComponentProperties}"/>
|
||||
/// for <c>MessageProperties.WithComponents</c>.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<IMessageComponentProperties> Comps(params IMessageComponentProperties[] items) =>
|
||||
items;
|
||||
|
||||
private static void EnsureCustomIdFits(string customId)
|
||||
{
|
||||
if (customId.Length > MaxCustomIdLength)
|
||||
{
|
||||
throw new System.InvalidOperationException(
|
||||
$"Custom id '{customId}' is {customId.Length} chars; Discord limit is {MaxCustomIdLength}.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single-game steps ─────────────────────────────────────────────
|
||||
private static DiscordWizardRender RenderType() => new(
|
||||
"🎲 Создание игровой сессии",
|
||||
"Выберите тип: одна игра или пул.",
|
||||
new IMessageComponentProperties[] { Row(ChoiceBtn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary),
|
||||
ChoiceBtn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderTitle() => new(
|
||||
"📝 Название",
|
||||
"Введите название игры в модальном окне.",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.Title);
|
||||
|
||||
private static DiscordWizardRender RenderDescription() => new(
|
||||
"📄 Описание",
|
||||
"Введите описание (или «-», чтобы пропустить).",
|
||||
new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Description, "-"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.Description);
|
||||
|
||||
private static DiscordWizardRender RenderCover() => new(
|
||||
"🖼 Обложка",
|
||||
"Введите URL картинки (или «-», чтобы пропустить).",
|
||||
new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Cover, "-"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.Cover);
|
||||
|
||||
private static DiscordWizardRender RenderSystem() => new(
|
||||
"🎲 Система",
|
||||
"Выберите систему.",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("D&D 5e", WizardStepNames.System, "Dnd5e"),
|
||||
ChoiceBtn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"),
|
||||
ChoiceBtn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"),
|
||||
ChoiceBtn("GURPS", WizardStepNames.System, "GURPS"),
|
||||
ChoiceBtn("Fate", WizardStepNames.System, "Fate")),
|
||||
Row(ModalTriggerBtn("Другое… ✏️", "SystemFreeText", ButtonStyle.Primary),
|
||||
ChoiceBtn("⏭ Пропустить", WizardStepNames.System, "_skip"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderDuration() => new(
|
||||
"⏱ Длительность",
|
||||
"Выберите длительность (или «Другое…»).",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("3 часа", WizardStepNames.Duration, "180"),
|
||||
ChoiceBtn("4 часа", WizardStepNames.Duration, "240"),
|
||||
ChoiceBtn("5 часов", WizardStepNames.Duration, "300"),
|
||||
ChoiceBtn("6 часов", WizardStepNames.Duration, "360")),
|
||||
Row(ModalTriggerBtn("Другое… ✏️", "DurationFreeText", ButtonStyle.Primary),
|
||||
ChoiceBtn("⏭ Пропустить", WizardStepNames.Duration, "_skip"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderDateTime() => new(
|
||||
"📅 Дата и время",
|
||||
"Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.DateTime);
|
||||
|
||||
private static DiscordWizardRender RenderCapacity() => new(
|
||||
"👥 Лимит мест",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: WizardStepNames.Capacity);
|
||||
|
||||
private static DiscordWizardRender RenderVisibility() => new(
|
||||
"🔒 Видимость",
|
||||
"Выберите, кто увидит сессию.",
|
||||
new IMessageComponentProperties[]
|
||||
{
|
||||
BuildSelectMenu(
|
||||
SelectCustomId(WizardStepNames.Visibility),
|
||||
"Выберите видимость…",
|
||||
new[] { new StringMenuSelectOptionProperties("🌐 Публичная в общем showcase", "public"),
|
||||
new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"),
|
||||
new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"),
|
||||
}),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderPickClub(IReadOnlyList<WizardClubOption> clubs)
|
||||
{
|
||||
if (clubs.Count == 0)
|
||||
{
|
||||
return new DiscordWizardRender(
|
||||
"🏷 Выбор клуба",
|
||||
"У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: null);
|
||||
}
|
||||
var options = new List<StringMenuSelectOptionProperties>(clubs.Count);
|
||||
foreach (var c in clubs.Take(25))
|
||||
{
|
||||
options.Add(new StringMenuSelectOptionProperties(c.Name, c.ClubId.ToString()));
|
||||
}
|
||||
return new DiscordWizardRender(
|
||||
"🏷 Выбор клуба",
|
||||
"Выберите клуб из списка.",
|
||||
new IMessageComponentProperties[]
|
||||
{
|
||||
BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
}
|
||||
|
||||
private static DiscordWizardRender RenderPublish() => new(
|
||||
"✨ Публикация",
|
||||
"Опубликовать в витрине сейчас?",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success),
|
||||
ChoiceBtn("📝 Только в чате", WizardStepNames.Publish, "no")),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderConfirm(WizardPayload p) => new(
|
||||
"👀 Проверьте перед созданием",
|
||||
BuildConfirmDescription(p),
|
||||
new[]
|
||||
{
|
||||
Row(ControlBtn("✅ Создать", "create", ButtonStyle.Success),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
// ── Pool steps ────────────────────────────────────────────────────
|
||||
private static DiscordWizardRender RenderPoolSystemDuration() => new(
|
||||
"🎲 Система и длительность пула",
|
||||
"Выберите пресет или «Другое…».",
|
||||
new IMessageComponentProperties[]
|
||||
{
|
||||
BuildSelectMenu(
|
||||
SelectCustomId(WizardStepNames.PoolSystemDuration),
|
||||
"Выберите пресет…",
|
||||
new[] { new StringMenuSelectOptionProperties("D&D 5e · 4 ч", "Dnd5e:240"),
|
||||
new StringMenuSelectOptionProperties("Pathfinder 2e · 4 ч", "Pathfinder2e:240"),
|
||||
new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"),
|
||||
new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"),
|
||||
}),
|
||||
Row(ModalTriggerBtn("Другое… ✏️", "PoolSystemDurationFreeText", ButtonStyle.Primary),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderPoolAddSlots(WizardPayload p) => new(
|
||||
$"📅 Слоты пула «{p.Title}»",
|
||||
$"Добавлено: {p.Pool?.Slots.Count ?? 0}.",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary),
|
||||
ChoiceBtn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderPoolSlotDateTime() => new(
|
||||
"📅 Дата/время слота",
|
||||
"Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.PoolSlotDateTime);
|
||||
|
||||
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
||||
"👥 Лимит слотов",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: WizardStepNames.PoolSlotCapacity);
|
||||
|
||||
private static DiscordWizardRender RenderPoolConfirm(WizardPayload p) => new(
|
||||
"👀 Проверьте пул перед созданием",
|
||||
BuildPoolConfirmDescription(p),
|
||||
new[]
|
||||
{
|
||||
Row(ControlBtn("✅ Создать пул", "create", ButtonStyle.Success),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
// ── Builders for embed descriptions and selects ───────────────────
|
||||
private static StringMenuProperties BuildSelectMenu(
|
||||
string customId,
|
||||
string placeholder,
|
||||
IReadOnlyList<StringMenuSelectOptionProperties> options)
|
||||
{
|
||||
EnsureCustomIdFits(customId);
|
||||
return new StringMenuProperties(customId, options)
|
||||
{
|
||||
Placeholder = placeholder,
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildConfirmDescription(WizardPayload p)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("🎲 ").AppendLine(p.Title);
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
|
||||
if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
|
||||
if (p.Single?.ScheduledAt is { } at) sb.Append("📅 ").AppendLine(at.FormatMoscow());
|
||||
if (p.Single?.MaxPlayers is { } mp)
|
||||
{
|
||||
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
else if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
sb.Append("👥 Без лимита, waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildPoolConfirmDescription(WizardPayload p)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("📝 ").AppendLine(p.Title);
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
|
||||
if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
|
||||
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||
sb.Append("Слоты (").Append(p.Pool?.Slots.Count ?? 0).AppendLine("):");
|
||||
if (p.Pool is not null)
|
||||
{
|
||||
foreach (var s in p.Pool.Slots)
|
||||
{
|
||||
sb.Append(" • ").Append(s.ScheduledAt.FormatMoscow())
|
||||
.Append(" — мест ").Append(s.MaxPlayers)
|
||||
.Append(", waitlist ").Append(s.Waitlist ? "вкл" : "выкл")
|
||||
.AppendLine();
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
||||
{
|
||||
WizardVisibility.Public => "публичная в общем showcase",
|
||||
WizardVisibility.Club => "публичная в витрине клуба",
|
||||
WizardVisibility.Members => "только для членов клуба",
|
||||
_ => "не задана",
|
||||
};
|
||||
|
||||
// ── Modal builders ────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Build a <see cref="ModalProperties"/> for the given wizard step.
|
||||
/// The wizard step's <c>openModal</c> value drives which modal we
|
||||
/// emit. Returns <c>null</c> if no modal is required for the step.
|
||||
/// </summary>
|
||||
public static ModalProperties? BuildModal(string step, string? draftTitle)
|
||||
{
|
||||
return step switch
|
||||
{
|
||||
WizardStepNames.Title => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Title),
|
||||
"📝 Название игры",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Название",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Title), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "Например: D&D 5e, Проклятие Страда",
|
||||
MinLength = 1,
|
||||
MaxLength = WizardStepLimits.MaxTitleLength,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.Description => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Description),
|
||||
"📄 Описание",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Описание",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Description), TextInputStyle.Paragraph)
|
||||
{
|
||||
Placeholder = "Опишите сценарий / сеттинг. «-» чтобы пропустить.",
|
||||
MaxLength = WizardStepLimits.MaxDescriptionLength,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.Cover => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Cover),
|
||||
"🖼 Обложка (URL)",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"URL картинки",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Cover), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "https://… или «-» чтобы пропустить",
|
||||
MaxLength = 500,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
"SystemFreeText" => new ModalProperties(
|
||||
ModalCustomId("SystemFreeText"),
|
||||
"🎲 Другая система",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Система",
|
||||
new TextInputProperties(ModalCustomId("SystemFreeText"), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "Свободное название системы",
|
||||
MaxLength = WizardStepLimits.MaxSystemLength,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
"DurationFreeText" => new ModalProperties(
|
||||
ModalCustomId("DurationFreeText"),
|
||||
"⏱ Длительность (часы)",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Часы",
|
||||
new TextInputProperties(ModalCustomId("DurationFreeText"), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "1..12",
|
||||
MaxLength = 4,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.DateTime => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.DateTime),
|
||||
"📅 Дата и время",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Когда",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.DateTime), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
|
||||
MaxLength = 32,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.Capacity => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Capacity),
|
||||
"👥 Лимит мест",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Max players",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Capacity), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "1..50",
|
||||
MaxLength = 3,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.PoolSlotDateTime => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.PoolSlotDateTime),
|
||||
"📅 Дата/время слота",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Когда",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotDateTime), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
|
||||
MaxLength = 32,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.PoolSlotCapacity => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.PoolSlotCapacity),
|
||||
"👥 Лимит слотов",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Max players",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotCapacity), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "1..50",
|
||||
MaxLength = 3,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
"PoolSystemDurationFreeText" => new ModalProperties(
|
||||
ModalCustomId("PoolSystemDurationFreeText"),
|
||||
"🎲 Другая система пула",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Система и длительность",
|
||||
new TextInputProperties(ModalCustomId("PoolSystemDurationFreeText"), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "Dnd5e:240 или «Pathfinder 2e:180»",
|
||||
MaxLength = 32,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Finalises a wizard draft by calling the shared
|
||||
/// <see cref="CreateSessionHandler"/>. On success the original draft
|
||||
/// message is overwritten with a "✅ Создано" confirmation; on failure
|
||||
/// the user is offered Retry / Cancel buttons so the same draft can be
|
||||
/// re-submitted without re-entering the wizard from scratch.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardSubmitter
|
||||
{
|
||||
private const int MaxRetries = 3;
|
||||
|
||||
private readonly CreateSessionHandler _shared;
|
||||
private readonly RestClient _rest;
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly ILogger<DiscordWizardSubmitter> _log;
|
||||
|
||||
public DiscordWizardSubmitter(
|
||||
CreateSessionHandler shared,
|
||||
RestClient rest,
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
ILogger<DiscordWizardSubmitter> log)
|
||||
{
|
||||
_shared = shared;
|
||||
_rest = rest;
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submit the draft to the shared handler. On a 1-shot failure we
|
||||
/// edit the draft message to show "retry / cancel" affordances and
|
||||
/// bump the in-payload retry counter; after <see cref="MaxRetries"/>
|
||||
/// consecutive failures the draft is deleted.
|
||||
/// </summary>
|
||||
public async Task SubmitAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
var payload = LoadPayload(draft);
|
||||
if (!IsComplete(payload, out var missing))
|
||||
{
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"❌ Не заполнены поля: {missing}",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var commands = BuildCommands(draft, payload);
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
await _shared.HandleAsync(cmd, ct);
|
||||
}
|
||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Submit failed for draft {DraftId}", draft.Id);
|
||||
payload.RetryCount += 1;
|
||||
SavePayload(draft, payload);
|
||||
if (payload.RetryCount >= MaxRetries)
|
||||
{
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
return;
|
||||
}
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
// The full exception (with stack trace, Postgres constraint
|
||||
// name, sometimes partial SQL) is already logged server-side
|
||||
// on line 86. Show the user a generic message — never leak
|
||||
// internal error strings to the Discord channel.
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"💥 Не удалось создать сессию. Попробуйте ещё раз (попытка {payload.RetryCount}/{MaxRetries}).",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build shared commands ────────────────────────────────────────
|
||||
// Same shape as the Telegram submitter: pool → one command with N
|
||||
// times, single → one command with one time.
|
||||
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
||||
{
|
||||
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||||
{
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||||
MaxPlayersForPool(pool),
|
||||
isOneShot: false),
|
||||
};
|
||||
}
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
PlatformKind.Discord,
|
||||
draft.OwnerId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalUsername: null);
|
||||
var group = new PlatformGroup(
|
||||
PlatformKind.Discord,
|
||||
draft.ChatId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalChannelId: null,
|
||||
ExternalThreadId: draft.MessageThreadId);
|
||||
return new CreateSessionCommand(
|
||||
User: user,
|
||||
Group: group,
|
||||
Title: p.Title ?? string.Empty,
|
||||
Link: string.Empty,
|
||||
ScheduledTimes: scheduledTimes,
|
||||
MaxPlayers: maxPlayers,
|
||||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||
System: ParseSystem(p.System),
|
||||
Description: p.Description,
|
||||
Format: null,
|
||||
DurationMinutes: p.DurationMinutes,
|
||||
IsOneShot: isOneShot);
|
||||
}
|
||||
|
||||
private static GameSystem? ParseSystem(string? code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return null;
|
||||
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
private static bool IsComplete(WizardPayload p, out string missing)
|
||||
{
|
||||
var missingFields = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||||
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
||||
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
||||
if (p.Visibility is null) missingFields.Add("видимость");
|
||||
|
||||
if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
|
||||
}
|
||||
missing = string.Join(", ", missingFields);
|
||||
return missingFields.Count == 0;
|
||||
}
|
||||
|
||||
// ── Payload I/O ──────────────────────────────────────────────────
|
||||
private static WizardPayload LoadPayload(WizardDraft draft)
|
||||
{
|
||||
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
||||
return System.Text.Json.JsonSerializer.Deserialize(
|
||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
}
|
||||
|
||||
private static void SavePayload(WizardDraft draft, WizardPayload p)
|
||||
{
|
||||
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
p, WizardPayloadJsonContext.Default.WizardPayload);
|
||||
}
|
||||
|
||||
// ── Embed editing ────────────────────────────────────────────────
|
||||
private async Task EditDraftMessageAsync(
|
||||
WizardDraft draft, string text, IReadOnlyList<WizardAction> actions, CancellationToken ct)
|
||||
{
|
||||
if (!_contextStore.TryGet(draft.Id, out var ctx))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
|
||||
!ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle("Мастер создания сессии")
|
||||
.WithDescription(Truncate(text, 3900))
|
||||
.WithColor(new Color(0x5865F2));
|
||||
var rows = BuildActionRowsFromActions(actions);
|
||||
await _rest.ModifyMessageAsync(
|
||||
channelId,
|
||||
messageId,
|
||||
options =>
|
||||
{
|
||||
options.Embeds = new[] { embed };
|
||||
options.Components = rows;
|
||||
});
|
||||
}
|
||||
catch (RestException ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Failed to edit wizard message for draft {DraftId}", draft.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IMessageComponentProperties> BuildActionRowsFromActions(
|
||||
IReadOnlyList<WizardAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return Array.Empty<IMessageComponentProperties>();
|
||||
}
|
||||
var rows = new List<IMessageComponentProperties>();
|
||||
foreach (var chunk in actions.Chunk(5))
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var action in chunk)
|
||||
{
|
||||
var style = action.Style switch
|
||||
{
|
||||
WizardActionStyle.Primary => ButtonStyle.Primary,
|
||||
WizardActionStyle.Success => ButtonStyle.Success,
|
||||
WizardActionStyle.Danger => ButtonStyle.Danger,
|
||||
_ => ButtonStyle.Secondary,
|
||||
};
|
||||
row.Add(new ButtonProperties(action.Payload, action.Label, style));
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int max) =>
|
||||
text.Length <= max ? text : text[..max];
|
||||
|
||||
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||||
{
|
||||
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
|
||||
<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" />
|
||||
|
||||
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
|
||||
}
|
||||
|
||||
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
var rows = BuildActionRows(actions);
|
||||
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
|
||||
}
|
||||
|
||||
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
|
||||
var rows = BuildActionRows(actions);
|
||||
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||
{
|
||||
options.Content = htmlText;
|
||||
options.Components = rows;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||
{
|
||||
// Discord thread creation is not implemented in this adapter
|
||||
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
|
||||
}
|
||||
|
||||
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
|
||||
}
|
||||
|
||||
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||
{
|
||||
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
||||
@@ -98,17 +130,34 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(request.Group);
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||
try
|
||||
{
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
request.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
logger?.LogInformation(
|
||||
"Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||
channelId,
|
||||
message.Id);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
request.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
|
||||
channelId,
|
||||
request.SessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||
@@ -135,15 +184,32 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(notification.Group);
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||
try
|
||||
{
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
notification.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
logger?.LogInformation(
|
||||
"Join link sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||
channelId,
|
||||
message.Id);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
notification.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
|
||||
channelId,
|
||||
notification.SessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendDirectSessionNotificationAsync(
|
||||
@@ -272,14 +338,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
? "—"
|
||||
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
||||
|
||||
return new EmbedProperties()
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle($"Ссылка на игру: {notification.Title}")
|
||||
.WithDescription(
|
||||
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||
$"Ссылка: {notification.JoinLink}\n\n" +
|
||||
$"Участники: {mentions}")
|
||||
.WithUrl(notification.JoinLink)
|
||||
.WithColor(new Color(0x57F287));
|
||||
|
||||
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
|
||||
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
||||
@@ -367,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
return ParseSnowflake(channelId);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rows = new List<ActionRowProperties>();
|
||||
foreach (var chunk in actions.Chunk(5))
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var action in chunk)
|
||||
{
|
||||
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
|
||||
{
|
||||
CustomId = action.Payload
|
||||
});
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static ulong ParseSnowflake(string value) =>
|
||||
ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.DiscordBot.Infrastructure;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Infrastructure.Health;
|
||||
@@ -10,6 +11,7 @@ using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
@@ -18,11 +20,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using NetCord;
|
||||
using NetCord.Gateway;
|
||||
using NetCord.Hosting.Gateway;
|
||||
using NetCord.Hosting.Services;
|
||||
using NetCord.Hosting.Services.ApplicationCommands;
|
||||
using NetCord.Hosting.Services.ComponentInteractions;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
|
||||
[module: Dapper.DapperAot]
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
@@ -34,6 +40,8 @@ discordOptions.Validate();
|
||||
|
||||
builder.Services.AddSingleton(discordOptions);
|
||||
|
||||
builder.Logging.AddConsole();
|
||||
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
@@ -52,8 +60,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
|
||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
@@ -76,18 +86,38 @@ builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
|
||||
|
||||
// ── Wizard services (issue #112) ──────────────────────────────────────
|
||||
// The Discord wizard reuses the platform-neutral state machine in
|
||||
// GmRelay.Shared (GameCreationWizard, IWizardMessenger,
|
||||
// IWizardDraftRepository) and only adds a Discord-specific messenger,
|
||||
// step renderer, slash command, and submitter on top. The wizard's
|
||||
// cleanup service is shared with the Telegram bot and is not
|
||||
// registered here — it would compete on the same drafts table.
|
||||
builder.Services.AddSingleton<IWizardDraftRepository, GmRelay.Shared.Features.Sessions.CreateSession.Wizard.WizardDraftRepository>();
|
||||
builder.Services.AddSingleton<IWizardContextStore, DiscordWizardContextStore>();
|
||||
builder.Services.AddSingleton<IWizardMessenger, DiscordWizardMessenger>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||
builder.Services.AddSingleton<DiscordWizardSubmitter>();
|
||||
builder.Services.AddSingleton<WizardInteractionDispatcher>();
|
||||
builder.Services.AddSingleton<DiscordWizardButtonModule>();
|
||||
builder.Services.AddSingleton<DiscordWizardStringMenuModule>();
|
||||
builder.Services.AddSingleton<DiscordWizardModalModule>();
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
{
|
||||
options.Token = discordOptions.Token;
|
||||
options.Intents = GatewayIntents.Guilds;
|
||||
})
|
||||
.AddApplicationCommands()
|
||||
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
|
||||
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
||||
.AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>()
|
||||
.AddComponentInteractions<ModalInteraction, ModalInteractionContext>()
|
||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
|
||||
host.AddModules(typeof(Program).Assembly);
|
||||
|
||||
await host.RunAsync();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace GmRelay.DiscordBot.Rendering;
|
||||
|
||||
public static class DiscordEmbedUrls
|
||||
{
|
||||
public static string? NormalizeHttpUrl(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var candidate = value.Trim();
|
||||
if (IsSupportedHttpUrl(candidate, out var normalized))
|
||||
return normalized;
|
||||
|
||||
if (candidate.Contains("://", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
|
||||
&& HasPublicHost(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUrl(string value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = uri.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasPublicHost(string value) =>
|
||||
Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& uri.Host.Contains('.', StringComparison.Ordinal);
|
||||
}
|
||||
@@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer
|
||||
.WithInline()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
|
||||
if (embedUrl is not null)
|
||||
{
|
||||
embed = embed.WithUrl(session.JoinLink);
|
||||
embed = embed.WithUrl(embedUrl);
|
||||
}
|
||||
|
||||
embed = embed.WithColor(GetColor(session));
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
"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.Hosting": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public enum GameSystem
|
||||
{
|
||||
Dnd5e,
|
||||
Pathfinder2e,
|
||||
CallOfCthulhu7e,
|
||||
Shadowdark,
|
||||
OldSchoolEssentials,
|
||||
Dragonbane,
|
||||
BladesInTheDark,
|
||||
Daggerheart,
|
||||
CyberpunkRed,
|
||||
Mothership,
|
||||
AlienRpg,
|
||||
WarhammerFantasy,
|
||||
VampireMasquerade5e,
|
||||
StarWarsFfg,
|
||||
Genesys,
|
||||
SavageWorlds,
|
||||
GURPS,
|
||||
Fate,
|
||||
DungeonWorld,
|
||||
Ironsworn,
|
||||
Other
|
||||
}
|
||||
|
||||
public static class GameSystemExtensions
|
||||
{
|
||||
private static readonly FrozenDictionary<GameSystem, string> DisplayNames =
|
||||
new Dictionary<GameSystem, string>
|
||||
{
|
||||
[GameSystem.Dnd5e] = "D&D 5e",
|
||||
[GameSystem.Pathfinder2e] = "Pathfinder 2e",
|
||||
[GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e",
|
||||
[GameSystem.Shadowdark] = "Shadowdark",
|
||||
[GameSystem.OldSchoolEssentials] = "Old School Essentials",
|
||||
[GameSystem.Dragonbane] = "Dragonbane",
|
||||
[GameSystem.BladesInTheDark] = "Blades in the Dark",
|
||||
[GameSystem.Daggerheart] = "Daggerheart",
|
||||
[GameSystem.CyberpunkRed] = "Cyberpunk RED",
|
||||
[GameSystem.Mothership] = "Mothership",
|
||||
[GameSystem.AlienRpg] = "Alien RPG",
|
||||
[GameSystem.WarhammerFantasy] = "Warhammer Fantasy",
|
||||
[GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e",
|
||||
[GameSystem.StarWarsFfg] = "Star Wars (FFG)",
|
||||
[GameSystem.Genesys] = "Genesys",
|
||||
[GameSystem.SavageWorlds] = "Savage Worlds",
|
||||
[GameSystem.GURPS] = "GURPS",
|
||||
[GameSystem.Fate] = "Fate",
|
||||
[GameSystem.DungeonWorld] = "Dungeon World",
|
||||
[GameSystem.Ironsworn] = "Ironsworn",
|
||||
[GameSystem.Other] = "Другое"
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
public static string ToDisplayName(this GameSystem system) =>
|
||||
DisplayNames.TryGetValue(system, out var name) ? name : "Другое";
|
||||
|
||||
public static GameSystem? TryParseFuzzy(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return null;
|
||||
|
||||
var normalized = input.Trim().ToLowerInvariant();
|
||||
|
||||
if (Enum.TryParse<GameSystem>(normalized, true, out var exact))
|
||||
return exact;
|
||||
|
||||
foreach (var value in Enum.GetValues<GameSystem>())
|
||||
{
|
||||
if (value == GameSystem.Other)
|
||||
continue;
|
||||
|
||||
var display = value.ToDisplayName().ToLowerInvariant();
|
||||
if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
|
||||
return value;
|
||||
}
|
||||
|
||||
return GameSystem.Other;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public enum PublicationMode
|
||||
{
|
||||
None,
|
||||
Catalog,
|
||||
ClubOnly,
|
||||
Both
|
||||
}
|
||||
|
||||
public static class PublicationModeExtensions
|
||||
{
|
||||
public const string NoneValue = nameof(PublicationMode.None);
|
||||
public const string CatalogValue = nameof(PublicationMode.Catalog);
|
||||
public const string ClubOnlyValue = nameof(PublicationMode.ClubOnly);
|
||||
public const string BothValue = nameof(PublicationMode.Both);
|
||||
|
||||
public static bool IsVisibleInCatalog(this PublicationMode mode) =>
|
||||
mode is PublicationMode.Catalog or PublicationMode.Both;
|
||||
|
||||
public static bool IsVisibleToClubMembers(this PublicationMode mode) =>
|
||||
mode is PublicationMode.ClubOnly or PublicationMode.Both;
|
||||
|
||||
public static string ToDatabaseValue(this PublicationMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
PublicationMode.None => NoneValue,
|
||||
PublicationMode.Catalog => CatalogValue,
|
||||
PublicationMode.ClubOnly => ClubOnlyValue,
|
||||
PublicationMode.Both => BothValue,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown publication mode.")
|
||||
};
|
||||
|
||||
public static PublicationMode FromDatabaseValue(string? value) =>
|
||||
value switch
|
||||
{
|
||||
null or "" => PublicationMode.None,
|
||||
NoneValue => PublicationMode.None,
|
||||
CatalogValue => PublicationMode.Catalog,
|
||||
ClubOnlyValue => PublicationMode.ClubOnly,
|
||||
BothValue => PublicationMode.Both,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown publication mode.")
|
||||
};
|
||||
}
|
||||
@@ -56,8 +56,8 @@ public sealed class HandleRsvpHandler(
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND COALESCE(p.platform, 'Telegram') = @Platform
|
||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
)
|
||||
@@ -90,8 +90,8 @@ public sealed class HandleRsvpHandler(
|
||||
AND player_id = (
|
||||
SELECT id
|
||||
FROM players
|
||||
WHERE COALESCE(platform, 'Telegram') = @Platform
|
||||
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
|
||||
WHERE platform = @Platform
|
||||
AND external_user_id = @ExternalUserId
|
||||
LIMIT 1
|
||||
)
|
||||
AND registration_status = @Active
|
||||
@@ -265,10 +265,10 @@ public sealed class HandleRsvpHandler(
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
@@ -312,23 +312,13 @@ public sealed class HandleRsvpHandler(
|
||||
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
p.external_username AS ExternalUsername
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
FROM game_groups g
|
||||
JOIN players p ON p.telegram_id = g.gm_telegram_id
|
||||
WHERE g.id = @GroupId
|
||||
AND g.gm_telegram_id IS NOT NULL
|
||||
""",
|
||||
new { GroupId = groupId },
|
||||
transaction);
|
||||
|
||||
+6
-6
@@ -45,10 +45,10 @@ public sealed class SendConfirmationHandler(
|
||||
s.title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
||||
g.platform AS Platform,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
g.name AS DisplayName,
|
||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
||||
g.external_channel_id AS ExternalChannelId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
@@ -65,10 +65,10 @@ public sealed class SendConfirmationHandler(
|
||||
|
||||
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
|
||||
@@ -47,10 +47,10 @@ public sealed class SendJoinLinkHandler(
|
||||
s.title,
|
||||
s.join_link AS JoinLink,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
||||
g.platform AS Platform,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
g.name AS DisplayName,
|
||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
||||
g.external_channel_id AS ExternalChannelId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
@@ -58,14 +58,14 @@ public sealed class SendJoinLinkHandler(
|
||||
WHERE s.id = @SessionId
|
||||
AND s.status = @Confirmed
|
||||
AND (
|
||||
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
|
||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||
OR (
|
||||
COALESCE(g.platform, 'Telegram') <> 'Telegram'
|
||||
g.platform <> 'Telegram'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_messages pm
|
||||
WHERE pm.session_id = s.id
|
||||
AND pm.platform = COALESCE(g.platform, 'Telegram')
|
||||
AND pm.platform = g.platform
|
||||
AND pm.purpose = 'join_link'
|
||||
)
|
||||
)
|
||||
@@ -81,10 +81,10 @@ public sealed class SendJoinLinkHandler(
|
||||
|
||||
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
|
||||
+3
-3
@@ -56,10 +56,10 @@ public sealed class SendOneHourReminderHandler(
|
||||
|
||||
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
p.external_username AS ExternalUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record CreateSessionCommand(
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string Title,
|
||||
string Link,
|
||||
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||
int? MaxPlayers,
|
||||
string? ImageReference,
|
||||
GameSystem? System = null,
|
||||
string? Description = null,
|
||||
string? Format = null,
|
||||
int? DurationMinutes = null,
|
||||
bool IsOneShot = false);
|
||||
@@ -0,0 +1,168 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var transactionCommitted = false;
|
||||
try
|
||||
{
|
||||
var platform = command.User.Platform.ToString();
|
||||
var externalUserId = command.User.ExternalUserId;
|
||||
var displayName = command.User.DisplayName;
|
||||
var externalUsername = command.User.ExternalUsername;
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@Name, @Platform, @ExternalId, @Username)
|
||||
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 { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform },
|
||||
transaction);
|
||||
|
||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = g.id
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalGmId
|
||||
) AS CanManage
|
||||
FROM game_groups g
|
||||
WHERE g.platform = @Platform
|
||||
AND g.external_group_id = @ExternalGroupId
|
||||
""",
|
||||
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
|
||||
transaction);
|
||||
|
||||
Guid groupId;
|
||||
if (existingGroup is null)
|
||||
{
|
||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
{
|
||||
Platform = platform,
|
||||
ExternalGroupId = command.Group.ExternalGroupId,
|
||||
ExternalChannelId = command.Group.ExternalChannelId,
|
||||
ChatName = command.Group.DisplayName
|
||||
},
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalGmId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!existingGroup.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
return new CreateSessionResult(
|
||||
false,
|
||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
|
||||
groupId = existingGroup.GroupId;
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE game_groups
|
||||
SET name = @ChatName
|
||||
WHERE id = @GroupId
|
||||
""",
|
||||
new { ChatName = command.Group.DisplayName, GroupId = groupId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessions = new List<SessionBatchDto>();
|
||||
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
|
||||
|
||||
foreach (var scheduledAt in orderedTimes)
|
||||
{
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
{
|
||||
BatchId = batchId,
|
||||
GroupId = groupId,
|
||||
command.Title,
|
||||
Link = command.Link,
|
||||
ScheduledAt = scheduledAt,
|
||||
Status = SessionStatus.Planned,
|
||||
MaxPlayers = command.MaxPlayers,
|
||||
System = command.System?.ToString(),
|
||||
command.Description,
|
||||
command.Format,
|
||||
DurationMinutes = command.DurationMinutes,
|
||||
IsOneShot = command.IsOneShot,
|
||||
CoverImageUrl = command.ImageReference
|
||||
},
|
||||
transaction);
|
||||
|
||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
transactionCommitted = true;
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
|
||||
return new CreateSessionResult(
|
||||
true,
|
||||
null,
|
||||
view,
|
||||
batchId,
|
||||
groupId,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using GmRelay.Shared.Rendering;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record CreateSessionResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
SessionBatchViewModel? View,
|
||||
Guid? BatchId,
|
||||
Guid? GroupId,
|
||||
IReadOnlyList<string> Warnings);
|
||||
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
PlatformMessageRef ScheduleMessage,
|
||||
bool DeferScheduleUpdate = false);
|
||||
|
||||
public sealed record SessionInteractionResult(
|
||||
string ReplyText,
|
||||
SessionBatchViewModel? UpdatedView = null);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
||||
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<JoinSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
@@ -35,30 +40,19 @@ public sealed class JoinSessionHandler(
|
||||
{
|
||||
// 1. Убеждаемся, что игрок есть в базе
|
||||
var platform = command.User.Platform.ToString();
|
||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
||||
: (long?)null;
|
||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
||||
? command.User.ExternalUsername
|
||||
: null;
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
||||
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@Name, @Platform, @ExternalUserId, @ExternalUsername)
|
||||
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,
|
||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
||||
platform = EXCLUDED.platform,
|
||||
external_user_id = EXCLUDED.external_user_id,
|
||||
external_username = EXCLUDED.external_username
|
||||
RETURNING id;",
|
||||
new
|
||||
{
|
||||
LegacyTelegramId = legacyTelegramId,
|
||||
Name = command.User.DisplayName,
|
||||
LegacyTelegramUsername = legacyTelegramUsername,
|
||||
Platform = platform,
|
||||
command.User.ExternalUserId,
|
||||
command.User.ExternalUsername
|
||||
@@ -77,15 +71,13 @@ public sealed class JoinSessionHandler(
|
||||
if (batchInfo is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(batchInfo.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
}
|
||||
|
||||
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
||||
@@ -105,8 +97,7 @@ public sealed class JoinSessionHandler(
|
||||
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Вы уже в листе ожидания!"
|
||||
: "Вы уже записаны!";
|
||||
await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||
}
|
||||
|
||||
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
||||
@@ -139,8 +130,7 @@ public sealed class JoinSessionHandler(
|
||||
if (inserted == 0)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||
}
|
||||
|
||||
// Загружаем весь батч для перерисовки
|
||||
@@ -154,7 +144,7 @@ public sealed class JoinSessionHandler(
|
||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id as SessionId,
|
||||
p.display_name as DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
||||
p.external_username as TelegramUsername,
|
||||
sp.registration_status as RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -168,17 +158,20 @@ public sealed class JoinSessionHandler(
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
if (!command.DeferScheduleUpdate)
|
||||
{
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
}
|
||||
|
||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||
: "Вы успешно записаны!";
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -191,10 +184,17 @@ public sealed class JoinSessionHandler(
|
||||
var errorText = transactionCommitted
|
||||
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при регистрации.";
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
private async Task<SessionInteractionResult> AnswerAsync(
|
||||
string interactionId,
|
||||
string text,
|
||||
CancellationToken ct,
|
||||
SessionBatchViewModel? updatedView = null)
|
||||
{
|
||||
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
return new SessionInteractionResult(text, updatedView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
PlatformMessageRef ScheduleMessage,
|
||||
bool DeferScheduleUpdate = false);
|
||||
|
||||
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||||
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||||
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<LeaveSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
|
||||
if (session is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
}
|
||||
|
||||
var platform = command.User.Platform.ToString();
|
||||
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
|
||||
if (participant is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@@ -175,7 +173,7 @@ public sealed class LeaveSessionHandler(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
|
||||
transactionCommitted = true;
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
if (!command.DeferScheduleUpdate)
|
||||
{
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
}
|
||||
|
||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Вы удалены из листа ожидания."
|
||||
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
|
||||
? "Вы отписались от сессии."
|
||||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
|
||||
var errorText = transactionCommitted
|
||||
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при отмене записи.";
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
private async Task<SessionInteractionResult> AnswerAsync(
|
||||
string interactionId,
|
||||
string text,
|
||||
CancellationToken ct,
|
||||
SessionBatchViewModel? updatedView = null)
|
||||
{
|
||||
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
return new SessionInteractionResult(text, updatedView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Central state machine for the game/pool creation wizard. Lives in
|
||||
/// <c>GmRelay.Shared</c> so it can be driven from any platform
|
||||
/// messenger. Platform-specific code (<c>Telegram.Bot</c>,
|
||||
/// <c>NetCord</c>, …) lives in the corresponding adapter and converts
|
||||
/// its native update type into a <see cref="WizardInteraction"/> before
|
||||
/// calling <see cref="HandleInteractionAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class GameCreationWizard
|
||||
{
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardMessenger _messenger;
|
||||
private readonly ILogger<GameCreationWizard> _log;
|
||||
|
||||
public GameCreationWizard(
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardMessenger messenger,
|
||||
ILogger<GameCreationWizard> log)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_messenger = messenger;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a single user interaction with the wizard. Adapters should
|
||||
/// map their native event (Telegram <c>Update</c>, Discord
|
||||
/// interaction, …) into a <see cref="WizardInteraction"/> first.
|
||||
/// </summary>
|
||||
public async Task HandleInteractionAsync(
|
||||
WizardInteraction interaction,
|
||||
WizardDraft draft,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (interaction.CallbackPayload is not null)
|
||||
{
|
||||
await HandleCallbackAsync(draft, interaction, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleTextAsync(draft, interaction, ct);
|
||||
}
|
||||
}
|
||||
catch (WizardStorageException)
|
||||
{
|
||||
if (interaction.CallbackPayload is not null)
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(
|
||||
interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id);
|
||||
if (interaction.CallbackPayload is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(
|
||||
interaction.InteractionId, "⚠️ Ошибка", ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* swallow — we're already in error path */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCallbackAsync(
|
||||
WizardDraft draft,
|
||||
WizardInteraction interaction,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice))
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "cancel":
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft, "❌ Мастер отменён.", Array.Empty<WizardAction>(), ct);
|
||||
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||
return;
|
||||
|
||||
case "back":
|
||||
ApplyBack(draft, step);
|
||||
await PersistAndRenderAsync(draft, interaction.InteractionId, ct);
|
||||
return;
|
||||
|
||||
case "create":
|
||||
// Routed by the platform's CreateSessionHandler, not here.
|
||||
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||
return;
|
||||
|
||||
default:
|
||||
// For "Choice" callbacks, action == step.
|
||||
await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTextAsync(
|
||||
WizardDraft draft,
|
||||
WizardInteraction interaction,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (interaction.Text is not { } text)
|
||||
{
|
||||
// Photo or other non-text — handle cover step only.
|
||||
if (interaction.PhotoFileId is { } fileId &&
|
||||
draft.Step == WizardStepNames.Cover)
|
||||
{
|
||||
ApplyCoverPhoto(draft, fileId);
|
||||
await PersistAndRenderAsync(draft, null, ct);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var (nextStep, error, payload) = ApplyText(draft, text);
|
||||
if (payload is { } p) SavePayload(draft, p);
|
||||
if (error is { } errMsg)
|
||||
{
|
||||
// Re-render the same step with ⚠️ prefix.
|
||||
var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft));
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStep is { } step)
|
||||
{
|
||||
draft.Step = step;
|
||||
}
|
||||
await PersistAndRenderAsync(draft, null, ct);
|
||||
}
|
||||
|
||||
private async Task ApplyChoiceAsync(
|
||||
WizardDraft draft, string step, string choice, string interactionId, CancellationToken ct)
|
||||
{
|
||||
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
|
||||
if (error is { } err)
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(interactionId, err, ct);
|
||||
return;
|
||||
}
|
||||
if (payload is { } p) SavePayload(draft, p);
|
||||
if (nextStep is { } s)
|
||||
{
|
||||
draft.Step = s;
|
||||
}
|
||||
await PersistAndRenderAsync(draft, interactionId, ct);
|
||||
}
|
||||
|
||||
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
|
||||
{
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
var payload = LoadPayload(draft);
|
||||
IReadOnlyList<WizardClubOption>? clubs = null;
|
||||
if (draft.Step == WizardStepNames.PickClub)
|
||||
{
|
||||
clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct);
|
||||
}
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
|
||||
if (interactionId is { } id)
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(id, null, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text input dispatcher ─────────────────────────────────────────
|
||||
private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input)
|
||||
{
|
||||
var payload = LoadPayload(draft);
|
||||
switch (draft.Step)
|
||||
{
|
||||
case WizardStepNames.Title:
|
||||
return ValidateText(input, WizardStepLimits.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
|
||||
? (WizardStepNames.Description, SetTitle(payload, title), payload)
|
||||
: (null, title, payload);
|
||||
|
||||
case WizardStepNames.Description:
|
||||
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
|
||||
return ValidateText(input, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
|
||||
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
|
||||
: (null, desc, payload);
|
||||
|
||||
case WizardStepNames.Cover:
|
||||
if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload);
|
||||
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
return (NextAfterCover(payload), SetImageUrl(payload, input), payload);
|
||||
return (null, "Некорректный URL", payload);
|
||||
|
||||
case WizardStepNames.System when payload.System is null:
|
||||
// "Other" branch — only active if free-text was offered.
|
||||
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
|
||||
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
|
||||
: (null, sys, payload);
|
||||
|
||||
case WizardStepNames.Duration when payload.DurationMinutes is null:
|
||||
return TryParseHours(input, out var durMin)
|
||||
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload)
|
||||
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||
|
||||
case WizardStepNames.DateTime:
|
||||
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
|
||||
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
|
||||
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||
|
||||
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
||||
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
|
||||
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
|
||||
: (null, "Лимит должен быть 1..50", payload);
|
||||
|
||||
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
||||
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
||||
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
||||
: (null, psys, payload);
|
||||
|
||||
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
||||
return TryParseHours(input, out var pdur)
|
||||
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
|
||||
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||
|
||||
case WizardStepNames.PoolSlotDateTime:
|
||||
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
|
||||
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload)
|
||||
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||
|
||||
case WizardStepNames.PoolSlotCapacity:
|
||||
return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity
|
||||
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
|
||||
: (null, "Лимит должен быть 1..50", payload);
|
||||
|
||||
default:
|
||||
return (null, "Ожидается выбор кнопкой", payload);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Callback (button) dispatcher ──────────────────────────────────
|
||||
private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice)
|
||||
{
|
||||
var payload = LoadPayload(draft);
|
||||
var (next, err) = step switch
|
||||
{
|
||||
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
|
||||
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
||||
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
|
||||
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
|
||||
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
|
||||
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
|
||||
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
|
||||
WizardStepNames.PoolSystemDuration => ApplyPoolSystemDurationChoice(payload, choice),
|
||||
WizardStepNames.PoolAddSlots => ApplyPoolAddSlotsChoice(payload, choice),
|
||||
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
|
||||
_ => (null, "Неизвестный шаг"),
|
||||
};
|
||||
return (next, err, payload);
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"single" => (WizardStepNames.Title, SetType(p, WizardCreationType.Single)),
|
||||
"pool" => (WizardStepNames.Title, SetType(p, WizardCreationType.Pool)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplySystemChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"_other" => (WizardStepNames.System, null), // stay, await text
|
||||
"_skip" => (NextAfterSystem(p), SetSystem(p, null)),
|
||||
{ } code => (WizardStepNames.Duration, SetSystem(p, code)),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyDurationChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"_other" => (WizardStepNames.Duration, null),
|
||||
"_skip" => (NextAfterDuration(p), SetDurationMinutes(p, null)),
|
||||
{ } d => int.TryParse(d, out var min)
|
||||
? (NextAfterDuration(p), SetDurationMinutes(p, min))
|
||||
: (null, "Неверная длительность"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
|
||||
{
|
||||
if (choice is "no_limit")
|
||||
{
|
||||
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
|
||||
}
|
||||
|
||||
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
|
||||
{
|
||||
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||
}
|
||||
|
||||
return choice switch
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
|
||||
"club" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Club)),
|
||||
"members" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Members)),
|
||||
"pickclub" => (WizardStepNames.PickClub, null),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
|
||||
{
|
||||
if (!Guid.TryParse(choice, out var id))
|
||||
{
|
||||
return (null, "Неверный идентификатор клуба");
|
||||
}
|
||||
|
||||
var error = SetClubId(p, id);
|
||||
return (NextAfterVisibility(p), error);
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"yes" => (WizardStepNames.Confirm, SetPublishInShowcase(p, true)),
|
||||
"no" => (WizardStepNames.Confirm, SetPublishInShowcase(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyPoolSystemDurationChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
||||
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
|
||||
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
|
||||
: (null, "Неверный выбор"),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"add" => BeginNewPoolSlot(p),
|
||||
"done" => p.Pool?.Slots.Count > 0
|
||||
? (WizardStepNames.PoolConfirm, null)
|
||||
: (null, "Добавьте хотя бы один слот"),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyPoolSlotCapacityChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
// ── Back navigation ───────────────────────────────────────────────
|
||||
private static void ApplyBack(WizardDraft draft, string fromStep)
|
||||
{
|
||||
// The callback's "step" portion is the step the user is currently on (e.g. the
|
||||
// Confirm button emits `wizard:back` with no step, in which case we fall back to
|
||||
// the draft's current step). Both should produce the same result.
|
||||
var current = string.IsNullOrEmpty(fromStep) ? draft.Step : fromStep;
|
||||
var payload = LoadPayload(draft);
|
||||
var previous = PreviousStep(current, payload);
|
||||
if (previous is { } step) draft.Step = step;
|
||||
}
|
||||
|
||||
private static string? PreviousStep(string step, WizardPayload p) => step switch
|
||||
{
|
||||
WizardStepNames.Title => null, // first step
|
||||
WizardStepNames.Description => WizardStepNames.Title,
|
||||
WizardStepNames.Cover => WizardStepNames.Description,
|
||||
WizardStepNames.System => WizardStepNames.Cover,
|
||||
WizardStepNames.Duration => WizardStepNames.System,
|
||||
WizardStepNames.DateTime => WizardStepNames.Duration,
|
||||
WizardStepNames.Capacity => WizardStepNames.DateTime,
|
||||
WizardStepNames.Visibility => WizardStepNames.Capacity,
|
||||
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
||||
WizardStepNames.Publish => WizardStepNames.PickClub,
|
||||
WizardStepNames.Confirm => WizardStepNames.Publish,
|
||||
|
||||
WizardStepNames.PoolSystemDuration => null, // first pool step
|
||||
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
|
||||
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
|
||||
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
|
||||
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// ── Payload I/O ───────────────────────────────────────────────────
|
||||
public static WizardPayload LoadPayload(WizardDraft draft)
|
||||
{
|
||||
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
||||
return System.Text.Json.JsonSerializer.Deserialize(
|
||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
}
|
||||
|
||||
private static void SavePayload(WizardDraft draft, WizardPayload payload)
|
||||
{
|
||||
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
payload, WizardPayloadJsonContext.Default.WizardPayload);
|
||||
}
|
||||
|
||||
// Mutators — return the error message if any (kept here to centralise flow).
|
||||
private static string? SetTitle(WizardPayload p, string v) { p.Title = v; return null; }
|
||||
private static string? SetDescription(WizardPayload p, string? v) { p.Description = v; return null; }
|
||||
private static string? SetImageUrl(WizardPayload p, string? v) { p.ImageUrl = v; p.ImageFileId = null; return null; }
|
||||
private static void ApplyCoverPhoto(WizardDraft d, string fileId)
|
||||
{
|
||||
var p = LoadPayload(d);
|
||||
p.ImageFileId = fileId;
|
||||
p.ImageUrl = null;
|
||||
SavePayload(d, p);
|
||||
var next = NextAfterCover(p);
|
||||
if (next is { } s) d.Step = s;
|
||||
}
|
||||
private static string? SetSystem(WizardPayload p, string? v) { p.System = v; return null; }
|
||||
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
||||
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
||||
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
||||
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
|
||||
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
||||
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
|
||||
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
|
||||
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
|
||||
|
||||
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
|
||||
{
|
||||
p.Pool ??= new WizardPoolInput();
|
||||
var current = EnsureCurrentPoolSlot(p);
|
||||
current.ScheduledAt = v;
|
||||
return null;
|
||||
}
|
||||
private static string? SetCurrentSlotMaxPlayers(WizardPayload p, int v)
|
||||
{
|
||||
p.Pool ??= new WizardPoolInput();
|
||||
var current = EnsureCurrentPoolSlot(p);
|
||||
current.MaxPlayers = v;
|
||||
return null;
|
||||
}
|
||||
private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist)
|
||||
{
|
||||
p.Pool ??= new WizardPoolInput();
|
||||
var current = EnsureCurrentPoolSlot(p);
|
||||
current.Waitlist = waitlist;
|
||||
return null;
|
||||
}
|
||||
private static (string? nextStep, string? error) BeginNewPoolSlot(WizardPayload p)
|
||||
{
|
||||
p.Pool ??= new WizardPoolInput();
|
||||
p.Pool.Slots.Add(new WizardSlotInput());
|
||||
return (WizardStepNames.PoolSlotDateTime, null);
|
||||
}
|
||||
private static WizardSlotInput EnsureCurrentPoolSlot(WizardPayload p)
|
||||
{
|
||||
// Slots added via BeginNewPoolSlot are always committed before they leave the
|
||||
// PoolSlotCapacity step (CommitCurrentPoolSlot). If we somehow get here without
|
||||
// a slot, start a new one to keep the flow recoverable.
|
||||
p.Pool ??= new WizardPoolInput();
|
||||
var last = p.Pool.Slots.LastOrDefault();
|
||||
if (last is not null && last.MaxPlayers == 0) return last;
|
||||
p.Pool.Slots.Add(new WizardSlotInput());
|
||||
return p.Pool.Slots[^1];
|
||||
}
|
||||
|
||||
// ── Flow helpers ──────────────────────────────────────────────────
|
||||
private static string? NextAfterCover(WizardPayload p) => p.Type == WizardCreationType.Pool
|
||||
? WizardStepNames.PoolSystemDuration : WizardStepNames.System;
|
||||
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
|
||||
private static string? NextAfterDuration(WizardPayload p)
|
||||
{
|
||||
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
|
||||
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
|
||||
}
|
||||
private static string? NextAfterVisibility(WizardPayload p)
|
||||
{
|
||||
if (p.Visibility is WizardVisibility.Club or WizardVisibility.Members)
|
||||
{
|
||||
if (p.ClubId is null) return WizardStepNames.PickClub;
|
||||
}
|
||||
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
|
||||
}
|
||||
|
||||
private static (string? sys, int? dur) SplitSystemDuration(string s)
|
||||
{
|
||||
var idx = s.IndexOf(':');
|
||||
if (idx <= 0 || idx >= s.Length - 1) return (null, null);
|
||||
var sys = s.Substring(0, idx);
|
||||
if (!int.TryParse(s.Substring(idx + 1), out var durMin)) return (null, null);
|
||||
return (sys, durMin);
|
||||
}
|
||||
|
||||
private static bool ValidateText(
|
||||
string input, int maxLength, string emptyMsg, string tooLongMsg, out string trimmed)
|
||||
{
|
||||
trimmed = input.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
trimmed = emptyMsg;
|
||||
return false;
|
||||
}
|
||||
if (trimmed.Length > maxLength)
|
||||
{
|
||||
trimmed = tooLongMsg;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseHours(string input, out int minutes)
|
||||
{
|
||||
minutes = 0;
|
||||
var s = input.Trim();
|
||||
if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
|
||||
if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
|
||||
if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false;
|
||||
if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false;
|
||||
minutes = (int)Math.Round(hours * 60);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Visual style for a wizard button. The platform adapter maps this to its
|
||||
/// own native styling (Telegram currently ignores it; Discord uses it for
|
||||
/// primary/danger/success button colors).
|
||||
/// </summary>
|
||||
public enum WizardActionStyle
|
||||
{
|
||||
Primary,
|
||||
Secondary,
|
||||
Success,
|
||||
Danger,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single button on a wizard keyboard. <see cref="Payload"/> is the
|
||||
/// platform-neutral callback token — usually produced by
|
||||
/// <see cref="WizardCallbackData"/> but adapters are free to interpret
|
||||
/// any string.
|
||||
/// </summary>
|
||||
public sealed record WizardAction(
|
||||
string Label,
|
||||
string Payload,
|
||||
WizardActionStyle Style = WizardActionStyle.Secondary);
|
||||
|
||||
/// <summary>
|
||||
/// One row of buttons on a wizard keyboard. The platform adapter is
|
||||
/// responsible for laying out rows; the wizard core returns a flat list
|
||||
/// of actions and trusts the adapter to split them into rows.
|
||||
/// </summary>
|
||||
public sealed record WizardKeyboard(IReadOnlyList<WizardAction> Actions);
|
||||
|
||||
/// <summary>
|
||||
/// A user-owned group/club selectable from the visibility step. Moved
|
||||
/// from <c>GmRelay.Bot</c> so the wizard can ask for the list without
|
||||
/// taking a dependency on Telegram.
|
||||
/// </summary>
|
||||
public sealed record WizardClubOption(Guid ClubId, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Platform-neutral user interaction with the wizard. Adapters convert
|
||||
/// their native event (Telegram <c>Update</c>, Discord interaction, …)
|
||||
/// into one of these before handing it to <see cref="GameCreationWizard"/>.
|
||||
/// </summary>
|
||||
public sealed record WizardInteraction(
|
||||
string OwnerId,
|
||||
string? Text,
|
||||
string? CallbackPayload,
|
||||
string? PhotoFileId,
|
||||
string? PhotoUrl,
|
||||
string InteractionId);
|
||||
|
||||
/// <summary>
|
||||
/// Storage contract for wizard drafts. Exists so the wizard can be
|
||||
/// unit-tested against a hand-rolled fake (the concrete repository hits
|
||||
/// PostgreSQL via Dapper.AOT and is therefore unsuitable for fast
|
||||
/// in-process tests).
|
||||
/// </summary>
|
||||
public interface IWizardDraftRepository
|
||||
{
|
||||
Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct);
|
||||
|
||||
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken ct);
|
||||
|
||||
Task<int> DeleteExpiredAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contract the wizard core uses to talk to the chat platform. Each
|
||||
/// platform supplies its own implementation (Telegram today, Discord in
|
||||
/// a follow-up task).
|
||||
/// </summary>
|
||||
public interface IWizardMessenger
|
||||
{
|
||||
/// <summary>
|
||||
/// Edit the message that currently represents the wizard draft.
|
||||
/// Returns the new message id as a string — Telegram exposes
|
||||
/// <c>int32</c>, Discord uses 64-bit snowflakes, both fit in
|
||||
/// <see cref="string"/> for cross-platform uniformity.
|
||||
/// </summary>
|
||||
Task<string> EditDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Post a fresh wizard draft message and return its id.
|
||||
/// </summary>
|
||||
Task<string> SendDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a callback / interaction. <paramref name="text"/>
|
||||
/// is an optional toast the user sees briefly.
|
||||
/// </summary>
|
||||
Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// List the clubs/groups the owner manages. The platform
|
||||
/// implementation decides how to query the database — the wizard
|
||||
/// core only needs a list of (id, name) pairs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format for wizard callback data. The format is shared by all
|
||||
/// platforms (Telegram today, Discord in a follow-up task) and must
|
||||
/// stay stable because it is persisted in chat histories and slash-command
|
||||
/// autocomplete. Token is <c>wizard</c> to keep the namespace separate
|
||||
/// from the rest of the bot's command callbacks.
|
||||
/// </summary>
|
||||
public static class WizardCallbackData
|
||||
{
|
||||
public const string Prefix = "wizard";
|
||||
|
||||
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
|
||||
|
||||
public static string Back() => $"{Prefix}:back";
|
||||
|
||||
public static string Cancel() => $"{Prefix}:cancel";
|
||||
|
||||
public static string Create() => $"{Prefix}:create";
|
||||
|
||||
public static bool TryParse(string? data, out string action, out string step, out string choice)
|
||||
{
|
||||
action = step = choice = string.Empty;
|
||||
if (string.IsNullOrEmpty(data)) return false;
|
||||
var parts = data.Split(':', 3);
|
||||
if (parts.Length < 2 || parts[0] != Prefix) return false;
|
||||
action = parts[1];
|
||||
step = parts.Length >= 3 ? parts[1] : string.Empty;
|
||||
choice = parts.Length >= 3 ? parts[2] : string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
public sealed class WizardDraft
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable string id of the chat/guild/channel this draft lives in.
|
||||
/// Stored as <c>TEXT</c> to fit both Telegram's <c>long</c> chat ids
|
||||
/// and Discord's snowflakes.
|
||||
/// </summary>
|
||||
public string ChatId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional thread/topic id within the chat. Telegram's
|
||||
/// <c>message_thread_id</c>, Discord's thread snowflake, <c>null</c>
|
||||
/// when the chat has no sub-thread concept.
|
||||
/// </summary>
|
||||
public string? MessageThreadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-specific user id of the wizard owner. Telegram uses
|
||||
/// <c>long</c>, Discord uses snowflakes — both fit in a string.
|
||||
/// </summary>
|
||||
public string OwnerId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Which messenger platform owns this draft. Defaults to
|
||||
/// <c>"Telegram"</c> for backward compatibility with pre-V032 rows.
|
||||
/// </summary>
|
||||
public string Platform { get; set; } = "Telegram";
|
||||
|
||||
public string Step { get; set; } = string.Empty;
|
||||
|
||||
public string PayloadJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Id of the message that the wizard last edited. Stored as
|
||||
/// <c>TEXT</c> to fit both Telegram's <c>int32</c> ids and Discord's
|
||||
/// 64-bit snowflakes.
|
||||
/// </summary>
|
||||
public string? DraftMessageId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
||||
{
|
||||
// NOTE: NativeAOT — Dapper.AOT 1.0.48 only generates interceptors for the
|
||||
// (sql, param) extension overloads, NOT for the (CommandDefinition) overload.
|
||||
// Passing a CommandDefinition here would skip the interceptor and fall back to
|
||||
// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
|
||||
// throws PlatformNotSupportedException on AOT (issue: wizard silently dropped
|
||||
// every Telegram update in v3.9.0). All four methods therefore use the plain
|
||||
// (sql, param) overload, matching the pattern in JoinSessionHandler.
|
||||
public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id AS Id,
|
||||
chat_id AS ChatId,
|
||||
message_thread_id AS MessageThreadId,
|
||||
owner_id AS OwnerId,
|
||||
platform AS Platform,
|
||||
step AS Step,
|
||||
payload::text AS PayloadJson,
|
||||
draft_message_id AS DraftMessageId,
|
||||
created_at AS CreatedAt,
|
||||
updated_at AS UpdatedAt,
|
||||
expires_at AS ExpiresAt
|
||||
FROM wizard_drafts
|
||||
WHERE platform = @Platform
|
||||
AND owner_id = @OwnerId
|
||||
AND expires_at > NOW()
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
|
||||
sql,
|
||||
new { Platform = platform, OwnerId = ownerId });
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO wizard_drafts
|
||||
(id, chat_id, message_thread_id, owner_id, platform, step, payload, draft_message_id, created_at, updated_at, expires_at)
|
||||
VALUES
|
||||
(@Id, @ChatId, @MessageThreadId, @OwnerId, @Platform, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET step = EXCLUDED.step,
|
||||
payload = EXCLUDED.payload,
|
||||
draft_message_id = EXCLUDED.draft_message_id,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
expires_at = EXCLUDED.expires_at;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(sql, draft);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(sql, new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
|
||||
{
|
||||
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.ExecuteAsync(sql);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
public enum WizardCreationType { Single, Pool }
|
||||
|
||||
public enum WizardVisibility { Public, Club, Members }
|
||||
|
||||
public sealed class WizardSlotInput
|
||||
{
|
||||
public DateTimeOffset ScheduledAt { get; set; }
|
||||
public int MaxPlayers { get; set; }
|
||||
public bool Waitlist { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WizardSingleInput
|
||||
{
|
||||
public DateTimeOffset? ScheduledAt { get; set; }
|
||||
public int? MaxPlayers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WizardPayload
|
||||
{
|
||||
public WizardCreationType? Type { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImageFileId { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
public string? System { get; set; }
|
||||
public int? DurationMinutes { get; set; }
|
||||
public WizardVisibility? Visibility { get; set; }
|
||||
public Guid? ClubId { get; set; }
|
||||
public bool? PublishInShowcase { get; set; }
|
||||
public bool? Waitlist { get; set; }
|
||||
public WizardSingleInput? Single { get; set; }
|
||||
public WizardPoolInput? Pool { get; set; }
|
||||
|
||||
// Wizard-flow metadata (not a wizard step input).
|
||||
[JsonIgnore]
|
||||
public int RetryCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WizardPoolInput
|
||||
{
|
||||
public List<WizardSlotInput> Slots { get; set; } = new();
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(WizardPayload))]
|
||||
[JsonSerializable(typeof(WizardSingleInput))]
|
||||
[JsonSerializable(typeof(WizardPoolInput))]
|
||||
[JsonSerializable(typeof(WizardSlotInput))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public partial class WizardPayloadJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Limits and bounds used by the wizard's input validation. Kept here
|
||||
/// (rather than on the Telegram-only <c>WizardStep</c>) so the state
|
||||
/// machine can reference them without pulling in a platform dependency.
|
||||
/// </summary>
|
||||
public static class WizardStepLimits
|
||||
{
|
||||
public const int MaxTitleLength = 200;
|
||||
public const int MaxDescriptionLength = 4000;
|
||||
public const int MaxSystemLength = 100;
|
||||
public const int MaxCapacity = 50;
|
||||
public const int MinCapacity = 1;
|
||||
public const int MinDurationHours = 1;
|
||||
public const int MaxDurationHours = 12;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Symbolic step identifiers used by <see cref="WizardDraft.Step"/> and
|
||||
/// the <see cref="WizardCallbackData"/> payload. Strings (rather than an
|
||||
/// enum) so that future platforms can extend the set without breaking
|
||||
/// the wire format stored in PostgreSQL.
|
||||
/// </summary>
|
||||
public static class WizardStepNames
|
||||
{
|
||||
public const string Type = "Type";
|
||||
public const string Title = "Title";
|
||||
public const string Description = "Description";
|
||||
public const string Cover = "Cover";
|
||||
public const string System = "System";
|
||||
public const string Duration = "Duration";
|
||||
public const string DateTime = "DateTime";
|
||||
public const string Capacity = "Capacity";
|
||||
public const string Visibility = "Visibility";
|
||||
public const string PickClub = "PickClub";
|
||||
public const string Publish = "Publish";
|
||||
public const string Confirm = "Confirm";
|
||||
|
||||
// Pool steps
|
||||
public const string PoolSystemDuration = "PoolSystemDuration";
|
||||
public const string PoolAddSlots = "PoolAddSlots";
|
||||
public const string PoolSlotDateTime = "PoolSlotDateTime";
|
||||
public const string PoolSlotCapacity = "PoolSlotCapacity";
|
||||
public const string PoolConfirm = "PoolConfirm";
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a (text, list of <see cref="WizardAction"/>s) pair for each
|
||||
/// wizard step. This is the "view builder" half of ADR-002: the same
|
||||
/// builder is used by every platform messenger, and each messenger is
|
||||
/// responsible for converting the action list into its native UI
|
||||
/// (Telegram's <c>InlineKeyboardMarkup</c> today, Discord components
|
||||
/// later).
|
||||
/// </summary>
|
||||
public static class WizardStepViewBuilder
|
||||
{
|
||||
public static (string Text, IReadOnlyList<WizardAction> Actions) Build(
|
||||
WizardDraft draft,
|
||||
WizardPayload payload,
|
||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||
{
|
||||
return draft.Step switch
|
||||
{
|
||||
WizardStepNames.Type => BuildType(),
|
||||
WizardStepNames.Title => BuildTitle(),
|
||||
WizardStepNames.Description => BuildDescription(),
|
||||
WizardStepNames.Cover => BuildCover(),
|
||||
WizardStepNames.System => BuildSystem(),
|
||||
WizardStepNames.Duration => BuildDuration(),
|
||||
WizardStepNames.DateTime => BuildDateTime(),
|
||||
WizardStepNames.Capacity => BuildCapacity(),
|
||||
WizardStepNames.Visibility => BuildVisibility(),
|
||||
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
||||
WizardStepNames.Publish => BuildPublish(),
|
||||
WizardStepNames.Confirm => BuildSingleConfirm(payload),
|
||||
|
||||
WizardStepNames.PoolSystemDuration => BuildPoolSystemDuration(),
|
||||
WizardStepNames.PoolAddSlots => BuildPoolAddSlots(payload),
|
||||
WizardStepNames.PoolSlotDateTime => BuildPoolSlotDateTime(),
|
||||
WizardStepNames.PoolSlotCapacity => BuildPoolSlotCapacity(),
|
||||
WizardStepNames.PoolConfirm => BuildPoolConfirm(payload),
|
||||
|
||||
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Single-game views ──────────────────────────────────────────────
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildType() => (
|
||||
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
|
||||
new[]
|
||||
{
|
||||
new WizardAction("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single"), WizardActionStyle.Primary),
|
||||
new WizardAction("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool"), WizardActionStyle.Primary),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildTitle() => (
|
||||
"📝 Введите название игры одним сообщением.",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDescription() => (
|
||||
"📄 Введите описание (или «-», чтобы пропустить).",
|
||||
SkipBackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCover() => (
|
||||
"🖼 Пришлите картинку как вложение или URL (или «-»).",
|
||||
SkipBackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildSystem() => (
|
||||
"🎲 Выберите систему.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")),
|
||||
new("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")),
|
||||
new("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")),
|
||||
new("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")),
|
||||
new("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")),
|
||||
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDuration() => (
|
||||
"⏱ Выберите длительность.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")),
|
||||
new("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")),
|
||||
new("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")),
|
||||
new("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")),
|
||||
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDateTime() => (
|
||||
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||
"🔒 Выберите видимость.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public"), WizardActionStyle.Primary),
|
||||
new("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club"), WizardActionStyle.Primary),
|
||||
new("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")),
|
||||
new("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPickClub(IReadOnlyList<WizardClubOption> clubs)
|
||||
{
|
||||
if (clubs.Count == 0)
|
||||
{
|
||||
return (
|
||||
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
||||
BackCancel());
|
||||
}
|
||||
var actions = new List<WizardAction>(clubs.Count);
|
||||
foreach (var club in clubs)
|
||||
{
|
||||
actions.Add(new WizardAction(
|
||||
club.Name,
|
||||
WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString())));
|
||||
}
|
||||
return ("🏷 Выберите клуб:", actions);
|
||||
}
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPublish() => (
|
||||
"✨ Опубликовать в витрине сейчас?",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes"), WizardActionStyle.Success),
|
||||
new("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildSingleConfirm(WizardPayload p)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("👀 Проверьте перед созданием:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"🎲 {p.Title}");
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
|
||||
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
|
||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||
return (
|
||||
sb.ToString(),
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Создать", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pool views ─────────────────────────────────────────────────────
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSystemDuration() => (
|
||||
"🎲 Выберите систему и длительность пула.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")),
|
||||
new("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")),
|
||||
new("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")),
|
||||
new("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolAddSlots(WizardPayload p) => (
|
||||
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), WizardActionStyle.Primary),
|
||||
new("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), WizardActionStyle.Success),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotDateTime() => (
|
||||
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) и выберите waitlist.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolConfirm(WizardPayload p)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("👀 Проверьте пул перед созданием:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"📝 {p.Title}");
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
||||
if (p.Pool is not null)
|
||||
{
|
||||
foreach (var s in p.Pool.Slots)
|
||||
{
|
||||
sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}");
|
||||
}
|
||||
}
|
||||
return (
|
||||
sb.ToString(),
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Создать пул", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
private static IReadOnlyList<WizardAction> BackCancel() => new[]
|
||||
{
|
||||
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
|
||||
private static IReadOnlyList<WizardAction> SkipBackCancel() => new[]
|
||||
{
|
||||
new WizardAction("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")),
|
||||
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
|
||||
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
||||
{
|
||||
WizardVisibility.Public => "публичная в общем showcase",
|
||||
WizardVisibility.Club => "публичная в витрине клуба",
|
||||
WizardVisibility.Members => "только для членов клуба",
|
||||
_ => "не задана",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the wizard's persistence layer fails. The wizard catches
|
||||
/// this specifically so the user sees a friendly message instead of a
|
||||
/// raw stack trace.
|
||||
/// </summary>
|
||||
public sealed class WizardStorageException : Exception
|
||||
{
|
||||
public WizardStorageException(string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||
|
||||
public sealed record ExportCalendarCommand(
|
||||
PlatformGroup Group,
|
||||
PlatformUser User);
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||
|
||||
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||
|
||||
public sealed class ExportCalendarHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
||||
+ " FROM sessions s"
|
||||
+ " JOIN game_groups g ON s.group_id = g.id"
|
||||
+ " WHERE g.platform = @Platform"
|
||||
+ " AND g.external_group_id = @ExternalGroupId"
|
||||
+ " AND s.status = @Planned"
|
||||
+ " AND s.scheduled_at > NOW()"
|
||||
+ " ORDER BY s.scheduled_at ASC",
|
||||
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
command.Group,
|
||||
"📭 У этой группы нет запланированных сессий для экспорта.",
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("BEGIN:VCALENDAR");
|
||||
sb.AppendLine("VERSION:2.0");
|
||||
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
||||
|
||||
foreach (var s in sessionsList)
|
||||
{
|
||||
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
||||
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
||||
|
||||
sb.AppendLine("BEGIN:VEVENT");
|
||||
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
||||
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
||||
sb.AppendLine($"DTSTART:{dtStart}");
|
||||
sb.AppendLine($"DTEND:{dtEnd}");
|
||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
||||
sb.AppendLine("END:VEVENT");
|
||||
}
|
||||
|
||||
sb.AppendLine("END:VCALENDAR");
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
|
||||
// Create calendar subscription
|
||||
string? subscriptionUrl = null;
|
||||
var baseUrl = configuration["Web:BaseUrl"];
|
||||
var senderId = command.User.ExternalUserId;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
||||
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
|
||||
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
||||
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
|
||||
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
||||
|
||||
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-critical: if subscription creation fails, still send the file
|
||||
}
|
||||
}
|
||||
|
||||
var actions = subscriptionUrl is not null
|
||||
? new[]
|
||||
{
|
||||
new PlatformMessageAction(
|
||||
"calendar-subscription",
|
||||
"🔗 Подписаться на календарь",
|
||||
subscriptionUrl)
|
||||
}
|
||||
: Array.Empty<PlatformMessageAction>();
|
||||
|
||||
await messenger.SendCalendarFileAsync(
|
||||
new PlatformCalendarFile(
|
||||
command.Group,
|
||||
"schedule.ics",
|
||||
bytes,
|
||||
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||
actions),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
internal sealed record DeleteSessionInfoDto(
|
||||
string Title,
|
||||
Guid BatchId,
|
||||
Guid GroupId,
|
||||
bool CanManage,
|
||||
int? ThreadId,
|
||||
bool TopicCreatedByBot);
|
||||
|
||||
public sealed record DeleteSessionResult(
|
||||
bool Success,
|
||||
string? ReplyText,
|
||||
string? Title,
|
||||
Guid? GroupId,
|
||||
int? ThreadId,
|
||||
bool TopicCreatedByBot,
|
||||
int RemainingInTopic);
|
||||
|
||||
public sealed class DeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Use the database mutation order before locking the session or linked portfolio cards.
|
||||
await connection.ExecuteAsync(
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)",
|
||||
transaction: transaction);
|
||||
|
||||
// 2. Lock the session before any linked portfolio card and verify group manager.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.group_id AS GroupId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE OF s
|
||||
""",
|
||||
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
|
||||
}
|
||||
|
||||
if (!session.CanManage)
|
||||
{
|
||||
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||
}
|
||||
|
||||
// 3. Unpublish a linked portfolio card before its required session link cascades away.
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
FROM portfolio_game_sessions pgs
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND pgs.session_id = @SessionId
|
||||
AND pg.is_public = true
|
||||
""",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
// 4. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
var remainingInTopic = session.ThreadId.HasValue
|
||||
? await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sessions
|
||||
WHERE group_id = @GroupId
|
||||
AND thread_id = @ThreadId
|
||||
""",
|
||||
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||
transaction)
|
||||
: 0;
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
return new DeleteSessionResult(
|
||||
true,
|
||||
"Сессия удалена!",
|
||||
session.Title,
|
||||
session.GroupId,
|
||||
session.ThreadId,
|
||||
session.TopicCreatedByBot,
|
||||
remainingInTopic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
public sealed record ListSessionsCommand(
|
||||
PlatformGroup Group,
|
||||
PlatformUser User);
|
||||
|
||||
public sealed record DeleteSessionCommand(
|
||||
Guid SessionId,
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
@@ -0,0 +1,57 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||
|
||||
public sealed record SessionListResult(
|
||||
IReadOnlyList<SessionListItemDto> Sessions,
|
||||
bool CanManage);
|
||||
|
||||
public sealed class ListSessionsHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||
@"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,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.platform = @Platform
|
||||
AND manager_player.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
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 = @Platform
|
||||
AND g.external_group_id = @ExternalGroupId
|
||||
AND s.status != @Cancelled
|
||||
AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
Platform = command.Group.Platform.ToString(),
|
||||
ExternalGroupId = command.Group.ExternalGroupId,
|
||||
ExternalUserId = command.User.ExternalUserId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
|
||||
|
||||
return new SessionListResult(sessionsList, canManage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
internal sealed record AwaitingProposalDto(
|
||||
Guid Id,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
Guid BatchId,
|
||||
int? BatchMessageId,
|
||||
string ExternalGroupId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleTimeInputCommand(
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string Text);
|
||||
|
||||
public sealed record HandleRescheduleVoteCommand(
|
||||
Guid OptionId,
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string InteractionId,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user