Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e59b0a78fd | |||
| b952be23eb | |||
| 4054d49ccb | |||
| d678c59105 | |||
| 20b4240a11 | |||
| e846a75ca1 | |||
| 29e5652477 | |||
| 02fc5bd106 | |||
| 6cd68493f1 | |||
| de121d7523 | |||
| 3c967dc3e3 | |||
| 7d5dd2ed0a | |||
| 7cb5b03cc2 | |||
| 014b5edd31 | |||
| bbd58142db | |||
| 956ec01583 | |||
| 5014ca5c58 | |||
| efd86bca0a | |||
| 2241568bac | |||
| 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 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.7.1
|
VERSION: 3.11.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
@@ -70,9 +70,42 @@ jobs:
|
|||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.codeanddice.ru
|
||||||
|
username: toutsu
|
||||||
|
password: ${{ secrets.GIT_TOKEN }}
|
||||||
|
|
||||||
- name: Install Trivy
|
- name: Install Trivy
|
||||||
run: |
|
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 ...`
|
||||||
|
# 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: Pull images for scan
|
||||||
|
run: |
|
||||||
|
docker pull git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||||
|
docker pull git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||||
|
docker pull git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Scan Bot image
|
- name: Scan Bot image
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -47,13 +47,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Trivy
|
- name: Install Trivy
|
||||||
run: |
|
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
|
trivy --version
|
||||||
|
|
||||||
- name: Trivy filesystem security scan
|
- name: Trivy filesystem security scan
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
trivy fs --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
|
trivy fs --timeout 30m --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
|
||||||
trivy_exit="${PIPESTATUS[0]}"
|
trivy_exit="${PIPESTATUS[0]}"
|
||||||
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
|
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
|
||||||
echo "::error::Trivy did not detect any language-specific dependency files."
|
echo "::error::Trivy did not detect any language-specific dependency files."
|
||||||
@@ -78,4 +90,11 @@ jobs:
|
|||||||
# ── Tests ──
|
# ── Tests ──
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
|
run: |
|
||||||
|
# Exclude Testcontainers-backed PostgreSQL integration collections from PR CI.
|
||||||
|
# The ARM64 runner is too slow to reliably start Postgres containers and apply
|
||||||
|
# migrations before the default timeouts expire. These tests are still run
|
||||||
|
# locally and can be executed manually with `dotnet test`.
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj \
|
||||||
|
--filter "FullyQualifiedName!~PortfolioMigrationPostgresTests&FullyQualifiedName!~CreateSessionHandlerIntegrationTests&FullyQualifiedName!~WizardDraftRepositoryTests&FullyQualifiedName!~DbSessionTriggerStoreTests&Collection!~CreateSessionHandlerPostgresCollection" \
|
||||||
|
--verbosity normal
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.7.1</Version>
|
<Version>3.11.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v3.6.0`.
|
**Текущая версия:** `v3.10.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
|
|
||||||
### 🤖 Telegram Bot
|
### 🤖 Telegram Bot
|
||||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
|
- **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация.
|
||||||
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
|
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
|
||||||
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
||||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||||
@@ -127,7 +127,7 @@ docker compose up -d
|
|||||||
2. Создайте группу через `/newgroup`.
|
2. Создайте группу через `/newgroup`.
|
||||||
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
||||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
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`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
5. Перезапустите Docker Compose (`docker compose up -d`), затем создайте расписание: в Telegram через `/newsession` выберите `Online` и URL подключения или `Offline` и адрес места проведения; в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`.
|
||||||
|
|
||||||
## 📚 Портфолио завершённых приключений
|
## 📚 Портфолио завершённых приключений
|
||||||
|
|
||||||
|
|||||||
+93
-5
@@ -1,8 +1,96 @@
|
|||||||
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
|
## 🎯 Minor 3.10.0 — Online/offline format in /newsession wizard (issue #136)
|
||||||
|
|
||||||
|
### 🧩 Что вошло в релиз
|
||||||
|
- Telegram `/newsession` wizard теперь запрашивает формат `Online` / `Offline`.
|
||||||
|
- Для `Online` мастер вводит URL подключения; для `Offline` — адрес места проведения.
|
||||||
|
- Offline-адрес сохраняется в `sessions.location_address` через миграцию `V033__add_session_location_address.sql`.
|
||||||
|
- Telegram schedule messages показывают URL online-игры или адрес offline-встречи; Web duplicate Telegram renderer синхронизирован.
|
||||||
|
|
||||||
|
### 📦 Версия и деплой
|
||||||
|
- Версия обновлена до 3.10.0 (`Directory.Build.props`, `NavMenu.razor`, `.gitea/workflows/deploy.yml`).
|
||||||
|
- Docker-образы тегируются `3.10.0` в `compose.yaml`.
|
||||||
|
|
||||||
|
## 🐞 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.
|
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
|
||||||
|
|
||||||
## 🧩 Что вошло в релиз
|
### 🧩 Что вошло в релиз
|
||||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
|
- 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/DiscordNewSessionHandler.cs — handler создания batch + session в БД
|
||||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
|
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
|
||||||
@@ -13,11 +101,11 @@
|
|||||||
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
|
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
|
||||||
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
|
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
|
||||||
|
|
||||||
## 🗺 Что это даёт
|
### 🗺 Что это даёт
|
||||||
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
|
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
|
||||||
- Участники сервера видят расписание через /listsessions.
|
- Участники сервера видят расписание через /listsessions.
|
||||||
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
|
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
|
||||||
|
|
||||||
## 📦 Версия и деплой
|
### 📦 Версия и деплой
|
||||||
- версия обновлена до 2.4.0
|
- версия обновлена до 2.4.0
|
||||||
- Docker-образы используют тег 2.4.0
|
- Docker-образы используют тег 2.4.0
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.1
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.1
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -86,7 +86,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.1
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
+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
|
||||||
@@ -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.
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
||||||
|
<!-- Overrides transitive vulnerable MessagePack 2.5.192 pulled by Aspire.Hosting.PostgreSQL.
|
||||||
|
See GHSA-hv8m-jj95-wg3x / CVE-2026-48109. -->
|
||||||
|
<PackageReference Include="MessagePack" Version="2.5.301" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
"System.IO.Hashing": "10.0.3"
|
"System.IO.Hashing": "10.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MessagePack": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[2.5.301, )",
|
||||||
|
"resolved": "2.5.301",
|
||||||
|
"contentHash": "WUnJgmYc06ngIxZxLe9sa0P6rOTyOZIQn8SuDvJSjyMn7e8/AdlNAdt81WPUhWKeQ7hDkgxKU1vTrJqX/4L79A==",
|
||||||
|
"dependencies": {
|
||||||
|
"MessagePack.Annotations": "2.5.301",
|
||||||
|
"Microsoft.NET.StringTools": "17.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"SecurityCodeScan.VS2019": {
|
"SecurityCodeScan.VS2019": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[5.6.7, )",
|
"requested": "[5.6.7, )",
|
||||||
@@ -248,19 +258,10 @@
|
|||||||
"YamlDotNet": "16.3.0"
|
"YamlDotNet": "16.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"MessagePack": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "2.5.192",
|
|
||||||
"contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==",
|
|
||||||
"dependencies": {
|
|
||||||
"MessagePack.Annotations": "2.5.192",
|
|
||||||
"Microsoft.NET.StringTools": "17.6.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"MessagePack.Annotations": {
|
"MessagePack.Annotations": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.5.192",
|
"resolved": "2.5.301",
|
||||||
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
|
"contentHash": "3PyBiSeKTfvtyzUv3+9eXGIw7vBBZ0GAc4k3+RVT0tz2vKv3l0pviiA2b6DrmHyDvj1Au8lSVDDw/wKPMxUQ4A=="
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.AI.Abstractions": {
|
"Microsoft.Extensions.AI.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
|||||||
@@ -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
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Устанавливаем wget для healthcheck
|
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
# и 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Копируем только AOT-результаты из билда
|
# Копируем только AOT-результаты из билда
|
||||||
|
|||||||
@@ -70,7 +70,17 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
// 3. Загружаем весь батч для перерисовки
|
// 3. Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
@"SELECT id as SessionId,
|
||||||
|
scheduled_at as ScheduledAt,
|
||||||
|
status as Status,
|
||||||
|
max_players as MaxPlayers,
|
||||||
|
join_link as JoinLink,
|
||||||
|
format as Format,
|
||||||
|
location_address as LocationAddress,
|
||||||
|
description as Description,
|
||||||
|
system as System,
|
||||||
|
duration_minutes as DurationMinutes,
|
||||||
|
is_one_shot as IsOneShot
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at",
|
ORDER BY scheduled_at",
|
||||||
|
|||||||
@@ -1,57 +1,66 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wizard-driven entry point for game-session creation. Replaces the legacy
|
/// Telegram-side entry point for the wizard-driven session creation
|
||||||
/// text-template parser. Exposes <see cref="StartWizardAsync"/> (called from
|
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
|
||||||
/// <c>/newsession</c>), <see cref="TryResumeAsync"/> (continue a draft), and
|
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
|
||||||
/// <see cref="SubmitDraftAsync"/> (finalize on "✅ Создать" callback).
|
/// platform glue (mapping <c>Message</c> to draft fields, rendering
|
||||||
|
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CreateSessionHandler
|
public sealed class CreateSessionHandler
|
||||||
{
|
{
|
||||||
private const int MaxRetries = 3;
|
private const int MaxRetries = 3;
|
||||||
|
private const string PlatformName = "Telegram";
|
||||||
|
|
||||||
private readonly IWizardDraftRepository _drafts;
|
private readonly IWizardDraftRepository _drafts;
|
||||||
private readonly SharedCreateSessionHandler _shared;
|
private readonly SharedCreateSessionHandler _shared;
|
||||||
private readonly ITelegramWizardMessenger _messenger;
|
private readonly IWizardMessenger _messenger;
|
||||||
private readonly ILogger<CreateSessionHandler> _log;
|
private readonly ILogger<CreateSessionHandler> _log;
|
||||||
|
private readonly IPlatformMessenger? _platformMessenger;
|
||||||
|
private readonly NpgsqlDataSource? _dataSource;
|
||||||
|
|
||||||
public CreateSessionHandler(
|
public CreateSessionHandler(
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
SharedCreateSessionHandler shared,
|
SharedCreateSessionHandler shared,
|
||||||
ITelegramWizardMessenger messenger,
|
IWizardMessenger messenger,
|
||||||
ILogger<CreateSessionHandler> log)
|
ILogger<CreateSessionHandler> log,
|
||||||
|
IPlatformMessenger? platformMessenger = null,
|
||||||
|
NpgsqlDataSource? dataSource = null)
|
||||||
{
|
{
|
||||||
_drafts = drafts;
|
_drafts = drafts;
|
||||||
_shared = shared;
|
_shared = shared;
|
||||||
_messenger = messenger;
|
_messenger = messenger;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_platformMessenger = platformMessenger;
|
||||||
|
_dataSource = dataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for <c>/newsession</c>. If a non-expired draft already exists for
|
/// Entry point for <c>/newsession</c>. If a non-expired draft
|
||||||
/// this (chat, thread, owner), returns <c>null</c> so the caller can render a
|
/// already exists for this owner, returns <c>null</c> so the caller
|
||||||
/// "Continue / Start over / Cancel" menu.
|
/// can render a "Continue / Start over / Cancel" menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
|
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existing = await _drafts.GetActiveAsync(
|
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||||
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
|
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -60,60 +69,69 @@ public sealed class CreateSessionHandler
|
|||||||
var draft = new WizardDraft
|
var draft = new WizardDraft
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = message.Chat.Id,
|
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
MessageThreadId = message.MessageThreadId,
|
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
|
||||||
OwnerTelegramId = message.From?.Id ?? 0,
|
OwnerId = ownerId,
|
||||||
|
Platform = PlatformName,
|
||||||
Step = WizardStepNames.Type,
|
Step = WizardStepNames.Type,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||||
};
|
};
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
|
|
||||||
var (text, kb) = WizardStep.Render(draft, new WizardPayload());
|
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||||||
var msgId = await _messenger.SendGroupMessageAsync(
|
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||||
draft.ChatId, draft.MessageThreadId, text, kb, ct);
|
|
||||||
draft.DraftMessageId = msgId;
|
draft.DraftMessageId = msgId;
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resume an existing draft — returns the draft row so the caller can re-render.
|
/// Resume an existing draft — returns the draft row so the caller
|
||||||
|
/// can re-render the resume/reset menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct) =>
|
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
|
||||||
_drafts.GetActiveAsync(
|
{
|
||||||
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
|
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finalize: build shared command(s), call the shared handler, edit the wizard message.
|
/// Finalize: build shared command(s), call the shared handler, edit
|
||||||
/// On failure, retry up to <see cref="MaxRetries"/> times before deleting the draft.
|
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
|
||||||
|
/// times before deleting the draft.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
|
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var payload = LoadPayload(draft);
|
var payload = LoadPayload(draft);
|
||||||
if (!IsComplete(payload, out var missing))
|
if (!IsComplete(payload, out var missing))
|
||||||
{
|
{
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
|
||||||
$"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var commands = BuildCommands(draft, payload);
|
var commands = BuildCommands(draft, payload);
|
||||||
|
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var cmd in commands)
|
foreach (var cmd in commands)
|
||||||
{
|
{
|
||||||
await _shared.HandleAsync(cmd, ct);
|
var result = await _shared.HandleAsync(cmd, ct);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
created.Add((cmd, result));
|
||||||
}
|
}
|
||||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
|
||||||
await _messenger.EditMessageTextAsync(
|
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
|
||||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
|
||||||
EmptyKeyboard(), ct);
|
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -122,26 +140,109 @@ public sealed class CreateSessionHandler
|
|||||||
SavePayload(draft, payload);
|
SavePayload(draft, payload);
|
||||||
if (payload.RetryCount >= MaxRetries)
|
if (payload.RetryCount >= MaxRetries)
|
||||||
{
|
{
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft,
|
||||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||||||
EmptyKeyboard(), ct);
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft,
|
||||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||||
RetryCancelKeyboard(), ct);
|
RetryCancelActions(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in created)
|
||||||
|
{
|
||||||
|
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_platformMessenger is null || _dataSource is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Session publication dependencies are not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.View is null || result.BatchId is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Created session result does not contain publication data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var group = command.Group;
|
||||||
|
var topicCreatedByBot = false;
|
||||||
|
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||||
|
{
|
||||||
|
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
|
||||||
|
group = group with { ExternalThreadId = thread.ExternalThreadId };
|
||||||
|
topicCreatedByBot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
|
||||||
|
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET thread_id = @ThreadId,
|
||||||
|
batch_message_id = @BatchMessageId,
|
||||||
|
topic_created_by_bot = @TopicCreatedByBot,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE batch_id = @BatchId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
result.BatchId,
|
||||||
|
ThreadId = ParseNullableInt(group.ExternalThreadId),
|
||||||
|
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
|
||||||
|
TopicCreatedByBot = topicCreatedByBot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseInt(string value) =>
|
||||||
|
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static int? ParseNullableInt(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? null
|
||||||
|
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
// ── Build shared commands ────────────────────────────────────────
|
// ── Build shared commands ────────────────────────────────────────
|
||||||
// The shared handler creates one session per scheduled time in a single transaction
|
// The shared handler creates one session per scheduled time in a
|
||||||
// and assigns the same batch_id to all of them. A wizard pool therefore produces ONE
|
// single transaction and assigns the same batch_id to all of them.
|
||||||
// command with N times; a single-game wizard produces ONE command with one time.
|
// 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)
|
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
||||||
{
|
{
|
||||||
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||||||
@@ -153,7 +254,7 @@ public sealed class CreateSessionHandler
|
|||||||
p,
|
p,
|
||||||
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||||||
MaxPlayersForPool(pool),
|
MaxPlayersForPool(pool),
|
||||||
isOneShot: false)
|
isOneShot: false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return new List<CreateSessionCommand>
|
return new List<CreateSessionCommand>
|
||||||
@@ -162,46 +263,46 @@ public sealed class CreateSessionHandler
|
|||||||
draft,
|
draft,
|
||||||
p,
|
p,
|
||||||
new[] { p.Single?.ScheduledAt ?? default },
|
new[] { p.Single?.ScheduledAt ?? default },
|
||||||
p.Single?.MaxPlayers ?? 0,
|
p.Single?.MaxPlayers,
|
||||||
isOneShot: true)
|
isOneShot: true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||||
|
|
||||||
private static CreateSessionCommand BuildCommand(
|
internal static CreateSessionCommand BuildCommand(
|
||||||
WizardDraft draft,
|
WizardDraft draft,
|
||||||
WizardPayload p,
|
WizardPayload p,
|
||||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
int maxPlayers,
|
int? maxPlayers,
|
||||||
bool isOneShot)
|
bool isOneShot)
|
||||||
{
|
{
|
||||||
var gmId = draft.OwnerTelegramId;
|
|
||||||
var user = new PlatformUser(
|
var user = new PlatformUser(
|
||||||
PlatformKind.Telegram,
|
PlatformKind.Telegram,
|
||||||
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
draft.OwnerId,
|
||||||
DisplayName: string.Empty,
|
DisplayName: string.Empty,
|
||||||
ExternalUsername: null);
|
ExternalUsername: null);
|
||||||
var group = new PlatformGroup(
|
var group = new PlatformGroup(
|
||||||
PlatformKind.Telegram,
|
PlatformKind.Telegram,
|
||||||
draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
draft.ChatId,
|
||||||
DisplayName: string.Empty,
|
DisplayName: string.Empty,
|
||||||
ExternalChannelId: null,
|
ExternalChannelId: null,
|
||||||
ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
ExternalThreadId: draft.MessageThreadId);
|
||||||
return new CreateSessionCommand(
|
return new CreateSessionCommand(
|
||||||
User: user,
|
User: user,
|
||||||
Group: group,
|
Group: group,
|
||||||
Title: p.Title ?? string.Empty,
|
Title: p.Title ?? string.Empty,
|
||||||
Link: string.Empty,
|
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
|
||||||
ScheduledTimes: scheduledTimes,
|
ScheduledTimes: scheduledTimes,
|
||||||
MaxPlayers: maxPlayers,
|
MaxPlayers: maxPlayers,
|
||||||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||||
System: ParseSystem(p.System),
|
System: ParseSystem(p.System),
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
Format: null,
|
Format: p.Format?.ToString(),
|
||||||
DurationMinutes: p.DurationMinutes,
|
DurationMinutes: p.DurationMinutes,
|
||||||
IsOneShot: isOneShot);
|
IsOneShot: isOneShot,
|
||||||
|
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GameSystem? ParseSystem(string? code)
|
private static GameSystem? ParseSystem(string? code)
|
||||||
@@ -217,12 +318,16 @@ public sealed class CreateSessionHandler
|
|||||||
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||||||
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
||||||
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
||||||
|
if (p.Format is null) missingFields.Add("формат");
|
||||||
|
if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка");
|
||||||
|
if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес");
|
||||||
if (p.Visibility is null) missingFields.Add("видимость");
|
if (p.Visibility is null) missingFields.Add("видимость");
|
||||||
|
|
||||||
if (p.Type == WizardCreationType.Single)
|
if (p.Type == WizardCreationType.Single)
|
||||||
{
|
{
|
||||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
// MaxPlayers = null is a valid "♾ Без лимита" choice
|
||||||
|
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -245,10 +350,9 @@ public sealed class CreateSessionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboards ────────────────────────────────────────────────────
|
// ── Keyboards ────────────────────────────────────────────────────
|
||||||
private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty<InlineKeyboardButton[]>());
|
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||||||
private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[]
|
|
||||||
{
|
{
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) },
|
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,13 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
scheduled_at AS ScheduledAt,
|
scheduled_at AS ScheduledAt,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
max_players AS MaxPlayers,
|
max_players AS MaxPlayers,
|
||||||
join_link AS JoinLink
|
join_link AS JoinLink,
|
||||||
|
format AS Format,
|
||||||
|
location_address AS LocationAddress,
|
||||||
|
description AS Description,
|
||||||
|
system AS System,
|
||||||
|
duration_minutes AS DurationMinutes,
|
||||||
|
is_one_shot AS IsOneShot
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
|
|
||||||
public sealed record WizardClubOption(Guid ClubId, string Name);
|
|
||||||
|
|
||||||
public interface ITelegramWizardMessenger
|
|
||||||
{
|
|
||||||
Task<long> EditMessageTextAsync(long chatId, int? messageThreadId, long messageId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
|
|
||||||
Task<long> SendGroupMessageAsync(long chatId, int? messageThreadId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
|
|
||||||
Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct);
|
|
||||||
Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -3,68 +3,140 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
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(
|
public sealed class TelegramWizardMessenger(
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
NpgsqlDataSource dataSource) : ITelegramWizardMessenger
|
NpgsqlDataSource dataSource) : IWizardMessenger
|
||||||
{
|
{
|
||||||
public async Task<long> EditMessageTextAsync(
|
public async Task<string> EditDraftMessageAsync(
|
||||||
long chatId, int? messageThreadId, long messageId, string text,
|
WizardDraft draft,
|
||||||
InlineKeyboardMarkup keyboard, CancellationToken ct)
|
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(
|
var msg = await bot.EditMessageText(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageId: (int)messageId,
|
messageId: messageId,
|
||||||
text: text,
|
text: text,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
return msg.MessageId;
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<long> SendGroupMessageAsync(
|
public async Task<string> SendDraftMessageAsync(
|
||||||
long chatId, int? messageThreadId, string text,
|
WizardDraft draft,
|
||||||
InlineKeyboardMarkup keyboard, CancellationToken ct)
|
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(
|
var msg = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
text: text,
|
text: text,
|
||||||
messageThreadId: messageThreadId,
|
messageThreadId: threadId,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
return msg.MessageId;
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
|
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(callbackId, text: text, cancellationToken: ct);
|
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
|
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Adjusted from the plan: this codebase models "clubs" as game_groups
|
// Adjusted from the plan: this codebase models "clubs" as game_groups
|
||||||
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
|
// (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
|
// 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
|
// game_groups the owner manages as a GM (via group_managers), matching
|
||||||
// the WizardClubOption contract (UUID id, name) used downstream.
|
// 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 = """
|
const string sql = """
|
||||||
SELECT g.id AS ClubId,
|
SELECT g.id AS ClubId,
|
||||||
g.name AS Name
|
g.name AS Name
|
||||||
FROM game_groups g
|
FROM game_groups g
|
||||||
JOIN group_managers gm ON gm.group_id = g.id
|
JOIN group_managers gm ON gm.group_id = g.id
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE p.platform = 'Telegram'
|
WHERE p.platform = @Platform
|
||||||
AND p.external_user_id = @ExternalId
|
AND p.external_user_id = @ExternalId
|
||||||
GROUP BY g.id, g.name
|
GROUP BY g.id, g.name
|
||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""";
|
""";
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
var rows = await connection.QueryAsync<WizardClubOption>(
|
var rows = await connection.QueryAsync<WizardClubOption>(
|
||||||
new CommandDefinition(sql, new { ExternalId = ownerTelegramId.ToString() }, cancellationToken: ct));
|
sql,
|
||||||
|
new { Platform = "Telegram", ExternalId = ownerId });
|
||||||
return rows.AsList();
|
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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,253 +1,65 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text;
|
using System.Linq;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
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 static class WizardStep
|
||||||
{
|
{
|
||||||
public const int MaxTitleLength = 200;
|
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
|
||||||
public const int MaxDescriptionLength = 4000;
|
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
|
||||||
public const int MaxSystemLength = 100;
|
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
|
||||||
public const int MaxCapacity = 50;
|
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
|
||||||
public const int MinCapacity = 1;
|
public const int MinCapacity = WizardStepLimits.MinCapacity;
|
||||||
public const int MinDurationHours = 1;
|
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
|
||||||
public const int MaxDurationHours = 12;
|
public const int MaxDurationHours = WizardStepLimits.MaxDurationHours;
|
||||||
|
|
||||||
public static (string text, InlineKeyboardMarkup keyboard) Render(
|
/// <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,
|
WizardDraft draft,
|
||||||
WizardPayload payload,
|
WizardPayload payload,
|
||||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||||
{
|
{
|
||||||
return draft.Step switch
|
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||||
{
|
return (text, ToInlineKeyboard(actions));
|
||||||
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 ?? Array.Empty<WizardClubOption>()),
|
|
||||||
WizardStepNames.Publish => RenderPublish(),
|
|
||||||
WizardStepNames.Confirm => RenderSingleConfirm(payload),
|
|
||||||
|
|
||||||
WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
|
|
||||||
WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
|
|
||||||
WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
|
|
||||||
WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
|
|
||||||
WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
|
|
||||||
|
|
||||||
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single-game renderers ──────────────────────────────────────────
|
/// <summary>
|
||||||
private static (string, InlineKeyboardMarkup) RenderType() => (
|
/// Convert a flat list of <see cref="WizardAction"/>s into a
|
||||||
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
|
/// Telegram keyboard. Each action is placed in its own row to
|
||||||
new InlineKeyboardMarkup(new[]
|
/// preserve the pre-refactor visual layout.
|
||||||
{
|
/// </summary>
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single")) },
|
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
}));
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderTitle() => (
|
|
||||||
"📝 Введите название игры одним сообщением.",
|
|
||||||
BackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderDescription() => (
|
|
||||||
"📄 Введите описание (или «-», чтобы пропустить).",
|
|
||||||
SkipBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderCover() => (
|
|
||||||
"🖼 Пришлите картинку как вложение или URL (или «-»).",
|
|
||||||
SkipBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderSystem()
|
|
||||||
{
|
{
|
||||||
var buttons = new List<InlineKeyboardButton[]>
|
if (actions.Count == 0)
|
||||||
{
|
{
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")) },
|
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")) },
|
|
||||||
};
|
|
||||||
return ("🎲 Выберите систему.", new InlineKeyboardMarkup(buttons).AppendBackCancel());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderDuration() => (
|
|
||||||
"⏱ Выберите длительность.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderDateTime() => (
|
|
||||||
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
|
||||||
BackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderCapacity() => (
|
|
||||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderVisibility() => (
|
|
||||||
"🔒 Выберите видимость.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPickClub(IReadOnlyList<WizardClubOption> clubs)
|
|
||||||
{
|
|
||||||
if (clubs.Count == 0)
|
|
||||||
{
|
|
||||||
return (
|
|
||||||
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
|
||||||
BackCancel());
|
|
||||||
}
|
}
|
||||||
var rows = new List<InlineKeyboardButton[]>();
|
var rows = new InlineKeyboardButton[actions.Count][];
|
||||||
foreach (var club in clubs)
|
for (var i = 0; i < actions.Count; i++)
|
||||||
{
|
{
|
||||||
rows.Add(new[]
|
rows[i] = new[]
|
||||||
{
|
{
|
||||||
InlineKeyboardButton.WithCallbackData(club.Name, WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString()))
|
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
return ("🏷 Выберите клуб:", new InlineKeyboardMarkup(rows).AppendBackCancel());
|
return new InlineKeyboardMarkup(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPublish() => (
|
|
||||||
"✨ Опубликовать в витрине сейчас?",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderSingleConfirm(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 InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать", WizardCallbackData.Create()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pool renderers ─────────────────────────────────────────────────
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolSystemDuration() => (
|
|
||||||
"🎲 Выберите систему и длительность пула.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolAddSlots(WizardPayload p) => (
|
|
||||||
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolSlotDateTime() => (
|
|
||||||
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
|
||||||
BackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolSlotCapacity() => (
|
|
||||||
"👥 Введите лимит мест (1..50) и выберите waitlist.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolConfirm(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 InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать пул", WizardCallbackData.Create()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
|
||||||
private static InlineKeyboardMarkup BackCancel() => new(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
});
|
|
||||||
|
|
||||||
private static InlineKeyboardMarkup SkipBackCancel() => new(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
});
|
|
||||||
|
|
||||||
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
|
||||||
{
|
|
||||||
WizardVisibility.Public => "публичная в общем showcase",
|
|
||||||
WizardVisibility.Club => "публичная в витрине клуба",
|
|
||||||
WizardVisibility.Members => "только для членов клуба",
|
|
||||||
_ => "не задана",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class InlineKeyboardMarkupExtensions
|
|
||||||
{
|
|
||||||
public static InlineKeyboardMarkup AppendBackCancel(this InlineKeyboardMarkup kb) => kb;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
|
|
||||||
public sealed class WizardStorageException : Exception
|
|
||||||
{
|
|
||||||
public WizardStorageException(string message, Exception inner) : base(message, inner) { }
|
|
||||||
}
|
|
||||||
+1
-1
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { result.BatchId })).ToList();
|
new { result.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -405,19 +405,8 @@ public sealed class TelegramPlatformMessenger(
|
|||||||
|
|
||||||
Ответьте кнопкой в групповом сообщении расписания.
|
Ответьте кнопкой в групповом сообщении расписания.
|
||||||
""",
|
""",
|
||||||
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
|
PlatformDirectSessionNotificationKind.OneHourReminder => BuildOneHourReminderDirectText(notification),
|
||||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification),
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
|
||||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
|
||||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
|
||||||
""",
|
|
||||||
PlatformDirectSessionNotificationKind.JoinLink => $"""
|
|
||||||
🎮 <b>Игра начинается через 5 минут</b>
|
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
|
||||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
|
||||||
""",
|
|
||||||
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
||||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||||
|
|
||||||
@@ -434,6 +423,39 @@ public sealed class TelegramPlatformMessenger(
|
|||||||
_ => BuildFallbackDirectText(notification)
|
_ => BuildFallbackDirectText(notification)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string BuildOneHourReminderDirectText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"⏰ <b>Игра начнётся примерно через 1 час</b>",
|
||||||
|
string.Empty,
|
||||||
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>",
|
||||||
|
$"📅 {notification.ScheduledAt.FormatMoscow()} (МСК)"
|
||||||
|
};
|
||||||
|
AppendJoinLinkLine(lines, notification.JoinLink);
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildJoinLinkDirectText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"🎮 <b>Игра начинается через 5 минут</b>",
|
||||||
|
string.Empty,
|
||||||
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>"
|
||||||
|
};
|
||||||
|
AppendJoinLinkLine(lines, notification.JoinLink);
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendJoinLinkLine(List<string> lines, string? joinLink)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(joinLink))
|
||||||
|
{
|
||||||
|
lines.Add($"🔗 {System.Net.WebUtility.HtmlEncode(joinLink)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
|
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
|
||||||
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
|
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
|
||||||
|
|
||||||
|
|||||||
@@ -17,15 +17,49 @@ public static class TelegramSessionBatchRenderer
|
|||||||
foreach (var session in view.Sessions)
|
foreach (var session in view.Sessions)
|
||||||
{
|
{
|
||||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||||
messageText += session.MaxPlayers.HasValue
|
|
||||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
|
||||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
var tags = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.System))
|
||||||
|
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Format))
|
||||||
|
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
|
||||||
|
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
|
||||||
|
|
||||||
|
if (tags.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
|
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.DurationMinutes.HasValue)
|
||||||
|
{
|
||||||
|
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Description))
|
||||||
|
{
|
||||||
|
messageText += $"📝 <b>Описание:</b>\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
var format = session.Format ?? string.Empty;
|
||||||
|
var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink))
|
||||||
|
{
|
||||||
|
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
|
||||||
|
messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||||
|
{
|
||||||
|
messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
messageText += session.MaxPlayers.HasValue
|
||||||
|
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||||
|
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
|
||||||
|
|
||||||
if (session.ActivePlayers.Count > 0)
|
if (session.ActivePlayers.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||||
@@ -38,7 +72,7 @@ public static class TelegramSessionBatchRenderer
|
|||||||
|
|
||||||
if (session.WaitlistedPlayers.Count > 0)
|
if (session.WaitlistedPlayers.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
|
||||||
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
}
|
}
|
||||||
@@ -60,4 +94,14 @@ public static class TelegramSessionBatchRenderer
|
|||||||
|
|
||||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatDuration(int minutes)
|
||||||
|
{
|
||||||
|
if (minutes <= 0) return "0 мин";
|
||||||
|
var hours = minutes / 60;
|
||||||
|
var mins = minutes % 60;
|
||||||
|
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
|
||||||
|
if (hours > 0) return $"{hours} ч";
|
||||||
|
return $"{mins} мин";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||||
|
using System.Globalization;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
@@ -16,6 +17,7 @@ using Telegram.Bot;
|
|||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.Enums;
|
using Telegram.Bot.Types.Enums;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ public sealed class UpdateRouter(
|
|||||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||||
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
BotRescheduleVoteHandler rescheduleVoteHandler,
|
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
GameCreationWizard wizard,
|
SharedWizard wizard,
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
@@ -47,9 +49,9 @@ public sealed class UpdateRouter(
|
|||||||
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
|
// 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
|
// (chat, thread, owner), every update routes to the wizard. The wizard is
|
||||||
// responsible for both text input and callback handling.
|
// responsible for both text input and callback handling.
|
||||||
if (TryGetWizardContext(update, out var chatId, out var threadId, out var ownerId))
|
if (TryGetWizardContext(update, out _, out _, out var ownerId))
|
||||||
{
|
{
|
||||||
var draft = await drafts.GetActiveAsync(chatId, threadId, ownerId, ct);
|
var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct);
|
||||||
if (draft is not null)
|
if (draft is not null)
|
||||||
{
|
{
|
||||||
// Resume / Reset / Cancel menu callbacks live in the router because
|
// Resume / Reset / Cancel menu callbacks live in the router because
|
||||||
@@ -60,7 +62,10 @@ public sealed class UpdateRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(update, draft, ct);
|
if (WizardInteractionMapper.TryMap(update, out var interaction))
|
||||||
|
{
|
||||||
|
await wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||||
|
}
|
||||||
|
|
||||||
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
|
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
|
||||||
// acknowledges the callback; the actual session creation lives in
|
// acknowledges the callback; the actual session creation lives in
|
||||||
@@ -157,7 +162,7 @@ public sealed class UpdateRouter(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||||
GameCreationWizard.LoadPayload(draft);
|
SharedWizard.LoadPayload(draft);
|
||||||
|
|
||||||
internal static string GetCommandText(Message message)
|
internal static string GetCommandText(Message message)
|
||||||
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
||||||
@@ -166,30 +171,30 @@ public sealed class UpdateRouter(
|
|||||||
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
|
/// 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).
|
/// Returns false for updates that carry no usable origin (e.g. inline queries).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out long ownerId)
|
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId)
|
||||||
{
|
{
|
||||||
chatId = 0;
|
chatId = 0;
|
||||||
messageThreadId = null;
|
messageThreadId = null;
|
||||||
ownerId = 0;
|
ownerId = string.Empty;
|
||||||
|
|
||||||
switch (update)
|
switch (update)
|
||||||
{
|
{
|
||||||
case { Message: { From: not null, Chat: { } chat } msg }:
|
case { Message: { From: not null, Chat: { } chat } msg }:
|
||||||
chatId = chat.Id;
|
chatId = chat.Id;
|
||||||
messageThreadId = msg.MessageThreadId;
|
messageThreadId = msg.MessageThreadId;
|
||||||
ownerId = msg.From!.Id;
|
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
||||||
chatId = cbmChat.Id;
|
chatId = cbmChat.Id;
|
||||||
messageThreadId = cb.Message?.MessageThreadId;
|
messageThreadId = cb.Message?.MessageThreadId;
|
||||||
ownerId = cb.From!.Id;
|
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case { CallbackQuery: { From: not null } cb2 }:
|
case { CallbackQuery: { From: not null } cb2 }:
|
||||||
// Callback arrived without a message (e.g. from a Mini App). No chat
|
// Callback arrived without a message (e.g. from a Mini App). No chat
|
||||||
// context → wizard cannot run on this update.
|
// context → wizard cannot run on this update.
|
||||||
ownerId = cb2.From!.Id;
|
ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN location_address TEXT;
|
||||||
@@ -73,8 +73,8 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.Create
|
|||||||
|
|
||||||
// Wizard services (issue #111)
|
// Wizard services (issue #111)
|
||||||
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
|
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
|
||||||
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
|
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
|
||||||
builder.Services.AddSingleton<GameCreationWizard>();
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var sessions = (await connection.QueryAsync<SessionBatchDto>(
|
var sessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { result.BatchId })).ToList();
|
new { result.BatchId })).ToList();
|
||||||
|
|
||||||
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||||
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||||
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||||
|
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using GmRelay.DiscordBot;
|
using GmRelay.DiscordBot;
|
||||||
using GmRelay.DiscordBot.Features.Sessions;
|
using GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
using GmRelay.DiscordBot.Infrastructure;
|
using GmRelay.DiscordBot.Infrastructure;
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
using GmRelay.DiscordBot.Infrastructure.Health;
|
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.SendJoinLink;
|
||||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
@@ -25,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
|
|||||||
using NetCord.Services.ComponentInteractions;
|
using NetCord.Services.ComponentInteractions;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
|
[module: Dapper.DapperAot]
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
builder.AddServiceDefaults();
|
builder.AddServiceDefaults();
|
||||||
@@ -82,6 +86,23 @@ builder.Services.AddHostedService<SessionSchedulerService>();
|
|||||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||||
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
|
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
|
builder.Services
|
||||||
.AddDiscordGateway(options =>
|
.AddDiscordGateway(options =>
|
||||||
{
|
{
|
||||||
@@ -90,6 +111,8 @@ builder.Services
|
|||||||
})
|
})
|
||||||
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
|
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
|
||||||
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
||||||
|
.AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>()
|
||||||
|
.AddComponentInteractions<ModalInteraction, ModalInteractionContext>()
|
||||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||||
@@ -57,6 +57,7 @@ public sealed class SendJoinLinkHandler(
|
|||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
AND s.status = @Confirmed
|
AND s.status = @Confirmed
|
||||||
|
AND btrim(s.join_link) <> ''
|
||||||
AND (
|
AND (
|
||||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||||
OR (
|
OR (
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ public sealed record CreateSessionCommand(
|
|||||||
string? Description = null,
|
string? Description = null,
|
||||||
string? Format = null,
|
string? Format = null,
|
||||||
int? DurationMinutes = null,
|
int? DurationMinutes = null,
|
||||||
bool IsOneShot = false);
|
bool IsOneShot = false,
|
||||||
|
string? LocationAddress = null);
|
||||||
|
|||||||
@@ -82,7 +82,13 @@ public sealed class CreateSessionHandler(
|
|||||||
AND p.external_user_id = @ExternalGmId
|
AND p.external_user_id = @ExternalGmId
|
||||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
new
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
Platform = platform,
|
||||||
|
ExternalGmId = externalUserId,
|
||||||
|
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||||
|
},
|
||||||
transaction);
|
transaction);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -118,8 +124,8 @@ public sealed class CreateSessionHandler(
|
|||||||
{
|
{
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
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)
|
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, location_address)
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl, @LocationAddress)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -136,11 +142,23 @@ public sealed class CreateSessionHandler(
|
|||||||
command.Format,
|
command.Format,
|
||||||
DurationMinutes = command.DurationMinutes,
|
DurationMinutes = command.DurationMinutes,
|
||||||
IsOneShot = command.IsOneShot,
|
IsOneShot = command.IsOneShot,
|
||||||
CoverImageUrl = command.ImageReference
|
CoverImageUrl = command.ImageReference,
|
||||||
|
command.LocationAddress
|
||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
|
sessions.Add(new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
scheduledAt.UtcDateTime,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
command.MaxPlayers,
|
||||||
|
command.Link,
|
||||||
|
command.Format,
|
||||||
|
command.LocationAddress,
|
||||||
|
command.Description,
|
||||||
|
command.System?.ToString(),
|
||||||
|
command.DurationMinutes,
|
||||||
|
command.IsOneShot));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public sealed class JoinSessionHandler(
|
|||||||
|
|
||||||
// Загружаем весь батч для перерисовки
|
// Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at",
|
ORDER BY scheduled_at",
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ public sealed class LeaveSessionHandler(
|
|||||||
scheduled_at AS ScheduledAt,
|
scheduled_at AS ScheduledAt,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
max_players AS MaxPlayers,
|
max_players AS MaxPlayers,
|
||||||
join_link AS JoinLink
|
join_link AS JoinLink,
|
||||||
|
format AS Format,
|
||||||
|
location_address AS LocationAddress
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at
|
||||||
|
|||||||
+142
-76
@@ -3,25 +3,27 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Telegram.Bot.Types;
|
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central state machine for the game/pool creation wizard.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class GameCreationWizard
|
public sealed class GameCreationWizard
|
||||||
{
|
{
|
||||||
private readonly IWizardDraftRepository _drafts;
|
private readonly IWizardDraftRepository _drafts;
|
||||||
private readonly ITelegramWizardMessenger _messenger;
|
private readonly IWizardMessenger _messenger;
|
||||||
private readonly ILogger<GameCreationWizard> _log;
|
private readonly ILogger<GameCreationWizard> _log;
|
||||||
|
|
||||||
public GameCreationWizard(
|
public GameCreationWizard(
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
ITelegramWizardMessenger messenger,
|
IWizardMessenger messenger,
|
||||||
ILogger<GameCreationWizard> log)
|
ILogger<GameCreationWizard> log)
|
||||||
{
|
{
|
||||||
_drafts = drafts;
|
_drafts = drafts;
|
||||||
@@ -29,44 +31,61 @@ public sealed class GameCreationWizard
|
|||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Handle a text or callback update from the owning GM.</summary>
|
/// <summary>
|
||||||
public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct)
|
/// 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
|
try
|
||||||
{
|
{
|
||||||
if (update.CallbackQuery is { } cb)
|
if (interaction.CallbackPayload is not null)
|
||||||
{
|
{
|
||||||
await HandleCallbackAsync(draft, cb, ct);
|
await HandleCallbackAsync(draft, interaction, ct);
|
||||||
}
|
}
|
||||||
else if (update.Message is { } msg)
|
else
|
||||||
{
|
{
|
||||||
await HandleTextAsync(draft, msg, ct);
|
await HandleTextAsync(draft, interaction, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (WizardStorageException)
|
catch (WizardStorageException)
|
||||||
{
|
{
|
||||||
// Surface storage failure; do not crash the update loop.
|
if (interaction.CallbackPayload is not null)
|
||||||
if (update.CallbackQuery is { } cb2)
|
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct);
|
await _messenger.AnswerInteractionAsync(
|
||||||
|
interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id);
|
_log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id);
|
||||||
if (update.CallbackQuery is { } cb3)
|
if (interaction.CallbackPayload is not null)
|
||||||
{
|
{
|
||||||
try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); }
|
try
|
||||||
catch { /* swallow — we're already in error path */ }
|
{
|
||||||
|
await _messenger.AnswerInteractionAsync(
|
||||||
|
interaction.InteractionId, "⚠️ Ошибка", ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* swallow — we're already in error path */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCallbackAsync(WizardDraft draft, CallbackQuery cb, CancellationToken ct)
|
private async Task HandleCallbackAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
WizardInteraction interaction,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!WizardCallbackData.TryParse(cb.Data, out var action, out var step, out var choice))
|
if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice))
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(cb.Id, "Неизвестная команда", ct);
|
await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,37 +93,39 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
case "cancel":
|
case "cancel":
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft, "❌ Мастер отменён.", Array.Empty<WizardAction>(), ct);
|
||||||
"❌ Мастер отменён.", EmptyKeyboard, ct);
|
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||||
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "back":
|
case "back":
|
||||||
ApplyBack(draft, step);
|
ApplyBack(draft, step);
|
||||||
await PersistAndRenderAsync(draft, cb.Id, ct);
|
await PersistAndRenderAsync(draft, interaction.InteractionId, ct);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "create":
|
case "create":
|
||||||
// Routed by CreateSessionHandler, not here.
|
// Routed by the platform's CreateSessionHandler, not here.
|
||||||
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
|
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// For "Choice" callbacks, action == step.
|
// For "Choice" callbacks, action == step.
|
||||||
await ApplyChoiceAsync(draft, step, choice, cb.Id, ct);
|
await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleTextAsync(WizardDraft draft, Message msg, CancellationToken ct)
|
private async Task HandleTextAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
WizardInteraction interaction,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (msg.Text is not { } text)
|
if (interaction.Text is not { } text)
|
||||||
{
|
{
|
||||||
// Photo or other non-text — handle cover step only.
|
// Photo or other non-text — handle cover step only.
|
||||||
if (msg.Photo is { Length: > 0 } && draft.Step == WizardStepNames.Cover)
|
if (interaction.PhotoFileId is { } fileId &&
|
||||||
|
draft.Step == WizardStepNames.Cover)
|
||||||
{
|
{
|
||||||
var fileId = msg.Photo[^1].FileId;
|
|
||||||
ApplyCoverPhoto(draft, fileId);
|
ApplyCoverPhoto(draft, fileId);
|
||||||
await PersistAndRenderAsync(draft, null, ct);
|
await PersistAndRenderAsync(draft, null, ct);
|
||||||
}
|
}
|
||||||
@@ -113,13 +134,12 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
var (nextStep, error, payload) = ApplyText(draft, text);
|
var (nextStep, error, payload) = ApplyText(draft, text);
|
||||||
if (payload is { } p) SavePayload(draft, p);
|
if (payload is { } p) SavePayload(draft, p);
|
||||||
if (error is { } errMsg && draft.DraftMessageId is { } mid)
|
if (error is { } errMsg)
|
||||||
{
|
{
|
||||||
// Re-render the same step with ⚠️ prefix.
|
// Re-render the same step with ⚠️ prefix.
|
||||||
var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null);
|
var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft));
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, mid,
|
draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct);
|
||||||
"⚠️ " + errMsg + "\n\n" + rendered, kb, ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +150,13 @@ public sealed class GameCreationWizard
|
|||||||
await PersistAndRenderAsync(draft, null, ct);
|
await PersistAndRenderAsync(draft, null, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct)
|
private async Task ApplyChoiceAsync(
|
||||||
|
WizardDraft draft, string step, string choice, string interactionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
|
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
|
||||||
if (error is { } err)
|
if (error is { } err)
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
|
await _messenger.AnswerInteractionAsync(interactionId, err, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload is { } p) SavePayload(draft, p);
|
if (payload is { } p) SavePayload(draft, p);
|
||||||
@@ -143,26 +164,24 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
draft.Step = s;
|
draft.Step = s;
|
||||||
}
|
}
|
||||||
await PersistAndRenderAsync(draft, callbackId, ct);
|
await PersistAndRenderAsync(draft, interactionId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PersistAndRenderAsync(WizardDraft draft, string? callbackId, CancellationToken ct)
|
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
var payload = LoadPayload(draft);
|
var payload = LoadPayload(draft);
|
||||||
IReadOnlyList<WizardClubOption>? clubs = null;
|
IReadOnlyList<WizardClubOption>? clubs = null;
|
||||||
if (draft.Step == WizardStepNames.PickClub)
|
if (draft.Step == WizardStepNames.PickClub)
|
||||||
{
|
{
|
||||||
clubs = await _messenger.GetGmClubsAsync(draft.OwnerTelegramId, ct);
|
clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct);
|
||||||
}
|
}
|
||||||
var (text, kb) = WizardStep.Render(draft, payload, clubs);
|
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
if (interactionId is { } id)
|
||||||
text, kb, ct);
|
|
||||||
if (callbackId is { } id)
|
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(id, null, ct);
|
await _messenger.AnswerInteractionAsync(id, null, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,13 +192,13 @@ public sealed class GameCreationWizard
|
|||||||
switch (draft.Step)
|
switch (draft.Step)
|
||||||
{
|
{
|
||||||
case WizardStepNames.Title:
|
case WizardStepNames.Title:
|
||||||
return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
|
return ValidateText(input, WizardStepLimits.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
|
||||||
? (WizardStepNames.Description, SetTitle(payload, title), payload)
|
? (WizardStepNames.Description, SetTitle(payload, title), payload)
|
||||||
: (null, title, payload);
|
: (null, title, payload);
|
||||||
|
|
||||||
case WizardStepNames.Description:
|
case WizardStepNames.Description:
|
||||||
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
|
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
|
||||||
return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
|
return ValidateText(input, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
|
||||||
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
|
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
|
||||||
: (null, desc, payload);
|
: (null, desc, payload);
|
||||||
|
|
||||||
@@ -191,7 +210,7 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
case WizardStepNames.System when payload.System is null:
|
case WizardStepNames.System when payload.System is null:
|
||||||
// "Other" branch — only active if free-text was offered.
|
// "Other" branch — only active if free-text was offered.
|
||||||
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
|
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
|
||||||
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
|
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
|
||||||
: (null, sys, payload);
|
: (null, sys, payload);
|
||||||
|
|
||||||
@@ -206,18 +225,29 @@ public sealed class GameCreationWizard
|
|||||||
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||||
|
|
||||||
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
||||||
return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity
|
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
|
||||||
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
|
? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
|
||||||
: (null, "Лимит должен быть 1..50", payload);
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
|
|
||||||
|
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Online:
|
||||||
|
return Uri.TryCreate(input.Trim(), UriKind.Absolute, out var locationUri) &&
|
||||||
|
(locationUri.Scheme == Uri.UriSchemeHttp || locationUri.Scheme == Uri.UriSchemeHttps)
|
||||||
|
? (WizardStepNames.Visibility, SetJoinLink(payload, input.Trim()), payload)
|
||||||
|
: (null, "Некорректная ссылка", payload);
|
||||||
|
|
||||||
|
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Offline:
|
||||||
|
return ValidateText(input, WizardStepLimits.MaxLocationLength, "Адрес не может быть пустым", "Слишком длинный адрес", out var address)
|
||||||
|
? (WizardStepNames.Visibility, SetLocationAddress(payload, address), payload)
|
||||||
|
: (null, address, payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
||||||
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
||||||
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
||||||
: (null, psys, payload);
|
: (null, psys, payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
||||||
return TryParseHours(input, out var pdur)
|
return TryParseHours(input, out var pdur)
|
||||||
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
|
? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload)
|
||||||
: (null, "Неверная длительность (1..12 ч)", payload);
|
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSlotDateTime:
|
case WizardStepNames.PoolSlotDateTime:
|
||||||
@@ -226,7 +256,7 @@ public sealed class GameCreationWizard
|
|||||||
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSlotCapacity:
|
case WizardStepNames.PoolSlotCapacity:
|
||||||
return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity
|
return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity
|
||||||
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
|
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
|
||||||
: (null, "Лимит должен быть 1..50", payload);
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
|
|
||||||
@@ -245,6 +275,7 @@ public sealed class GameCreationWizard
|
|||||||
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
||||||
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
|
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
|
||||||
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
|
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
|
||||||
|
WizardStepNames.Format => ApplyFormatChoice(payload, choice),
|
||||||
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
|
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
|
||||||
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
|
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
|
||||||
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
|
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
|
||||||
@@ -279,10 +310,30 @@ public sealed class GameCreationWizard
|
|||||||
: (null, "Неверная длительность"),
|
: (null, "Неверная длительность"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
|
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
|
||||||
{
|
{
|
||||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
if (choice is "no_limit")
|
||||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
{
|
||||||
|
return (WizardStepNames.Format, SetMaxPlayers(p, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
|
||||||
|
{
|
||||||
|
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||||
|
}
|
||||||
|
|
||||||
|
return choice switch
|
||||||
|
{
|
||||||
|
"waitlist:on" => (WizardStepNames.Format, SetWaitlist(p, true)),
|
||||||
|
"waitlist:off" => (WizardStepNames.Format, SetWaitlist(p, false)),
|
||||||
|
_ => (null, "Неизвестный выбор"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string?, string?) ApplyFormatChoice(WizardPayload p, string choice) => choice switch
|
||||||
|
{
|
||||||
|
"online" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Online)),
|
||||||
|
"offline" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Offline)),
|
||||||
_ => (null, "Неизвестный выбор"),
|
_ => (null, "Неизвестный выбор"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -296,9 +347,15 @@ public sealed class GameCreationWizard
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
|
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
|
||||||
=> Guid.TryParse(choice, out var id)
|
{
|
||||||
? (NextAfterVisibility(p), SetClubId(p, id))
|
if (!Guid.TryParse(choice, out var id))
|
||||||
: (null, "Неверный идентификатор клуба");
|
{
|
||||||
|
return (null, "Неверный идентификатор клуба");
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = SetClubId(p, id);
|
||||||
|
return (NextAfterVisibility(p), error);
|
||||||
|
}
|
||||||
|
|
||||||
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
|
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
|
||||||
{
|
{
|
||||||
@@ -311,7 +368,7 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
||||||
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
|
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
|
||||||
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
|
? (WizardStepNames.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
|
||||||
: (null, "Неверный выбор"),
|
: (null, "Неверный выбор"),
|
||||||
_ => (null, "Неизвестный выбор"),
|
_ => (null, "Неизвестный выбор"),
|
||||||
};
|
};
|
||||||
@@ -353,13 +410,15 @@ public sealed class GameCreationWizard
|
|||||||
WizardStepNames.Duration => WizardStepNames.System,
|
WizardStepNames.Duration => WizardStepNames.System,
|
||||||
WizardStepNames.DateTime => WizardStepNames.Duration,
|
WizardStepNames.DateTime => WizardStepNames.Duration,
|
||||||
WizardStepNames.Capacity => WizardStepNames.DateTime,
|
WizardStepNames.Capacity => WizardStepNames.DateTime,
|
||||||
WizardStepNames.Visibility => WizardStepNames.Capacity,
|
WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity,
|
||||||
|
WizardStepNames.Location => WizardStepNames.Format,
|
||||||
|
WizardStepNames.Visibility => WizardStepNames.Location,
|
||||||
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
||||||
WizardStepNames.Publish => WizardStepNames.PickClub,
|
WizardStepNames.Publish => WizardStepNames.PickClub,
|
||||||
WizardStepNames.Confirm => WizardStepNames.Publish,
|
WizardStepNames.Confirm => WizardStepNames.Publish,
|
||||||
|
|
||||||
WizardStepNames.PoolSystemDuration => null, // first pool step
|
WizardStepNames.PoolSystemDuration => null, // first pool step
|
||||||
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
|
WizardStepNames.PoolAddSlots => WizardStepNames.Visibility,
|
||||||
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
|
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
|
||||||
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
|
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
|
||||||
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
|
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
|
||||||
@@ -367,7 +426,7 @@ public sealed class GameCreationWizard
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Payload I/O ───────────────────────────────────────────────────
|
// ── Payload I/O ───────────────────────────────────────────────────
|
||||||
internal static WizardPayload LoadPayload(WizardDraft draft)
|
public static WizardPayload LoadPayload(WizardDraft draft)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
||||||
return System.Text.Json.JsonSerializer.Deserialize(
|
return System.Text.Json.JsonSerializer.Deserialize(
|
||||||
@@ -397,13 +456,22 @@ public sealed class GameCreationWizard
|
|||||||
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
||||||
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
||||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
||||||
private static string? SetMaxPlayers(WizardPayload p, int v)
|
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
||||||
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
{ 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? 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? 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? 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? 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? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
|
||||||
|
private static string? SetFormat(WizardPayload p, WizardSessionFormat v)
|
||||||
|
{
|
||||||
|
p.Format = v;
|
||||||
|
p.JoinLink = null;
|
||||||
|
p.LocationAddress = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private static string? SetJoinLink(WizardPayload p, string v) { p.JoinLink = v; p.LocationAddress = null; return null; }
|
||||||
|
private static string? SetLocationAddress(WizardPayload p, string v) { p.LocationAddress = v; p.JoinLink = null; return null; }
|
||||||
|
|
||||||
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
|
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
|
||||||
{
|
{
|
||||||
@@ -450,8 +518,8 @@ public sealed class GameCreationWizard
|
|||||||
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
|
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
|
||||||
private static string? NextAfterDuration(WizardPayload p)
|
private static string? NextAfterDuration(WizardPayload p)
|
||||||
{
|
{
|
||||||
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
|
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Format;
|
||||||
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
|
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
|
||||||
}
|
}
|
||||||
private static string? NextAfterVisibility(WizardPayload p)
|
private static string? NextAfterVisibility(WizardPayload p)
|
||||||
{
|
{
|
||||||
@@ -495,10 +563,8 @@ public sealed class GameCreationWizard
|
|||||||
if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
|
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 (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 (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false;
|
||||||
if (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false;
|
if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false;
|
||||||
minutes = (int)Math.Round(hours * 60);
|
minutes = (int)Math.Round(hours * 60);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly InlineKeyboardMarkup EmptyKeyboard = new(Array.Empty<InlineKeyboardButton[]>());
|
|
||||||
}
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|
||||||
|
|
||||||
/// <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(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct);
|
|
||||||
|
|
||||||
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
|
|
||||||
|
|
||||||
Task DeleteAsync(Guid id, CancellationToken ct);
|
|
||||||
|
|
||||||
Task<int> DeleteExpiredAsync(CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+11
-1
@@ -1,14 +1,24 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
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 static class WizardCallbackData
|
||||||
{
|
{
|
||||||
public const string Prefix = "wizard";
|
public const string Prefix = "wizard";
|
||||||
|
|
||||||
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
|
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
|
||||||
|
|
||||||
public static string Back() => $"{Prefix}:back";
|
public static string Back() => $"{Prefix}:back";
|
||||||
|
|
||||||
public static string Cancel() => $"{Prefix}:cancel";
|
public static string Cancel() => $"{Prefix}:cancel";
|
||||||
|
|
||||||
public static string Create() => $"{Prefix}:create";
|
public static string Create() => $"{Prefix}:create";
|
||||||
|
|
||||||
public static bool TryParse(string? data, out string action, out string step, out string choice)
|
public static bool TryParse(string? data, out string action, out string step, out string choice)
|
||||||
@@ -5,13 +5,47 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|||||||
public sealed class WizardDraft
|
public sealed class WizardDraft
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public long ChatId { get; set; }
|
|
||||||
public int? MessageThreadId { get; set; }
|
/// <summary>
|
||||||
public long OwnerTelegramId { get; set; }
|
/// 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 Step { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string PayloadJson { get; set; } = "{}";
|
public string PayloadJson { get; set; } = "{}";
|
||||||
public long? DraftMessageId { get; set; }
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
/// <summary>
|
||||||
public DateTimeOffset UpdatedAt { get; set; }
|
/// Id of the message that the wizard last edited. Stored as
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
/// <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; }
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-16
@@ -8,14 +8,21 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|||||||
|
|
||||||
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
||||||
{
|
{
|
||||||
public async Task<WizardDraft?> GetActiveAsync(
|
// NOTE: NativeAOT — Dapper.AOT 1.0.48 only generates interceptors for the
|
||||||
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
|
// (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 = """
|
const string sql = """
|
||||||
SELECT id AS Id,
|
SELECT id AS Id,
|
||||||
chat_id AS ChatId,
|
chat_id AS ChatId,
|
||||||
message_thread_id AS MessageThreadId,
|
message_thread_id AS MessageThreadId,
|
||||||
owner_telegram_id AS OwnerTelegramId,
|
owner_id AS OwnerId,
|
||||||
|
platform AS Platform,
|
||||||
step AS Step,
|
step AS Step,
|
||||||
payload::text AS PayloadJson,
|
payload::text AS PayloadJson,
|
||||||
draft_message_id AS DraftMessageId,
|
draft_message_id AS DraftMessageId,
|
||||||
@@ -23,27 +30,25 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
|||||||
updated_at AS UpdatedAt,
|
updated_at AS UpdatedAt,
|
||||||
expires_at AS ExpiresAt
|
expires_at AS ExpiresAt
|
||||||
FROM wizard_drafts
|
FROM wizard_drafts
|
||||||
WHERE chat_id = @ChatId
|
WHERE platform = @Platform
|
||||||
AND (message_thread_id = @ThreadId OR (@ThreadId IS NULL AND message_thread_id IS NULL))
|
AND owner_id = @OwnerId
|
||||||
AND owner_telegram_id = @OwnerId
|
|
||||||
AND expires_at > NOW()
|
AND expires_at > NOW()
|
||||||
|
ORDER BY updated_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
|
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
|
||||||
new CommandDefinition(sql,
|
sql,
|
||||||
new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId },
|
new { Platform = platform, OwnerId = ownerId });
|
||||||
cancellationToken: ct));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
|
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
INSERT INTO wizard_drafts
|
INSERT INTO wizard_drafts
|
||||||
(id, chat_id, message_thread_id, owner_telegram_id, step, payload, draft_message_id, created_at, updated_at, expires_at)
|
(id, chat_id, message_thread_id, owner_id, platform, step, payload, draft_message_id, created_at, updated_at, expires_at)
|
||||||
VALUES
|
VALUES
|
||||||
(@Id, @ChatId, @MessageThreadId, @OwnerTelegramId, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
|
(@Id, @ChatId, @MessageThreadId, @OwnerId, @Platform, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
|
||||||
ON CONFLICT (id) DO UPDATE
|
ON CONFLICT (id) DO UPDATE
|
||||||
SET step = EXCLUDED.step,
|
SET step = EXCLUDED.step,
|
||||||
payload = EXCLUDED.payload,
|
payload = EXCLUDED.payload,
|
||||||
@@ -51,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
|||||||
updated_at = EXCLUDED.updated_at,
|
updated_at = EXCLUDED.updated_at,
|
||||||
expires_at = EXCLUDED.expires_at;
|
expires_at = EXCLUDED.expires_at;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
|
await connection.ExecuteAsync(sql, draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid id, CancellationToken ct)
|
public async Task DeleteAsync(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
|
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
await connection.ExecuteAsync(sql, new { Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
|
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
|
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
|
return await connection.ExecuteAsync(sql);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ public enum WizardCreationType { Single, Pool }
|
|||||||
|
|
||||||
public enum WizardVisibility { Public, Club, Members }
|
public enum WizardVisibility { Public, Club, Members }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<WizardSessionFormat>))]
|
||||||
|
public enum WizardSessionFormat { Online, Offline }
|
||||||
|
|
||||||
public sealed class WizardSlotInput
|
public sealed class WizardSlotInput
|
||||||
{
|
{
|
||||||
public DateTimeOffset ScheduledAt { get; set; }
|
public DateTimeOffset ScheduledAt { get; set; }
|
||||||
@@ -30,6 +33,9 @@ public sealed class WizardPayload
|
|||||||
public string? ImageUrl { get; set; }
|
public string? ImageUrl { get; set; }
|
||||||
public string? System { get; set; }
|
public string? System { get; set; }
|
||||||
public int? DurationMinutes { get; set; }
|
public int? DurationMinutes { get; set; }
|
||||||
|
public WizardSessionFormat? Format { get; set; }
|
||||||
|
public string? JoinLink { get; set; }
|
||||||
|
public string? LocationAddress { get; set; }
|
||||||
public WizardVisibility? Visibility { get; set; }
|
public WizardVisibility? Visibility { get; set; }
|
||||||
public Guid? ClubId { get; set; }
|
public Guid? ClubId { get; set; }
|
||||||
public bool? PublishInShowcase { get; set; }
|
public bool? PublishInShowcase { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
public const int MaxLocationLength = 500;
|
||||||
|
}
|
||||||
+9
-1
@@ -1,5 +1,11 @@
|
|||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
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 static class WizardStepNames
|
||||||
{
|
{
|
||||||
public const string Type = "Type";
|
public const string Type = "Type";
|
||||||
@@ -10,6 +16,8 @@ public static class WizardStepNames
|
|||||||
public const string Duration = "Duration";
|
public const string Duration = "Duration";
|
||||||
public const string DateTime = "DateTime";
|
public const string DateTime = "DateTime";
|
||||||
public const string Capacity = "Capacity";
|
public const string Capacity = "Capacity";
|
||||||
|
public const string Format = "Format";
|
||||||
|
public const string Location = "Location";
|
||||||
public const string Visibility = "Visibility";
|
public const string Visibility = "Visibility";
|
||||||
public const string PickClub = "PickClub";
|
public const string PickClub = "PickClub";
|
||||||
public const string Publish = "Publish";
|
public const string Publish = "Publish";
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
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.Format => BuildFormat(),
|
||||||
|
WizardStepNames.Location => BuildLocation(payload),
|
||||||
|
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>) BuildFormat() => (
|
||||||
|
"🧭 Выберите формат игры.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("🌐 Online", WizardCallbackData.Choice(WizardStepNames.Format, "online"), WizardActionStyle.Primary),
|
||||||
|
new("📍 Offline", WizardCallbackData.Choice(WizardStepNames.Format, "offline"), WizardActionStyle.Primary),
|
||||||
|
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||||
|
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildLocation(WizardPayload payload) => payload.Format switch
|
||||||
|
{
|
||||||
|
WizardSessionFormat.Offline => ("📍 Введите адрес места проведения.", BackCancel()),
|
||||||
|
_ => ("🔗 Введите ссылку для подключения к online-игре.", BackCancel()),
|
||||||
|
};
|
||||||
|
|
||||||
|
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} ч");
|
||||||
|
AppendFormatLocation(sb, p);
|
||||||
|
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} ч");
|
||||||
|
AppendFormatLocation(sb, p);
|
||||||
|
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 => "только для членов клуба",
|
||||||
|
_ => "не задана",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void AppendFormatLocation(StringBuilder sb, WizardPayload p)
|
||||||
|
{
|
||||||
|
if (p.Format is null) return;
|
||||||
|
|
||||||
|
sb.AppendLine($"🧭 Формат: {p.Format}");
|
||||||
|
if (p.Format == WizardSessionFormat.Online && !string.IsNullOrWhiteSpace(p.JoinLink))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"🔗 Ссылка: {p.JoinLink}");
|
||||||
|
}
|
||||||
|
else if (p.Format == WizardSessionFormat.Offline && !string.IsNullOrWhiteSpace(p.LocationAddress))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"📍 Адрес: {p.LocationAddress}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -159,7 +159,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { proposal.BatchId })).ToList();
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ public sealed class DbSessionTriggerStore(
|
|||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE g.platform = @Platform
|
WHERE g.platform = @Platform
|
||||||
AND s.status = @Confirmed
|
AND s.status = @Confirmed
|
||||||
|
AND btrim(s.join_link) <> ''
|
||||||
AND s.scheduled_at - @LeadTime <= @Now
|
AND s.scheduled_at - @LeadTime <= @Now
|
||||||
AND (
|
AND (
|
||||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
namespace GmRelay.Shared.Rendering;
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink);
|
public sealed record SessionBatchDto(
|
||||||
|
Guid SessionId,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
int? MaxPlayers,
|
||||||
|
string JoinLink,
|
||||||
|
string? Format = null,
|
||||||
|
string? LocationAddress = null,
|
||||||
|
string? Description = null,
|
||||||
|
string? System = null,
|
||||||
|
int? DurationMinutes = null,
|
||||||
|
bool IsOneShot = false);
|
||||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ public static class SessionBatchViewBuilder
|
|||||||
session.Status,
|
session.Status,
|
||||||
session.MaxPlayers,
|
session.MaxPlayers,
|
||||||
session.JoinLink,
|
session.JoinLink,
|
||||||
|
session.Format,
|
||||||
|
session.LocationAddress,
|
||||||
|
session.Description,
|
||||||
|
session.System,
|
||||||
|
session.DurationMinutes,
|
||||||
|
session.IsOneShot,
|
||||||
activePlayers.Count,
|
activePlayers.Count,
|
||||||
activePlayers,
|
activePlayers,
|
||||||
waitlistedPlayers,
|
waitlistedPlayers,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ public sealed record SessionViewItem(
|
|||||||
string Status,
|
string Status,
|
||||||
int? MaxPlayers,
|
int? MaxPlayers,
|
||||||
string JoinLink,
|
string JoinLink,
|
||||||
|
string? Format,
|
||||||
|
string? LocationAddress,
|
||||||
|
string? Description,
|
||||||
|
string? System,
|
||||||
|
int? DurationMinutes,
|
||||||
|
bool IsOneShot,
|
||||||
int ActivePlayerCount,
|
int ActivePlayerCount,
|
||||||
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
||||||
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.7.1</div>
|
<div class="nav-version">v3.11.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -119,7 +119,14 @@ internal sealed record WebBatchSessionRow(
|
|||||||
long TelegramChatId,
|
long TelegramChatId,
|
||||||
int? ThreadId,
|
int? ThreadId,
|
||||||
string NotificationMode,
|
string NotificationMode,
|
||||||
bool TopicCreatedByBot = false);
|
bool TopicCreatedByBot = false,
|
||||||
|
string? Description = null,
|
||||||
|
string? System = null,
|
||||||
|
int? DurationMinutes = null,
|
||||||
|
string? Format = null,
|
||||||
|
string? LocationAddress = null,
|
||||||
|
bool IsOneShot = false,
|
||||||
|
string? CoverImageUrl = null);
|
||||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||||
internal sealed record WebPublicGroupRow(
|
internal sealed record WebPublicGroupRow(
|
||||||
@@ -1508,7 +1515,14 @@ public sealed class SessionService(
|
|||||||
g.external_group_id::BIGINT AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode,
|
||||||
|
s.description AS Description,
|
||||||
|
s.system AS System,
|
||||||
|
s.duration_minutes AS DurationMinutes,
|
||||||
|
s.format AS Format,
|
||||||
|
s.location_address AS LocationAddress,
|
||||||
|
s.is_one_shot AS IsOneShot,
|
||||||
|
s.cover_image_url AS CoverImageUrl
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.batch_id = @BatchId
|
WHERE s.batch_id = @BatchId
|
||||||
@@ -1536,8 +1550,14 @@ public sealed class SessionService(
|
|||||||
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
||||||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
|
INSERT INTO sessions (
|
||||||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
|
batch_id, group_id, title, join_link, scheduled_at, status, thread_id,
|
||||||
|
topic_created_by_bot, max_players, notification_mode, description, system,
|
||||||
|
duration_minutes, format, location_address, is_one_shot, cover_image_url)
|
||||||
|
VALUES (
|
||||||
|
@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId,
|
||||||
|
@TopicCreatedByBot, @MaxPlayers, @NotificationMode, @Description, @System,
|
||||||
|
@DurationMinutes, @Format, @LocationAddress, @IsOneShot, @CoverImageUrl)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -1551,11 +1571,29 @@ public sealed class SessionService(
|
|||||||
ThreadId = threadId,
|
ThreadId = threadId,
|
||||||
sourceSession.TopicCreatedByBot,
|
sourceSession.TopicCreatedByBot,
|
||||||
sourceSession.MaxPlayers,
|
sourceSession.MaxPlayers,
|
||||||
sourceSession.NotificationMode
|
sourceSession.NotificationMode,
|
||||||
|
Description = sourceSession.Description,
|
||||||
|
System = sourceSession.System,
|
||||||
|
DurationMinutes = sourceSession.DurationMinutes,
|
||||||
|
Format = sourceSession.Format,
|
||||||
|
LocationAddress = sourceSession.LocationAddress,
|
||||||
|
IsOneShot = sourceSession.IsOneShot,
|
||||||
|
CoverImageUrl = sourceSession.CoverImageUrl
|
||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
|
renderedSessions.Add(new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
scheduledAt,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
sourceSession.MaxPlayers,
|
||||||
|
batchJoinLink,
|
||||||
|
sourceSession.Format,
|
||||||
|
sourceSession.LocationAddress,
|
||||||
|
sourceSession.Description,
|
||||||
|
sourceSession.System,
|
||||||
|
sourceSession.DurationMinutes,
|
||||||
|
sourceSession.IsOneShot));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
@@ -1770,7 +1808,18 @@ public sealed class SessionService(
|
|||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
|
renderedSessions.Add(new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
scheduledAt,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
template.MaxPlayers,
|
||||||
|
template.JoinLink,
|
||||||
|
Format: null,
|
||||||
|
LocationAddress: null,
|
||||||
|
Description: null,
|
||||||
|
System: null,
|
||||||
|
DurationMinutes: null,
|
||||||
|
IsOneShot: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
@@ -1897,7 +1946,7 @@ public sealed class SessionService(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
|
||||||
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
var sessions = (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",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { BatchId = batchId })).ToList();
|
new { BatchId = batchId })).ToList();
|
||||||
|
|
||||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -16,15 +16,49 @@ public static class TelegramSessionBatchRenderer
|
|||||||
foreach (var session in view.Sessions)
|
foreach (var session in view.Sessions)
|
||||||
{
|
{
|
||||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||||
messageText += session.MaxPlayers.HasValue
|
|
||||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
|
||||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
var tags = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.System))
|
||||||
|
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Format))
|
||||||
|
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
|
||||||
|
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
|
||||||
|
|
||||||
|
if (tags.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
|
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.DurationMinutes.HasValue)
|
||||||
|
{
|
||||||
|
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Description))
|
||||||
|
{
|
||||||
|
messageText += $"📝 <b>Описание:</b>\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
var format = session.Format ?? string.Empty;
|
||||||
|
var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink))
|
||||||
|
{
|
||||||
|
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
|
||||||
|
messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||||
|
{
|
||||||
|
messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
messageText += session.MaxPlayers.HasValue
|
||||||
|
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||||
|
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
|
||||||
|
|
||||||
if (session.ActivePlayers.Count > 0)
|
if (session.ActivePlayers.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||||
@@ -37,7 +71,7 @@ public static class TelegramSessionBatchRenderer
|
|||||||
|
|
||||||
if (session.WaitlistedPlayers.Count > 0)
|
if (session.WaitlistedPlayers.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
|
||||||
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
}
|
}
|
||||||
@@ -59,4 +93,14 @@ public static class TelegramSessionBatchRenderer
|
|||||||
|
|
||||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatDuration(int minutes)
|
||||||
|
{
|
||||||
|
if (minutes <= 0) return "0 мин";
|
||||||
|
var hours = minutes / 60;
|
||||||
|
var mins = minutes % 60;
|
||||||
|
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
|
||||||
|
if (hours > 0) return $"{hours} ч";
|
||||||
|
return $"{mins} мин";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Discord;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source-level structural smoke tests for the Discord wizard
|
||||||
|
/// interaction module. The NetCord component-interaction service
|
||||||
|
/// uses dispatch-by-attribute (no public registry), so a runtime
|
||||||
|
/// instantiation test would need to spin up the full NetCord host —
|
||||||
|
/// overkill for a smoke gate. Instead we assert on the source shape
|
||||||
|
/// (custom-id formats, handler method signatures, dispatcher wiring)
|
||||||
|
/// so the existing wizard tests catch regressions in the platform-
|
||||||
|
/// neutral state machine while this file catches regressions in the
|
||||||
|
/// Discord adapter shell.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DiscordWizardInteractionModuleSourceTests
|
||||||
|
{
|
||||||
|
private static string GetRepoRoot()
|
||||||
|
{
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||||
|
{
|
||||||
|
dir = Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadSource(string relativePath)
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var fullPath = Path.Combine(repoRoot, relativePath);
|
||||||
|
Assert.True(File.Exists(fullPath), $"Source file {relativePath} should exist.");
|
||||||
|
return File.ReadAllText(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Module_ShouldExist()
|
||||||
|
{
|
||||||
|
var path = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs";
|
||||||
|
var source = ReadSource(path);
|
||||||
|
Assert.Contains("public sealed class DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public sealed class DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public sealed class DiscordWizardModalModule", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public sealed class WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Modules_ShouldBeDerivedFromComponentInteractionModule()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// The dispatching modules are thin shells that inherit from
|
||||||
|
// NetCord's ComponentInteractionModule<TContext> for each
|
||||||
|
// supported component type.
|
||||||
|
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ComponentInteractionModule<StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ComponentInteractionModule<ModalInteractionContext>", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Modules_ShouldRegisterWizardComponentInteraction()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// All three modules use the [ComponentInteraction("wizard")]
|
||||||
|
// prefix registration; the args string carries the rest of
|
||||||
|
// the custom-id (e.g. "btn:choice:Type:single" or
|
||||||
|
// "select:Visibility" or "modal:Title").
|
||||||
|
var count = CountOccurrences(source, "[ComponentInteraction(\"wizard\")]");
|
||||||
|
Assert.Equal(3, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispatcher_ShouldHandleButtonSelectAndModal()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
Assert.Contains("public async Task HandleButtonAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public async Task HandleStringMenuAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public async Task HandleModalAsync", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispatcher_ShouldParseAllWizardActionKinds()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// The button handler must dispatch on the five action kinds
|
||||||
|
// the wizard's callback data format emits: choice, back,
|
||||||
|
// cancel, create. The resume flow is a wizard-internal control
|
||||||
|
// emitted by the slash command's "Continue / Start over" row.
|
||||||
|
Assert.Contains("\"choice\"", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"back\"", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"cancel\"", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"create\"", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"resume\"", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispatcher_ShouldWireWizardStateMachine()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// The dispatcher must call the shared GameCreationWizard's
|
||||||
|
// HandleInteractionAsync, which is the same entry point the
|
||||||
|
// Telegram bot uses. This is the core invariant of the
|
||||||
|
// platform-neutral refactor.
|
||||||
|
Assert.Contains("GameCreationWizard", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("HandleInteractionAsync", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispatcher_ShouldInvokeSubmitterOnCreate()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// The "create" callback must delegate to DiscordWizardSubmitter
|
||||||
|
// (the 3-retry finalize loop) rather than call the wizard's
|
||||||
|
// render path.
|
||||||
|
Assert.Contains("SubmitAsync", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_ShouldRegisterAllThreeComponentServices()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
||||||
|
// NetCord requires AddComponentInteractions<TInteraction, TContext>
|
||||||
|
// per supported interaction type. The wizard needs all three:
|
||||||
|
// buttons, StringSelectMenus, and modal submits.
|
||||||
|
Assert.Contains("AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("AddComponentInteractions<ModalInteraction, ModalInteractionContext>", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_ShouldRegisterWizardModuleClasses()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
||||||
|
// The wizard module classes have constructor dependencies
|
||||||
|
// (the dispatcher + shared services) that DI must resolve.
|
||||||
|
// AddComponentInteractions only registers the IComponentInteractionService,
|
||||||
|
// not the module classes themselves.
|
||||||
|
Assert.Contains("WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DiscordWizardModalModule", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispatcher_ShouldLookupDraftByOwner()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// All three handlers must look up the active draft by
|
||||||
|
// (platform="Discord", ownerId=userId) — the wizard's
|
||||||
|
// invariant is "one active draft per owner", not
|
||||||
|
// "draft-id-in-custom-id".
|
||||||
|
Assert.Contains("GetActiveAsync(\"Discord\"", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ModalHandler_ShouldExtractTextFromLabel()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// The wizard's modals wrap a single TextInput in a Label. The
|
||||||
|
// handler must walk Components[0] (Label) → .Component (TextInput)
|
||||||
|
// → .Value to retrieve the user's text. If this drifts the
|
||||||
|
// modal submit silently becomes a no-op.
|
||||||
|
Assert.Contains("Components[0]", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("TextInput", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(".Value", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StringMenuHandler_ShouldReadSelectedValues()
|
||||||
|
{
|
||||||
|
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||||
|
// StringSelectMenu interactions expose SelectedValues[0]
|
||||||
|
// for our MaxValues=1 menus.
|
||||||
|
Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Roundtrip the renderer output through the dispatcher's parser to
|
||||||
|
/// prove the wire formats agree. This is a real behavioural test
|
||||||
|
/// (not a string-grep) — it actually constructs the ButtonProperties
|
||||||
|
/// that NetCord would send, strips the [ComponentInteraction("wizard")]
|
||||||
|
/// prefix exactly as NetCord does, and asserts the dispatcher's
|
||||||
|
/// switch would route the click to the right branch. Catches the
|
||||||
|
/// class of "renderer and dispatcher disagree on the wire format"
|
||||||
|
/// regressions that the string-grep tests above cannot detect.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Renderer_And_Dispatcher_Agree_On_Wire_Format()
|
||||||
|
{
|
||||||
|
// Choice button: dispatcher expects `btn:choice:<step>:<value>`.
|
||||||
|
var choice = DiscordWizardStep.ChoiceButtonCustomId("Type", "single");
|
||||||
|
Assert.Equal("wizard:btn:choice:Type:single", choice);
|
||||||
|
var choiceArgs = StripWizardPrefix(choice);
|
||||||
|
var choiceParts = choiceArgs.Split(':', 4);
|
||||||
|
Assert.Equal("btn", choiceParts[0]);
|
||||||
|
Assert.Equal("choice", choiceParts[1]);
|
||||||
|
Assert.Equal("Type", choiceParts[2]);
|
||||||
|
Assert.Equal("single", choiceParts[3]);
|
||||||
|
|
||||||
|
// Control button: dispatcher expects `btn:<action>:1`.
|
||||||
|
var cancel = DiscordWizardStep.ControlButtonCustomId("cancel");
|
||||||
|
Assert.Equal("wizard:btn:cancel:1", cancel);
|
||||||
|
var cancelArgs = StripWizardPrefix(cancel);
|
||||||
|
var cancelParts = cancelArgs.Split(':', 3);
|
||||||
|
Assert.Equal("btn", cancelParts[0]);
|
||||||
|
Assert.Equal("cancel", cancelParts[1]);
|
||||||
|
|
||||||
|
// Modal trigger: dispatcher expects `btn:modal:<modalStep>`.
|
||||||
|
var modal = DiscordWizardStep.ModalTriggerButtonCustomId("SystemFreeText");
|
||||||
|
Assert.Equal("wizard:btn:modal:SystemFreeText", modal);
|
||||||
|
var modalArgs = StripWizardPrefix(modal);
|
||||||
|
var modalParts = modalArgs.Split(':', 3);
|
||||||
|
Assert.Equal("btn", modalParts[0]);
|
||||||
|
Assert.Equal("modal", modalParts[1]);
|
||||||
|
Assert.Equal("SystemFreeText", modalParts[2]);
|
||||||
|
|
||||||
|
// All customIds must fit Discord's 100-char limit.
|
||||||
|
Assert.All(
|
||||||
|
new[] { choice, cancel, modal },
|
||||||
|
cid => Assert.True(
|
||||||
|
cid.Length <= DiscordWizardStep.MaxCustomIdLength,
|
||||||
|
$"CustomId '{cid}' exceeds 100 chars: {cid.Length}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Create/Back/Cancel/Resume control buttons in the renderer
|
||||||
|
/// (and in BuildResumeRow) must emit the format the dispatcher's
|
||||||
|
/// switch matches directly — NOT the choice-button format. This
|
||||||
|
/// test parses every button's customId and asserts the dispatcher
|
||||||
|
/// would route it to the right branch.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ControlButtons_Are_Parsed_As_Control_Not_Choice()
|
||||||
|
{
|
||||||
|
// Real customIds the renderer / BuildResumeRow emit for control actions.
|
||||||
|
var controlIds = new[]
|
||||||
|
{
|
||||||
|
DiscordWizardStep.ControlButtonCustomId("back"),
|
||||||
|
DiscordWizardStep.ControlButtonCustomId("cancel"),
|
||||||
|
"wizard:btn:create:1",
|
||||||
|
"wizard:btn:resume:continue",
|
||||||
|
"wizard:btn:resume:restart",
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var cid in controlIds)
|
||||||
|
{
|
||||||
|
var parts = StripWizardPrefix(cid).Split(':', 3);
|
||||||
|
Assert.Equal("btn", parts[0]);
|
||||||
|
// The dispatcher's switch matches these as parts[1] == "back"|"cancel"|"create"|"resume".
|
||||||
|
// They must NOT be tagged as "choice" (that would route through the wizard
|
||||||
|
// with a nonsensical step name).
|
||||||
|
Assert.NotEqual("choice", parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirror NetCord's [ComponentInteraction("wizard")] prefix strip.</summary>
|
||||||
|
private static string StripWizardPrefix(string customId)
|
||||||
|
{
|
||||||
|
const string prefix = "wizard:";
|
||||||
|
return customId.StartsWith(prefix, StringComparison.Ordinal) ? customId[prefix.Length..] : customId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountOccurrences(string haystack, string needle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(needle)) return 0;
|
||||||
|
var count = 0;
|
||||||
|
var idx = 0;
|
||||||
|
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
idx += needle.Length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using NetCord.Rest;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
|
||||||
|
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
|
||||||
|
/// a session with no player cap (null in <c>sessions.max_players</c>), the
|
||||||
|
/// same affordance the Telegram wizard provides.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DiscordWizardStepCapacityRenderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RenderCapacity_ContainsNoLimitButton()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||||
|
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||||
|
|
||||||
|
var labels = ExtractButtonLabels(render);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
|
||||||
|
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||||
|
|
||||||
|
var labels = ExtractButtonLabels(render);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WizardStepNames.Capacity, "wizard:btn:choice:Capacity:no_limit")]
|
||||||
|
[InlineData(WizardStepNames.PoolSlotCapacity, "wizard:btn:choice:PoolSlotCapacity:no_limit")]
|
||||||
|
public void Render_NoLimitButton_HasChoiceCustomIdForNoLimit(string step, string expectedCustomIdPrefix)
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft { Step = step };
|
||||||
|
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||||
|
|
||||||
|
var buttons = ExtractButtons(render);
|
||||||
|
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
|
||||||
|
Assert.NotNull(noLimit);
|
||||||
|
Assert.StartsWith(expectedCustomIdPrefix, noLimit!.CustomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Collections.Generic.List<string> ExtractButtonLabels(
|
||||||
|
DiscordWizardStep.DiscordWizardRender render) =>
|
||||||
|
render.Components
|
||||||
|
.OfType<ActionRowProperties>()
|
||||||
|
.SelectMany(r => r.Components)
|
||||||
|
.OfType<ButtonProperties>()
|
||||||
|
.Select(b => b.Label ?? string.Empty)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||||
|
DiscordWizardStep.DiscordWizardRender render) =>
|
||||||
|
render.Components
|
||||||
|
.OfType<ActionRowProperties>()
|
||||||
|
.SelectMany(r => r.Components)
|
||||||
|
.OfType<ButtonProperties>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
|
||||||
|
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
|
||||||
|
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
|
||||||
|
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
|
||||||
|
/// <c>0</c>. <c>0</c> would violate the DB CHECK
|
||||||
|
/// <c>ck_sessions_max_players</c> in V006 and the contract that
|
||||||
|
/// <c>null</c> means "no limit".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DiscordWizardSubmitterBuildCommandTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Null(cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal(5, cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
using Testcontainers.PostgreSql;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture<CreateSessionHandlerPostgresFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "Create session handler PostgreSQL";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||||
|
|
||||||
|
public Task InitializeAsync()
|
||||||
|
{
|
||||||
|
return container.StartAsync().WaitAsync(ContainerTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync()
|
||||||
|
{
|
||||||
|
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateMigratedDatabaseAsync()
|
||||||
|
{
|
||||||
|
var databaseName = $"create_session_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
|
||||||
|
{
|
||||||
|
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||||
|
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
|
||||||
|
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
|
||||||
|
{
|
||||||
|
Database = databaseName,
|
||||||
|
Timeout = 10,
|
||||||
|
CommandTimeout = 30
|
||||||
|
}.ConnectionString;
|
||||||
|
|
||||||
|
await using var connection = new NpgsqlConnection(connectionString);
|
||||||
|
await connection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||||
|
|
||||||
|
foreach (var migration in GetMigrationPaths())
|
||||||
|
{
|
||||||
|
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
|
||||||
|
{
|
||||||
|
CommandTimeout = 30
|
||||||
|
};
|
||||||
|
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> GetMigrationPaths()
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
|
||||||
|
if (Directory.Exists(migrationsDirectory))
|
||||||
|
{
|
||||||
|
return Directory.GetFiles(migrationsDirectory, "V*.sql")
|
||||||
|
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||||
|
public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPostgresFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_NewPlatformGroup_AddsOwnerAndPersistsSession()
|
||||||
|
{
|
||||||
|
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
var sut = new CreateSessionHandler(dataSource);
|
||||||
|
|
||||||
|
var result = await sut.HandleAsync(
|
||||||
|
new CreateSessionCommand(
|
||||||
|
new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"),
|
||||||
|
new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"),
|
||||||
|
"Test Adventure",
|
||||||
|
"https://vtt.example/game",
|
||||||
|
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
GameSystem.Dnd5e,
|
||||||
|
"Integration regression test",
|
||||||
|
"Online",
|
||||||
|
240,
|
||||||
|
true,
|
||||||
|
"Online room notes"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Success, result.ErrorMessage);
|
||||||
|
Assert.NotNull(result.BatchId);
|
||||||
|
Assert.NotNull(result.GroupId);
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = @group_id
|
||||||
|
AND gm.role = 'Owner'
|
||||||
|
AND p.platform = 'Telegram'
|
||||||
|
AND p.external_user_id = '111111111'
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("group_id", result.GroupId.Value);
|
||||||
|
|
||||||
|
var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
|
||||||
|
Assert.Equal(1, ownerCount);
|
||||||
|
|
||||||
|
await using var sessionCommand = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT join_link, format, location_address
|
||||||
|
FROM sessions
|
||||||
|
WHERE batch_id = @batch_id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
sessionCommand.Parameters.AddWithValue("batch_id", result.BatchId.Value);
|
||||||
|
|
||||||
|
await using var reader = await sessionCommand.ExecuteReaderAsync();
|
||||||
|
Assert.True(await reader.ReadAsync());
|
||||||
|
Assert.Equal("https://vtt.example/game", reader.GetString(0));
|
||||||
|
Assert.Equal("Online", reader.GetString(1));
|
||||||
|
Assert.Equal("Online room notes", reader.GetString(2));
|
||||||
|
Assert.False(await reader.ReadAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_OfflineSession_PersistsFormatAndLocationAddress()
|
||||||
|
{
|
||||||
|
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
var sut = new CreateSessionHandler(dataSource);
|
||||||
|
|
||||||
|
var result = await sut.HandleAsync(
|
||||||
|
new CreateSessionCommand(
|
||||||
|
new PlatformUser(PlatformKind.Telegram, "333333333", "Offline GM", "offline_gm"),
|
||||||
|
new PlatformGroup(PlatformKind.Telegram, "444444444", "Offline Group"),
|
||||||
|
"Offline Adventure",
|
||||||
|
string.Empty,
|
||||||
|
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||||
|
4,
|
||||||
|
null,
|
||||||
|
GameSystem.Dnd5e,
|
||||||
|
"Offline integration regression test",
|
||||||
|
"Offline",
|
||||||
|
240,
|
||||||
|
true,
|
||||||
|
"Москва, ул. Кубиков, 12"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Success, result.ErrorMessage);
|
||||||
|
Assert.NotNull(result.BatchId);
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT join_link, format, location_address
|
||||||
|
FROM sessions
|
||||||
|
WHERE batch_id = @batch_id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("batch_id", result.BatchId.Value);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync();
|
||||||
|
Assert.True(await reader.ReadAsync());
|
||||||
|
Assert.Equal(string.Empty, reader.GetString(0));
|
||||||
|
Assert.Equal("Offline", reader.GetString(1));
|
||||||
|
Assert.Equal("Москва, ул. Кубиков, 12", reader.GetString(2));
|
||||||
|
Assert.False(await reader.ReadAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
|
||||||
|
/// when the wizard payload carries no player limit (e.g. user picked
|
||||||
|
/// «♾ Без лимита» on the Capacity step), the resulting
|
||||||
|
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
|
||||||
|
/// <c>0</c> would violate the DB CHECK constraint
|
||||||
|
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
|
||||||
|
/// <c>NULL</c>, which is the wire-level representation of "no limit".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateSessionHandlerBuildCommandTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Null(cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal(5, cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenFormatIsOnline_PropagatesFormatAndJoinLink()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal("Online", cmd.Format);
|
||||||
|
Assert.Equal("https://vtt.example/game", cmd.Link);
|
||||||
|
Assert.Null(cmd.LocationAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenFormatIsOffline_PropagatesFormatAndAddress()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Format = WizardSessionFormat.Offline,
|
||||||
|
LocationAddress = "Москва, ул. Кубиков, 12",
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal("Offline", cmd.Format);
|
||||||
|
Assert.Equal(string.Empty, cmd.Link);
|
||||||
|
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
@@ -39,7 +38,7 @@ public sealed class CreateSessionHandlerSubmitMissingFieldsTests
|
|||||||
// The wizard message is edited to surface the missing-field error.
|
// The wizard message is edited to surface the missing-field error.
|
||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
var edit = messenger.Edits[0];
|
var edit = messenger.Edits[0];
|
||||||
Assert.Equal(draft.ChatId, edit.ChatId);
|
Assert.Equal(long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture), edit.ChatId);
|
||||||
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-14
@@ -1,19 +1,107 @@
|
|||||||
using System;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using Xunit;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Npgsql;
|
||||||
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||||
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
|
public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture)
|
||||||
/// on a single-game wizard payload. The success path calls the shared
|
|
||||||
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
|
|
||||||
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
|
|
||||||
/// sessions, and related tables). The missing-fields and validation
|
|
||||||
/// branches are covered by the dedicated tests in this folder.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateSessionHandlerSubmitSingleDraftTests
|
|
||||||
{
|
{
|
||||||
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
|
[Fact]
|
||||||
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
|
public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs()
|
||||||
throw new NotImplementedException("See Skip reason above.");
|
{
|
||||||
|
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
var drafts = new FakeWizardDraftRepository();
|
||||||
|
var wizardMessenger = new FakeWizardMessenger();
|
||||||
|
var platformMessenger = new FakePlatformMessenger();
|
||||||
|
|
||||||
|
var sut = new CreateSessionHandler(
|
||||||
|
drafts,
|
||||||
|
new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler(dataSource),
|
||||||
|
wizardMessenger,
|
||||||
|
NullLogger<CreateSessionHandler>.Instance,
|
||||||
|
platformMessenger,
|
||||||
|
dataSource);
|
||||||
|
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "Тест публикации",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var draft = NewDraft(WizardStepNames.Confirm, payload, ownerId: 111111111);
|
||||||
|
draft.ChatId = "-1003916537960";
|
||||||
|
draft.DraftMessageId = "7";
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(platformMessenger.CreatedThreads);
|
||||||
|
Assert.Equal("Тест публикации", platformMessenger.CreatedThreads[0].Title);
|
||||||
|
Assert.Single(platformMessenger.SentSchedules);
|
||||||
|
Assert.Equal("456", platformMessenger.SentSchedules[0].Group.ExternalThreadId);
|
||||||
|
Assert.Contains(draft.Id, drafts.DeletedIds);
|
||||||
|
Assert.Contains(wizardMessenger.Edits, edit => edit.Text.Contains("✅ Создано: 1 сессия", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT thread_id, batch_message_id, topic_created_by_bot
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync();
|
||||||
|
Assert.True(await reader.ReadAsync());
|
||||||
|
Assert.Equal(456, reader.GetInt32(0));
|
||||||
|
Assert.Equal(789, reader.GetInt32(1));
|
||||||
|
Assert.True(reader.GetBoolean(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakePlatformMessenger : IPlatformMessenger
|
||||||
|
{
|
||||||
|
public List<(PlatformGroup Group, string Title)> CreatedThreads { get; } = new();
|
||||||
|
|
||||||
|
public List<PlatformScheduleMessage> SentSchedules { get; } = new();
|
||||||
|
|
||||||
|
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||||
|
{
|
||||||
|
CreatedThreads.Add((group, title));
|
||||||
|
return Task.FromResult(new PlatformMessageRef(group.Platform, group.ExternalGroupId, "456", string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
SentSchedules.Add(message);
|
||||||
|
return Task.FromResult(new PlatformMessageRef(
|
||||||
|
message.Group.Platform,
|
||||||
|
message.Group.ExternalGroupId,
|
||||||
|
message.Group.ExternalThreadId,
|
||||||
|
"789"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-1
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
@@ -37,6 +36,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Title = "T",
|
Title = "T",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Single = new WizardSingleInput
|
Single = new WizardSingleInput
|
||||||
{
|
{
|
||||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||||
@@ -70,6 +71,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Type = WizardCreationType.Single,
|
Type = WizardCreationType.Single,
|
||||||
Title = "T",
|
Title = "T",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Visibility = WizardVisibility.Public,
|
Visibility = WizardVisibility.Public,
|
||||||
Single = new WizardSingleInput
|
Single = new WizardSingleInput
|
||||||
{
|
{
|
||||||
@@ -105,6 +108,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Title = "T",
|
Title = "T",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Visibility = WizardVisibility.Public,
|
Visibility = WizardVisibility.Public,
|
||||||
Single = new WizardSingleInput { MaxPlayers = 4 },
|
Single = new WizardSingleInput { MaxPlayers = 4 },
|
||||||
};
|
};
|
||||||
@@ -136,6 +141,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Title = "P",
|
Title = "P",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Visibility = WizardVisibility.Public,
|
Visibility = WizardVisibility.Public,
|
||||||
Pool = new WizardPoolInput(),
|
Pool = new WizardPoolInput(),
|
||||||
};
|
};
|
||||||
@@ -147,4 +154,49 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitDraftAsync_SingleWithNoLimit_DoesNotReportMaxPlayersAsMissing()
|
||||||
|
{
|
||||||
|
// Regression for #131: pressing "♾ Без лимита" sets MaxPlayers = null.
|
||||||
|
// IsComplete must NOT flag that as a missing field; null means
|
||||||
|
// "no player limit" and is a valid final state.
|
||||||
|
var drafts = new FakeWizardDraftRepository();
|
||||||
|
var messenger = new FakeWizardMessenger();
|
||||||
|
|
||||||
|
var sut = new CreateSessionHandler(
|
||||||
|
drafts,
|
||||||
|
shared: null!,
|
||||||
|
messenger,
|
||||||
|
NullLogger<CreateSessionHandler>.Instance);
|
||||||
|
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var draft = NewDraft(WizardStepNames.Confirm, payload);
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
|
// Validation must let the no-limit payload through. The shared
|
||||||
|
// handler is null, so anything that reached the database call would
|
||||||
|
// throw a NullReferenceException — that is caught by the retry
|
||||||
|
// path and reported as a "💥 Ошибка:" edit, not a missing-fields
|
||||||
|
// edit. Therefore we assert that NO edit mentions a missing field.
|
||||||
|
Assert.NotEmpty(messenger.Edits);
|
||||||
|
var lastEdit = messenger.Edits[^1].Text;
|
||||||
|
Assert.DoesNotContain("Не заполнены", lastEdit, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Cancel();
|
var data = WizardCallbackData.Cancel();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Contains(draft.Id, drafts.DeletedIds);
|
Assert.Contains(draft.Id, drafts.DeletedIds);
|
||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
@@ -36,7 +35,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
// Title is the first step, so Back is a no-op.
|
// Title is the first step, so Back is a no-op.
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
@@ -51,7 +50,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Description, draft.Step);
|
Assert.Equal(WizardStepNames.Description, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -79,23 +78,30 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
|
public async Task Back_FromPoolAddSlots_GoesToVisibility()
|
||||||
{
|
{
|
||||||
var wizard = BuildWizard(out var drafts, out _);
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var draft = NewDraft(WizardStepNames.PoolAddSlots,
|
var draft = NewDraft(WizardStepNames.PoolAddSlots,
|
||||||
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
|
new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Pool,
|
||||||
|
Title = "Pool",
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
});
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -108,7 +114,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Create();
|
var data = WizardCallbackData.Create();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Confirm, draft.Step);
|
Assert.Equal(WizardStepNames.Confirm, draft.Step);
|
||||||
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
|
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
|
||||||
|
|||||||
+13
-14
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
@@ -7,8 +6,8 @@ using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTest
|
|||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the pool-specific branch of the wizard: the AddSlots flow that
|
/// Verifies the pool-specific branch of the wizard: the AddSlots flow
|
||||||
/// builds up slot metadata through date and capacity steps.
|
/// that builds up slot metadata through date and capacity steps.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GameCreationWizardPoolSlotTests
|
public sealed class GameCreationWizardPoolSlotTests
|
||||||
{
|
{
|
||||||
@@ -28,7 +27,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
|
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(addData, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -49,7 +48,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
|
|
||||||
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
|
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
|
||||||
var dtString = future.ToString("dd.MM.yyyy HH:mm");
|
var dtString = future.ToString("dd.MM.yyyy HH:mm");
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(dtString, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
|
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -61,7 +60,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
|
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -74,7 +73,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
|
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(noWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -87,7 +86,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
|
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(yesWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -107,7 +106,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -150,10 +149,10 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
// "add" then "done" — no date/capacity supplied in between.
|
// "add" then "done" — no date/capacity supplied in between.
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(
|
await wizard.HandleInteractionAsync(CallbackInteraction(
|
||||||
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None);
|
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(
|
await wizard.HandleInteractionAsync(CallbackInteraction(
|
||||||
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None);
|
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
// The wizard sees the in-memory slot count > 0 and advances to confirm.
|
// The wizard sees the in-memory slot count > 0 and advances to confirm.
|
||||||
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
||||||
|
|||||||
+129
-26
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
@@ -21,9 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
||||||
// Duration → DateTime (single, no maxPlayers yet)
|
// Duration → DateTime (single, no maxPlayers yet)
|
||||||
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
||||||
// Capacity → Visibility
|
// Capacity → Format (only explicit no-limit can skip numeric capacity)
|
||||||
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
|
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
|
||||||
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
|
||||||
// Visibility → Publish (public, no club)
|
// Visibility → Publish (public, no club)
|
||||||
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
||||||
// Visibility → PickClub
|
// Visibility → PickClub
|
||||||
@@ -41,14 +39,14 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(fromStep, choice);
|
var data = WizardCallbackData.Choice(fromStep, choice);
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(expectedStep, draft.Step);
|
Assert.Equal(expectedStep, draft.Step);
|
||||||
Assert.NotEmpty(drafts.Upserts); // was persisted
|
Assert.NotEmpty(drafts.Upserts); // was persisted
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
|
public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat()
|
||||||
{
|
{
|
||||||
var wizard = BuildWizard(out var drafts, out _);
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var payload = new WizardPayload
|
var payload = new WizardPayload
|
||||||
@@ -60,9 +58,9 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
Assert.Equal(WizardStepNames.Format, draft.Step);
|
||||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
Assert.True(root.TryGetProperty("system", out var sys));
|
Assert.True(root.TryGetProperty("system", out var sys));
|
||||||
@@ -72,31 +70,123 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
|
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||||
{
|
{
|
||||||
// The wizard's callback parser uses the step encoded in the callback
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
// (not the draft's current step) to drive transitions. So a stale
|
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
|
||||||
// "Capacity" button pressed while the user is on System will in fact
|
drafts.Seed(draft);
|
||||||
// move the draft forward as if they had pressed it on Capacity. We
|
|
||||||
// lock that behaviour in.
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Format, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("single", out var single));
|
||||||
|
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
|
||||||
|
// поэтому null-MaxPlayers просто не пишется. Оба варианта
|
||||||
|
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
|
||||||
|
// и уйдут в БД как NULL — то есть «без лимита».
|
||||||
|
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
maxPlayers.ValueKind == JsonValueKind.Null,
|
||||||
|
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
|
||||||
|
{
|
||||||
|
// A stale waitlist button from Capacity must not move a draft forward
|
||||||
|
// unless MaxPlayers is already set. Otherwise users can reach Confirm
|
||||||
|
// with a missing capacity and get "Не заполнены поля: лимит мест".
|
||||||
var wizard = BuildWizard(out var drafts, out _);
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var draft = NewDraft(WizardStepNames.System);
|
var draft = NewDraft(WizardStepNames.System);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PickClub_ValidGuid_ReachesStableStep()
|
public async Task Format_OnlineChoice_AdvancesToLocationAndPersistsFormat()
|
||||||
{
|
{
|
||||||
// The wizard has a quirk: NextAfterVisibility is evaluated before
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
// SetClubId, so a single click leaves the draft still on PickClub.
|
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
||||||
// We assert that the wizard does NOT throw and the messenger is asked
|
drafts.Seed(draft);
|
||||||
// to re-render (i.e. the handler ran end-to-end).
|
|
||||||
var wizard = BuildWizard(out var drafts, out var messenger);
|
var data = WizardCallbackData.Choice(WizardStepNames.Format, "online");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Location, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("format", out var format));
|
||||||
|
Assert.Equal("Online", format.GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Format_OfflineChoice_AdvancesToLocationAndPersistsFormat()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
var data = WizardCallbackData.Choice(WizardStepNames.Format, "offline");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Location, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("format", out var format));
|
||||||
|
Assert.Equal("Offline", format.GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Location_TextForOnline_StoresJoinLinkAndAdvancesToVisibility()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var payload = PayloadForStep(WizardStepNames.Location);
|
||||||
|
payload.Format = WizardSessionFormat.Online;
|
||||||
|
var draft = NewDraft(WizardStepNames.Location, payload);
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await wizard.HandleInteractionAsync(TextInteraction("https://vtt.example/game", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("joinLink", out var joinLink));
|
||||||
|
Assert.Equal("https://vtt.example/game", joinLink.GetString());
|
||||||
|
Assert.False(root.TryGetProperty("locationAddress", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Location_TextForOffline_StoresAddressAndAdvancesToVisibility()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var payload = PayloadForStep(WizardStepNames.Location);
|
||||||
|
payload.Format = WizardSessionFormat.Offline;
|
||||||
|
var draft = NewDraft(WizardStepNames.Location, payload);
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await wizard.HandleInteractionAsync(TextInteraction("Москва, ул. Кубиков, 12", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("locationAddress", out var address));
|
||||||
|
Assert.Equal("Москва, ул. Кубиков, 12", address.GetString());
|
||||||
|
Assert.False(root.TryGetProperty("joinLink", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var clubId = Guid.NewGuid();
|
var clubId = Guid.NewGuid();
|
||||||
var payload = new WizardPayload
|
var payload = new WizardPayload
|
||||||
{
|
{
|
||||||
@@ -110,10 +200,13 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
|
Assert.Equal(WizardStepNames.Publish, draft.Step);
|
||||||
Assert.NotEmpty(messenger.Edits);
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("clubId", out var clubIdJson));
|
||||||
|
Assert.Equal(clubId, clubIdJson.GetGuid());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -132,7 +225,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
|
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PickClub, draft.Step);
|
Assert.Equal(WizardStepNames.PickClub, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -161,6 +254,16 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
Title = "T",
|
Title = "T",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
},
|
||||||
|
WizardStepNames.Format or WizardStepNames.Location => new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
|
||||||
},
|
},
|
||||||
WizardStepNames.PickClub => new WizardPayload
|
WizardStepNames.PickClub => new WizardPayload
|
||||||
{
|
{
|
||||||
|
|||||||
+35
-13
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Title);
|
var draft = NewDraft(WizardStepNames.Title);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -32,8 +31,8 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Title);
|
var draft = NewDraft(WizardStepNames.Title);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var tooLong = new string('a', WizardStep.MaxTitleLength + 1);
|
var tooLong = new string('a', WizardStepLimits.MaxTitleLength + 1);
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -53,7 +52,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
// 2020-01-01 is firmly in the past
|
// 2020-01-01 is firmly in the past
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.DateTime);
|
var draft = NewDraft(WizardStepNames.DateTime);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -83,7 +82,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Cover, payload);
|
var draft = NewDraft(WizardStepNames.Cover, payload);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -96,7 +95,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -109,7 +108,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,30 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
});
|
});
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("waitlist:on")]
|
||||||
|
[InlineData("waitlist:off")]
|
||||||
|
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var draft = NewDraft(WizardStepNames.Capacity,
|
||||||
|
new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
|
||||||
|
});
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -148,7 +170,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -161,7 +183,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -178,7 +200,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -30,7 +30,7 @@ public sealed class UpdateRouterDelegationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Title);
|
var draft = NewDraft(WizardStepNames.Title);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId);
|
var update = TextUpdate("Curse of Strahd", ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
await sut.RouteAsync(update, CancellationToken.None);
|
await sut.RouteAsync(update, CancellationToken.None);
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ public sealed class UpdateRouterDelegationTests
|
|||||||
// "wizard:cancel" — wizard owns the cancel callback. The router
|
// "wizard:cancel" — wizard owns the cancel callback. The router
|
||||||
// delegates control-callbacks (resume/reset) but lets the wizard
|
// delegates control-callbacks (resume/reset) but lets the wizard
|
||||||
// handle wizard:* callbacks.
|
// handle wizard:* callbacks.
|
||||||
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId);
|
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
await sut.RouteAsync(update, CancellationToken.None);
|
await sut.RouteAsync(update, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -36,8 +36,8 @@ public sealed class UpdateRouterResetsDraftOnStaleCommandTests
|
|||||||
Message = new Message
|
Message = new Message
|
||||||
{
|
{
|
||||||
Text = "/newsession",
|
Text = "/newsession",
|
||||||
Chat = new Chat { Id = draft.ChatId },
|
Chat = new Chat { Id = long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture) },
|
||||||
From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" },
|
From = new User { Id = long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture), FirstName = "GM" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression guard: WizardDraftRepository must not use the (CommandDefinition)
|
||||||
|
/// overload of Dapper. Dapper.AOT 1.0.48 only generates interceptors for the
|
||||||
|
/// (sql, object?) extension overloads; using CommandDefinition falls back to
|
||||||
|
/// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
|
||||||
|
/// throws PlatformNotSupportedException on NativeAOT (v3.9.0 wizard regression).
|
||||||
|
/// The v3.9.1 hotfix switched all four methods to the direct overload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WizardDraftRepositoryAotShapeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WizardDraftRepository_GetActiveAsync_ShouldNotUseCommandDefinition()
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(
|
||||||
|
repoRoot,
|
||||||
|
"src",
|
||||||
|
"GmRelay.Shared",
|
||||||
|
"Features",
|
||||||
|
"Sessions",
|
||||||
|
"CreateSession",
|
||||||
|
"Wizard",
|
||||||
|
"WizardDraftRepository.cs"));
|
||||||
|
|
||||||
|
var getActive = ExtractMethodBody(source, "GetActiveAsync", "");
|
||||||
|
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("UpsertAsync")]
|
||||||
|
[InlineData("DeleteAsync")]
|
||||||
|
[InlineData("DeleteExpiredAsync")]
|
||||||
|
public void WizardDraftRepository_MutatingMethods_ShouldNotUseCommandDefinition(string methodName)
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(
|
||||||
|
repoRoot,
|
||||||
|
"src",
|
||||||
|
"GmRelay.Shared",
|
||||||
|
"Features",
|
||||||
|
"Sessions",
|
||||||
|
"CreateSession",
|
||||||
|
"Wizard",
|
||||||
|
"WizardDraftRepository.cs"));
|
||||||
|
|
||||||
|
var body = ExtractMethodBody(source, methodName, "");
|
||||||
|
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WizardDraftRepository was the only AOT-fatal site in v3.9.0, but the
|
||||||
|
/// same pattern (CommandDefinition on a Dapper extension that the AOT
|
||||||
|
/// generator cannot reach) is repeated in 4 club-picker / permission
|
||||||
|
/// lookups across Telegram and Discord messengers. v3.9.2 hotfix
|
||||||
|
/// converted them all to the direct (sql, params) overload. Lock the
|
||||||
|
/// regression so the next refactor doesn't reintroduce it.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs", "GetOwnerClubsAsync", "")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs", "GetOwnerClubsAsync", "")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs", "LoadClubsAsync", "internal static class WizardClubLookup")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs", "LoadManagerUserIdsAsync", "")]
|
||||||
|
public void ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition(string relativePath, string methodName, string containingClass)
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
|
||||||
|
var body = ExtractMethodBody(source, methodName, containingClass);
|
||||||
|
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AOT RowFactory for WizardDraft expects to read timestamps as
|
||||||
|
/// <see cref="DateTime"/> (UTC). If a future refactor switches the
|
||||||
|
/// properties back to <see cref="DateTimeOffset"/>, the AOT row factory
|
||||||
|
/// will throw <c>InvalidCastException: DateTime → DateTimeOffset</c>
|
||||||
|
/// on the first query against wizard_drafts.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WizardDraft_TimestampsMustBeDateTime_NotDateTimeOffset()
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(
|
||||||
|
repoRoot,
|
||||||
|
"src",
|
||||||
|
"GmRelay.Shared",
|
||||||
|
"Features",
|
||||||
|
"Sessions",
|
||||||
|
"CreateSession",
|
||||||
|
"Wizard",
|
||||||
|
"WizardDraft.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("public DateTime CreatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public DateTime UpdatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public DateTime ExpiresAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("public DateTimeOffset CreatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("public DateTimeOffset UpdatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractMethodBody(string source, string methodName, string containingClass)
|
||||||
|
{
|
||||||
|
var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// If a containing class is given (non-empty), narrow the search
|
||||||
|
// to the first occurrence AFTER the class declaration. This is
|
||||||
|
// needed when the same method name is used as a call site
|
||||||
|
// elsewhere in the file.
|
||||||
|
if (!string.IsNullOrEmpty(containingClass))
|
||||||
|
{
|
||||||
|
var classIdx = source.IndexOf(containingClass, StringComparison.Ordinal);
|
||||||
|
if (classIdx < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate class {containingClass} in source.");
|
||||||
|
}
|
||||||
|
searchFrom = source.IndexOf(methodName, classIdx, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchFrom < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate {methodName} in source.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept any return type: `public async Task` (no result) or
|
||||||
|
// `public async Task<int>` (with result). Search for the keyword
|
||||||
|
// "Task" in a 60-char window before the method name so we also
|
||||||
|
// pick up `public static async Task<IReadOnlyList<ulong>>`.
|
||||||
|
var windowStart = Math.Max(0, searchFrom - 60);
|
||||||
|
var idx = source.IndexOf("Task", windowStart, StringComparison.Ordinal);
|
||||||
|
if (idx < 0 || idx >= searchFrom)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate {methodName} declaration in source.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var braceStart = source.IndexOf('{', idx);
|
||||||
|
if (braceStart < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate body opening brace for {methodName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var depth = 1;
|
||||||
|
var pos = braceStart + 1;
|
||||||
|
while (pos < source.Length && depth > 0)
|
||||||
|
{
|
||||||
|
switch (source[pos])
|
||||||
|
{
|
||||||
|
case '{': depth++; break;
|
||||||
|
case '}': depth--; break;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.Substring(braceStart, pos - braceStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "Directory.Build.props")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("Could not locate repository root.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-8
@@ -11,7 +11,7 @@ public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardD
|
|||||||
|
|
||||||
public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
|
public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||||
|
|
||||||
public Task InitializeAsync()
|
public Task InitializeAsync()
|
||||||
@@ -49,22 +49,23 @@ public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
|
|||||||
"""
|
"""
|
||||||
CREATE TABLE wizard_drafts (
|
CREATE TABLE wizard_drafts (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
chat_id BIGINT NOT NULL,
|
chat_id TEXT NOT NULL,
|
||||||
message_thread_id INT,
|
message_thread_id TEXT,
|
||||||
owner_telegram_id BIGINT NOT NULL,
|
owner_id TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'Telegram',
|
||||||
step TEXT NOT NULL,
|
step TEXT NOT NULL,
|
||||||
payload JSONB NOT NULL,
|
payload JSONB NOT NULL,
|
||||||
draft_message_id BIGINT,
|
draft_message_id TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL,
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
expires_at TIMESTAMPTZ NOT NULL
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_wizard_drafts_owner
|
CREATE INDEX idx_wizard_drafts_owner
|
||||||
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
|
ON wizard_drafts(platform, owner_id);
|
||||||
|
|
||||||
CREATE INDEX idx_wizard_drafts_expires
|
CREATE INDEX idx_wizard_drafts_platform
|
||||||
ON wizard_drafts(expires_at);
|
ON wizard_drafts(platform);
|
||||||
""",
|
""",
|
||||||
connection);
|
connection);
|
||||||
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||||
|
|||||||
+21
-15
@@ -1,3 +1,6 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
@@ -13,14 +16,14 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
var sut = new WizardDraftRepository(dataSource);
|
||||||
|
|
||||||
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
|
var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1));
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
draft.Step = "Title";
|
draft.Step = "Title";
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
draft.UpdatedAt = DateTime.UtcNow.AddSeconds(1);
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
|
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
|
||||||
Assert.NotNull(loaded);
|
Assert.NotNull(loaded);
|
||||||
Assert.Equal("Title", loaded!.Step);
|
Assert.Equal("Title", loaded!.Step);
|
||||||
}
|
}
|
||||||
@@ -32,10 +35,10 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
var sut = new WizardDraftRepository(dataSource);
|
||||||
|
|
||||||
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
|
var draft = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
|
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
|
||||||
Assert.Null(loaded);
|
Assert.Null(loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +49,12 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
var sut = new WizardDraftRepository(dataSource);
|
||||||
|
|
||||||
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
|
var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1));
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, ownerTelegramId: draft.OwnerTelegramId + 1, CancellationToken.None);
|
var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1)
|
||||||
|
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var loaded = await sut.GetActiveAsync(draft.Platform, otherOwner, CancellationToken.None);
|
||||||
Assert.Null(loaded);
|
Assert.Null(loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +65,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
var sut = new WizardDraftRepository(dataSource);
|
||||||
|
|
||||||
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
|
var fresh = NewDraft("Type", DateTime.UtcNow.AddHours(1));
|
||||||
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
|
var stale = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
|
||||||
stale.Id = Guid.NewGuid();
|
stale.Id = Guid.NewGuid();
|
||||||
await sut.UpsertAsync(fresh, CancellationToken.None);
|
await sut.UpsertAsync(fresh, CancellationToken.None);
|
||||||
await sut.UpsertAsync(stale, CancellationToken.None);
|
await sut.UpsertAsync(stale, CancellationToken.None);
|
||||||
@@ -69,20 +74,21 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
|
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
|
||||||
Assert.Equal(1, deleted);
|
Assert.Equal(1, deleted);
|
||||||
|
|
||||||
var loadedFresh = await sut.GetActiveAsync(fresh.ChatId, fresh.MessageThreadId, fresh.OwnerTelegramId, CancellationToken.None);
|
var loadedFresh = await sut.GetActiveAsync(fresh.Platform, fresh.OwnerId, CancellationToken.None);
|
||||||
Assert.NotNull(loadedFresh);
|
Assert.NotNull(loadedFresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
|
private static WizardDraft NewDraft(string step, DateTime expiresAt) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = 42,
|
ChatId = "42",
|
||||||
MessageThreadId = null,
|
MessageThreadId = null,
|
||||||
OwnerTelegramId = 100,
|
OwnerId = "100",
|
||||||
|
Platform = "Telegram",
|
||||||
Step = step,
|
Step = step,
|
||||||
PayloadJson = "{}",
|
PayloadJson = "{}",
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ExpiresAt = expiresAt,
|
ExpiresAt = expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the Telegram <c>Update</c> → <c>WizardInteraction</c> mapping
|
||||||
|
/// that <see cref="WizardInteractionMapper"/> exposes. The mapper is the
|
||||||
|
/// single bridge between Telegram's native update type and the
|
||||||
|
/// platform-neutral wizard core, so its contract needs to be locked
|
||||||
|
/// down: callback queries carry the data payload, text messages carry
|
||||||
|
/// their text, and photos carry the largest photo's <c>FileId</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WizardInteractionMapperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CallbackUpdate_ProducesCallbackInteraction_WithPayloadAndOwner()
|
||||||
|
{
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
CallbackQuery = new CallbackQuery
|
||||||
|
{
|
||||||
|
Id = "cb-42",
|
||||||
|
Data = "wizard:choice:Type:single",
|
||||||
|
From = new User { Id = 100, FirstName = "GM" },
|
||||||
|
Message = new Message { Chat = new Chat { Id = 42 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("100", interaction.OwnerId);
|
||||||
|
Assert.Null(interaction.Text);
|
||||||
|
Assert.Equal("wizard:choice:Type:single", interaction.CallbackPayload);
|
||||||
|
Assert.Null(interaction.PhotoFileId);
|
||||||
|
Assert.Null(interaction.PhotoUrl);
|
||||||
|
Assert.Equal("cb-42", interaction.InteractionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TextUpdate_ProducesTextInteraction_WithTextAndNoCallback()
|
||||||
|
{
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
Text = "My Game Title",
|
||||||
|
Chat = new Chat { Id = 42 },
|
||||||
|
From = new User { Id = 200, FirstName = "GM" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("200", interaction.OwnerId);
|
||||||
|
Assert.Equal("My Game Title", interaction.Text);
|
||||||
|
Assert.Null(interaction.CallbackPayload);
|
||||||
|
Assert.Null(interaction.PhotoFileId);
|
||||||
|
Assert.Equal("msg", interaction.InteractionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PhotoUpdate_ProducesPhotoInteraction_WithLargestFileId()
|
||||||
|
{
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
Chat = new Chat { Id = 42 },
|
||||||
|
From = new User { Id = 300, FirstName = "GM" },
|
||||||
|
Photo = new[]
|
||||||
|
{
|
||||||
|
new PhotoSize { FileId = "small-id", Width = 90, Height = 60 },
|
||||||
|
new PhotoSize { FileId = "medium-id", Width = 320, Height = 240 },
|
||||||
|
new PhotoSize { FileId = "large-id", Width = 800, Height = 600 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("300", interaction.OwnerId);
|
||||||
|
Assert.Null(interaction.Text);
|
||||||
|
Assert.Null(interaction.CallbackPayload);
|
||||||
|
Assert.Equal("large-id", interaction.PhotoFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CaptionedPhoto_ProducesPhotoInteraction_AndKeepsCaptionOutOfText()
|
||||||
|
{
|
||||||
|
// Telegram sometimes attaches a caption to a photo message. The
|
||||||
|
// mapper treats it as a non-text interaction (cover-step uses
|
||||||
|
// PhotoFileId, not caption). This test pins that distinction.
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
Caption = "ignored",
|
||||||
|
Chat = new Chat { Id = 42 },
|
||||||
|
From = new User { Id = 400 },
|
||||||
|
Photo = new[]
|
||||||
|
{
|
||||||
|
new PhotoSize { FileId = "only-id", Width = 100, Height = 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("only-id", interaction.PhotoFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyUpdate_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var ok = WizardInteractionMapper.TryMap(new Update(), out var interaction);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(interaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-1
@@ -76,6 +76,40 @@ public sealed class WizardStepRenderTests
|
|||||||
var labels = ButtonLabels(kb);
|
var labels = ButtonLabels(kb);
|
||||||
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
|
||||||
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatStep_HasOnlineAndOfflineButtons()
|
||||||
|
{
|
||||||
|
var (text, kb) = Render(WizardStepNames.Format);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||||
|
var labels = ButtonLabels(kb);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Online", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Offline", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LocationStep_ForOnline_AsksForLink()
|
||||||
|
{
|
||||||
|
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Online });
|
||||||
|
|
||||||
|
Assert.Contains("ссыл", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var labels = ButtonLabels(kb);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LocationStep_ForOffline_AsksForAddress()
|
||||||
|
{
|
||||||
|
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Offline });
|
||||||
|
|
||||||
|
Assert.Contains("адрес", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var labels = ButtonLabels(kb);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -134,10 +168,14 @@ public sealed class WizardStepRenderTests
|
|||||||
{
|
{
|
||||||
Type = WizardCreationType.Single,
|
Type = WizardCreationType.Single,
|
||||||
Title = "My Game",
|
Title = "My Game",
|
||||||
|
Format = WizardSessionFormat.Offline,
|
||||||
|
LocationAddress = "Москва, ул. Кубиков, 12",
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||||
Assert.Contains("My Game", text);
|
Assert.Contains("My Game", text);
|
||||||
|
Assert.Contains("Offline", text);
|
||||||
|
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||||
var labels = ButtonLabels(kb);
|
var labels = ButtonLabels(kb);
|
||||||
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
|
||||||
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||||
@@ -248,7 +286,7 @@ public sealed class WizardStepRenderTests
|
|||||||
private static WizardDraft NewDraft(string step) => new()
|
private static WizardDraft NewDraft(string step) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = 42,
|
ChatId = "42",
|
||||||
Step = step,
|
Step = step,
|
||||||
PayloadJson = "{}",
|
PayloadJson = "{}",
|
||||||
};
|
};
|
||||||
|
|||||||
+115
-41
@@ -1,25 +1,26 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
using WizardBot = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||||
using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hand-rolled test doubles and helpers for wizard unit tests. The project
|
/// Hand-rolled test doubles and helpers for wizard unit tests. The
|
||||||
/// convention is to use fakes (not a mocking framework) so the suite stays
|
/// project convention is to use fakes (not a mocking framework) so the
|
||||||
/// AOT-friendly and the production code doesn't grow virtual members just
|
/// suite stays AOT-friendly and the production code doesn't grow
|
||||||
/// for tests.
|
/// virtual members just for tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class WizardTestFakes
|
internal static class WizardTestFakes
|
||||||
{
|
{
|
||||||
|
public const string PlatformName = "Telegram";
|
||||||
|
|
||||||
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
|
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
|
||||||
{
|
{
|
||||||
drafts = new FakeWizardDraftRepository();
|
drafts = new FakeWizardDraftRepository();
|
||||||
@@ -30,19 +31,77 @@ internal static class WizardTestFakes
|
|||||||
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
|
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = 42,
|
ChatId = "42",
|
||||||
MessageThreadId = null,
|
MessageThreadId = null,
|
||||||
OwnerTelegramId = ownerId,
|
OwnerId = ownerId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Platform = PlatformName,
|
||||||
Step = step,
|
Step = step,
|
||||||
DraftMessageId = 7,
|
DraftMessageId = "7",
|
||||||
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||||
payload ?? new WizardPayload(),
|
payload ?? new WizardPayload(),
|
||||||
WizardPayloadJsonContext.Default.WizardPayload),
|
WizardPayloadJsonContext.Default.WizardPayload),
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the platform-neutral <see cref="WizardInteraction"/> the
|
||||||
|
/// wizard now consumes. Pre-V112 callers passed
|
||||||
|
/// <c>Telegram.Bot.Types.Update</c> directly; tests now build the
|
||||||
|
/// neutral interaction via the same mapper the production code uses.
|
||||||
|
/// </summary>
|
||||||
|
public static WizardInteraction CallbackInteraction(
|
||||||
|
string data, string ownerId = "100", string callbackId = "cb-1")
|
||||||
|
{
|
||||||
|
return new WizardInteraction(
|
||||||
|
OwnerId: ownerId,
|
||||||
|
Text: null,
|
||||||
|
CallbackPayload: data,
|
||||||
|
PhotoFileId: null,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: callbackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a text-style <see cref="WizardInteraction"/> mirroring what
|
||||||
|
/// <c>WizardInteractionMapper</c> would produce for a Telegram text
|
||||||
|
/// message.
|
||||||
|
/// </summary>
|
||||||
|
public static WizardInteraction TextInteraction(
|
||||||
|
string text, string ownerId = "100", int messageId = 1)
|
||||||
|
{
|
||||||
|
return new WizardInteraction(
|
||||||
|
OwnerId: ownerId,
|
||||||
|
Text: text,
|
||||||
|
CallbackPayload: null,
|
||||||
|
PhotoFileId: null,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: $"msg-{messageId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a photo-style <see cref="WizardInteraction"/> mirroring
|
||||||
|
/// what <c>WizardInteractionMapper</c> would produce for a Telegram
|
||||||
|
/// photo message.
|
||||||
|
/// </summary>
|
||||||
|
public static WizardInteraction PhotoInteraction(
|
||||||
|
string fileId, string ownerId = "100", int messageId = 1)
|
||||||
|
{
|
||||||
|
return new WizardInteraction(
|
||||||
|
OwnerId: ownerId,
|
||||||
|
Text: null,
|
||||||
|
CallbackPayload: null,
|
||||||
|
PhotoFileId: fileId,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: $"msg-{messageId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a Telegram <see cref="Update"/> carrying a callback query.
|
||||||
|
/// Used by router-level tests that exercise
|
||||||
|
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
|
||||||
|
/// </summary>
|
||||||
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
|
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
|
||||||
{
|
{
|
||||||
CallbackQuery = new CallbackQuery
|
CallbackQuery = new CallbackQuery
|
||||||
@@ -57,6 +116,11 @@ internal static class WizardTestFakes
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a Telegram <see cref="Update"/> carrying a text message.
|
||||||
|
/// Used by router-level tests that exercise
|
||||||
|
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
|
||||||
|
/// </summary>
|
||||||
public static Update TextUpdate(string text, long ownerId = 100) => new()
|
public static Update TextUpdate(string text, long ownerId = 100) => new()
|
||||||
{
|
{
|
||||||
Message = new Message
|
Message = new Message
|
||||||
@@ -69,9 +133,9 @@ internal static class WizardTestFakes
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records every call the wizard makes against the draft repository. Backed by
|
/// Records every call the wizard makes against the draft repository.
|
||||||
/// an in-memory dictionary so tests can pre-seed an "active" draft for the
|
/// Backed by an in-memory dictionary so tests can pre-seed an "active"
|
||||||
/// wizard to mutate.
|
/// draft for the wizard to mutate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
||||||
{
|
{
|
||||||
@@ -85,13 +149,12 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
|||||||
|
|
||||||
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
|
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
|
||||||
|
|
||||||
public Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
|
public Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
foreach (var d in store.Values)
|
foreach (var d in store.Values)
|
||||||
{
|
{
|
||||||
if (d.ChatId == chatId &&
|
if (d.Platform == platform &&
|
||||||
d.MessageThreadId == messageThreadId &&
|
d.OwnerId == ownerId &&
|
||||||
d.OwnerTelegramId == ownerTelegramId &&
|
|
||||||
d.ExpiresAt > DateTimeOffset.UtcNow)
|
d.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
{
|
{
|
||||||
return Task.FromResult<WizardDraft?>(d);
|
return Task.FromResult<WizardDraft?>(d);
|
||||||
@@ -108,7 +171,8 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
|||||||
Id = draft.Id,
|
Id = draft.Id,
|
||||||
ChatId = draft.ChatId,
|
ChatId = draft.ChatId,
|
||||||
MessageThreadId = draft.MessageThreadId,
|
MessageThreadId = draft.MessageThreadId,
|
||||||
OwnerTelegramId = draft.OwnerTelegramId,
|
OwnerId = draft.OwnerId,
|
||||||
|
Platform = draft.Platform,
|
||||||
Step = draft.Step,
|
Step = draft.Step,
|
||||||
PayloadJson = draft.PayloadJson,
|
PayloadJson = draft.PayloadJson,
|
||||||
DraftMessageId = draft.DraftMessageId,
|
DraftMessageId = draft.DraftMessageId,
|
||||||
@@ -136,11 +200,14 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records every call the wizard makes against the messenger. Default return
|
/// Records every call the wizard makes against the messenger. Default
|
||||||
/// values (empty clubs, message-id 1) match what the wizard expects to see
|
/// return values (empty clubs, message-id 99) match what the wizard
|
||||||
/// in steady state.
|
/// expects to see in steady state. The recorded tuple shapes match
|
||||||
|
/// the old <c>ITelegramWizardMessenger</c> recorders so existing test
|
||||||
|
/// assertions (<c>edit.ChatId</c>, <c>edit.Text</c>, …) keep working
|
||||||
|
/// after the refactor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
|
internal sealed class FakeWizardMessenger : IWizardMessenger
|
||||||
{
|
{
|
||||||
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
|
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
|
||||||
|
|
||||||
@@ -148,37 +215,44 @@ internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
|
|||||||
|
|
||||||
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
|
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
|
||||||
|
|
||||||
|
public List<(string OwnerId, IReadOnlyList<WizardAction> Actions)> EditActions { get; } = new();
|
||||||
|
|
||||||
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
|
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
|
||||||
|
|
||||||
public Task<long> EditMessageTextAsync(
|
public Task<string> EditDraftMessageAsync(
|
||||||
long chatId,
|
WizardDraft draft,
|
||||||
int? messageThreadId,
|
|
||||||
long messageId,
|
|
||||||
string text,
|
string text,
|
||||||
InlineKeyboardMarkup keyboard,
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
Edits.Add((chatId, messageThreadId, messageId, text));
|
Edits.Add((
|
||||||
return Task.FromResult(messageId);
|
long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0,
|
||||||
|
int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null,
|
||||||
|
long.TryParse(draft.DraftMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var msgId) ? msgId : 0,
|
||||||
|
text));
|
||||||
|
EditActions.Add((draft.OwnerId, keyboard));
|
||||||
|
return Task.FromResult(draft.DraftMessageId ?? "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> SendGroupMessageAsync(
|
public Task<string> SendDraftMessageAsync(
|
||||||
long chatId,
|
WizardDraft draft,
|
||||||
int? messageThreadId,
|
|
||||||
string text,
|
string text,
|
||||||
InlineKeyboardMarkup keyboard,
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
Sends.Add((chatId, messageThreadId, text));
|
Sends.Add((
|
||||||
return Task.FromResult(99L);
|
long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0,
|
||||||
|
int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null,
|
||||||
|
text));
|
||||||
|
return Task.FromResult("99");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
|
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
AnsweredCallbacks.Add(callbackId);
|
AnsweredCallbacks.Add(interactionId);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
|
public Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
||||||
=> Task.FromResult(Clubs);
|
=> Task.FromResult(Clubs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Scheduling;
|
||||||
|
|
||||||
|
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||||
|
public sealed class DbSessionTriggerStoreTests(CreateSessionHandlerPostgresFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSessionsNeedingJoinLinkAsync_IgnoresConfirmedSessionsWithoutJoinLink()
|
||||||
|
{
|
||||||
|
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync();
|
||||||
|
|
||||||
|
var groupId = await InsertTelegramGroupAsync(connection);
|
||||||
|
var dueAt = DateTimeOffset.UtcNow.AddMinutes(4).UtcDateTime;
|
||||||
|
var onlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, "https://vtt.example/game", "Online");
|
||||||
|
var offlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, string.Empty, "Offline");
|
||||||
|
|
||||||
|
var sut = new DbSessionTriggerStore(dataSource, new PlatformSchedulerOptions(PlatformKind.Telegram));
|
||||||
|
|
||||||
|
var result = await sut.GetSessionsNeedingJoinLinkAsync(DateTimeOffset.UtcNow, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains(onlineSessionId, result);
|
||||||
|
Assert.DoesNotContain(offlineSessionId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> InsertTelegramGroupAsync(NpgsqlConnection connection)
|
||||||
|
{
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO game_groups (name, platform, external_group_id)
|
||||||
|
VALUES ('Trigger Test Group', 'Telegram', @ExternalGroupId)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("ExternalGroupId", Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Group insert failed."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> InsertSessionAsync(
|
||||||
|
NpgsqlConnection connection,
|
||||||
|
Guid groupId,
|
||||||
|
DateTime scheduledAt,
|
||||||
|
string joinLink,
|
||||||
|
string format)
|
||||||
|
{
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, format)
|
||||||
|
VALUES (@GroupId, 'Trigger Test Session', @JoinLink, @ScheduledAt, @Status, @Format)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("GroupId", groupId);
|
||||||
|
command.Parameters.AddWithValue("JoinLink", joinLink);
|
||||||
|
command.Parameters.AddWithValue("ScheduledAt", scheduledAt);
|
||||||
|
command.Parameters.AddWithValue("Status", SessionStatus.Confirmed);
|
||||||
|
command.Parameters.AddWithValue("Format", format);
|
||||||
|
|
||||||
|
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Session insert failed."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
||||||
|
|
||||||
@@ -36,9 +37,35 @@ public sealed class TelegramPlatformMessengerTests
|
|||||||
Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message);
|
Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildDirectNotificationText_OneHourReminderWithoutJoinLink_ShouldNotRenderBlankLinkLine()
|
||||||
|
{
|
||||||
|
var notification = new PlatformDirectSessionNotification(
|
||||||
|
PlatformDirectSessionNotificationKind.OneHourReminder,
|
||||||
|
new PlatformUser(PlatformKind.Telegram, "123", "Player", "player"),
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"Offline Game",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
JoinLink: string.Empty);
|
||||||
|
|
||||||
|
var text = InvokeBuildDirectNotificationText(notification);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("🔗", text);
|
||||||
|
}
|
||||||
|
|
||||||
private static TelegramPlatformMessenger CreateMessenger() =>
|
private static TelegramPlatformMessenger CreateMessenger() =>
|
||||||
new(null!, NullLogger<TelegramPlatformMessenger>.Instance);
|
new(null!, NullLogger<TelegramPlatformMessenger>.Instance);
|
||||||
|
|
||||||
|
private static string InvokeBuildDirectNotificationText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var method = typeof(TelegramPlatformMessenger).GetMethod(
|
||||||
|
"BuildDirectNotificationText",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
return Assert.IsType<string>(method.Invoke(null, new object[] { notification }));
|
||||||
|
}
|
||||||
|
|
||||||
private static SessionBatchViewModel CreateView() =>
|
private static SessionBatchViewModel CreateView() =>
|
||||||
new("Test batch", []);
|
new("Test batch", []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,4 +149,36 @@ public sealed class SessionBatchViewBuilderTests
|
|||||||
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
|
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
|
||||||
Assert.DoesNotContain("ожидания", joinAction.Label);
|
Assert.DoesNotContain("ожидания", joinAction.Label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ShouldPassThroughNewFields()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
"https://example.com/game",
|
||||||
|
"Offline",
|
||||||
|
"Moscow",
|
||||||
|
"A short description",
|
||||||
|
"D\u0026D 5e",
|
||||||
|
240,
|
||||||
|
true)
|
||||||
|
};
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var session = result.Sessions[0];
|
||||||
|
|
||||||
|
Assert.Equal("A short description", session.Description);
|
||||||
|
Assert.Equal("D\u0026D 5e", session.System);
|
||||||
|
Assert.Equal(240, session.DurationMinutes);
|
||||||
|
Assert.True(session.IsOneShot);
|
||||||
|
Assert.Equal("Offline", session.Format);
|
||||||
|
Assert.Equal("Moscow", session.LocationAddress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
|
|
||||||
var sessions = new[]
|
var sessions = new[]
|
||||||
{
|
{
|
||||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2"),
|
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2", "Online", null),
|
||||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
|
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
|
||||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1")
|
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1", "Online", null)
|
||||||
};
|
};
|
||||||
var participants = new[]
|
var participants = new[]
|
||||||
{
|
{
|
||||||
@@ -35,7 +35,7 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
Assert.Contains("Charlie", text);
|
Assert.Contains("Charlie", text);
|
||||||
Assert.Contains("Bob", text);
|
Assert.Contains("Bob", text);
|
||||||
Assert.Contains("Сессия отменена", text);
|
Assert.Contains("Сессия отменена", text);
|
||||||
Assert.Contains("Ссылка на игру", text);
|
Assert.Contains("Ссылка:", text);
|
||||||
Assert.Contains("https://example.com/game1", text);
|
Assert.Contains("https://example.com/game1", text);
|
||||||
Assert.Contains("https://example.com/game2", text);
|
Assert.Contains("https://example.com/game2", text);
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
public void Render_ShouldShowWaitlistButtonWhenFull()
|
public void Render_ShouldShowWaitlistButtonWhenFull()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game", "Online", null) };
|
||||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
@@ -130,16 +130,66 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
|
||||||
Assert.DoesNotContain("Ссылка на игру", text);
|
Assert.DoesNotContain("Ссылка:", text);
|
||||||
Assert.Contains("📅", text);
|
Assert.Contains("📅", text);
|
||||||
Assert.Equal(2, buttons.Count);
|
Assert.Equal(2, buttons.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldShowOfflineAddress()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
"",
|
||||||
|
"Offline",
|
||||||
|
"Москва, ул. Кубиков, 12"),
|
||||||
|
};
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants);
|
||||||
|
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("📍 <b>Адрес:</b>", text);
|
||||||
|
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||||
|
Assert.DoesNotContain("Ссылка:", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldShowOnlineLinkWithLinkIcon()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
"https://vtt.example/game",
|
||||||
|
"Online",
|
||||||
|
null),
|
||||||
|
};
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants);
|
||||||
|
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("🔗 <b>Ссылка:</b>", text);
|
||||||
|
Assert.Contains("https://vtt.example/game", text);
|
||||||
|
Assert.DoesNotContain("📍 Адрес:", text);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_ShouldEncodeHtmlInJoinLink()
|
public void Render_ShouldEncodeHtmlInJoinLink()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2") };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2", "Online", null) };
|
||||||
var participants = Array.Empty<ParticipantBatchDto>();
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
@@ -148,4 +198,77 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
Assert.Contains("a=1&b=2", text);
|
Assert.Contains("a=1&b=2", text);
|
||||||
Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded
|
Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldShowStructuredGameCard()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
"https://vtt.example/game",
|
||||||
|
"Hybrid",
|
||||||
|
"Moscow, Kubik Bar",
|
||||||
|
"Mystery one-shot in Bamberg.",
|
||||||
|
"D\u0026D 5e",
|
||||||
|
240,
|
||||||
|
true)
|
||||||
|
};
|
||||||
|
var participants = new[]
|
||||||
|
{
|
||||||
|
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||||
|
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
|
||||||
|
};
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
|
||||||
|
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("🏷", text);
|
||||||
|
Assert.Contains("Система:", text);
|
||||||
|
Assert.Contains("D\u0026amp;D 5e", text);
|
||||||
|
Assert.Contains("Формат:", text);
|
||||||
|
Assert.Contains("Hybrid", text);
|
||||||
|
Assert.Contains("Тип:", text);
|
||||||
|
Assert.Contains("One-shot", text);
|
||||||
|
Assert.Contains("⏱", text);
|
||||||
|
Assert.Contains("Длительность:", text);
|
||||||
|
Assert.Contains("4 ч", text);
|
||||||
|
Assert.Contains("📝", text);
|
||||||
|
Assert.Contains("Описание:", text);
|
||||||
|
Assert.Contains("Mystery one-shot in Bamberg.", text);
|
||||||
|
Assert.Contains("🔗", text);
|
||||||
|
Assert.Contains("Ссылка:", text);
|
||||||
|
Assert.Contains("📍", text);
|
||||||
|
Assert.Contains("Адрес:", text);
|
||||||
|
Assert.Contains("@alice", text);
|
||||||
|
Assert.Contains("Bob", text);
|
||||||
|
Assert.Contains("Лист ожидания", text);
|
||||||
|
|
||||||
|
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
Assert.Equal(2, buttons.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldHandleMissingOptionalFields()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
|
||||||
|
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("📅", text);
|
||||||
|
Assert.Contains("👥", text);
|
||||||
|
Assert.DoesNotContain("Система:", text);
|
||||||
|
Assert.DoesNotContain("Формат:", text);
|
||||||
|
Assert.DoesNotContain("Длительность:", text);
|
||||||
|
Assert.DoesNotContain("Описание:", text);
|
||||||
|
Assert.DoesNotContain("Ссылка:", text);
|
||||||
|
Assert.DoesNotContain("Адрес:", text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||||
{
|
{
|
||||||
|
// Read the version from Directory.Build.props (the canonical source of
|
||||||
|
// truth) so the test doesn't need to be hand-edited on every version
|
||||||
|
// bump. Asserting the rendered NavMenu matches the canonical version
|
||||||
|
// catches real bugs (e.g. someone bumps Directory.Build.props but
|
||||||
|
// forgets to update NavMenu.razor) without false alarms from a stale
|
||||||
|
// hard-coded literal.
|
||||||
|
var propsPath = FindRepositoryFile("Directory.Build.props");
|
||||||
|
var version = ReadVersionFromProps(propsPath);
|
||||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||||
Assert.Contains("v3.7.1", navMenu, StringComparison.Ordinal);
|
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -68,4 +76,23 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
|
|
||||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the <c><Version>...</Version></c> element from
|
||||||
|
/// <c>Directory.Build.props</c>. Tolerant of whitespace, comments and
|
||||||
|
/// attribute shuffling — the MSBuild schema for <c>Version</c> is just
|
||||||
|
/// a plain element with a string body.
|
||||||
|
/// </summary>
|
||||||
|
private static string ReadVersionFromProps(string propsPath)
|
||||||
|
{
|
||||||
|
var doc = System.Xml.Linq.XDocument.Load(propsPath);
|
||||||
|
var versionElement = doc.Descendants()
|
||||||
|
.FirstOrDefault(e => e.Name.LocalName == "Version");
|
||||||
|
Assert.NotNull(versionElement);
|
||||||
|
var version = versionElement!.Value.Trim();
|
||||||
|
Assert.False(
|
||||||
|
string.IsNullOrEmpty(version),
|
||||||
|
$"<Version> in {propsPath} is empty");
|
||||||
|
return version;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<Po
|
|||||||
|
|
||||||
public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime
|
public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||||
|
|
||||||
public Task InitializeAsync()
|
public Task InitializeAsync()
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using GmRelay.Web.Services;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web.Rendering;
|
||||||
|
|
||||||
|
public sealed class WebTelegramSessionBatchRendererTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldShowStructuredGameCard()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
"https://vtt.example/game",
|
||||||
|
"Hybrid",
|
||||||
|
"Moscow, Kubik Bar",
|
||||||
|
"Mystery one-shot in Bamberg.",
|
||||||
|
"D\u0026D 5e",
|
||||||
|
240,
|
||||||
|
true)
|
||||||
|
};
|
||||||
|
var participants = new[]
|
||||||
|
{
|
||||||
|
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||||
|
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
|
||||||
|
};
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
|
||||||
|
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("🏷", text);
|
||||||
|
Assert.Contains("Система:", text);
|
||||||
|
Assert.Contains("D\u0026amp;D 5e", text);
|
||||||
|
Assert.Contains("Формат:", text);
|
||||||
|
Assert.Contains("Hybrid", text);
|
||||||
|
Assert.Contains("Тип:", text);
|
||||||
|
Assert.Contains("One-shot", text);
|
||||||
|
Assert.Contains("⏱", text);
|
||||||
|
Assert.Contains("Длительность:", text);
|
||||||
|
Assert.Contains("4 ч", text);
|
||||||
|
Assert.Contains("📝", text);
|
||||||
|
Assert.Contains("Описание:", text);
|
||||||
|
Assert.Contains("Mystery one-shot in Bamberg.", text);
|
||||||
|
Assert.Contains("🔗", text);
|
||||||
|
Assert.Contains("Ссылка:", text);
|
||||||
|
Assert.Contains("📍", text);
|
||||||
|
Assert.Contains("Адрес:", text);
|
||||||
|
Assert.Contains("@alice", text);
|
||||||
|
Assert.Contains("Bob", text);
|
||||||
|
Assert.Contains("Лист ожидания", text);
|
||||||
|
|
||||||
|
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
Assert.Equal(2, buttons.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldHandleMissingOptionalFields()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
|
||||||
|
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("📅", text);
|
||||||
|
Assert.Contains("👥", text);
|
||||||
|
Assert.DoesNotContain("Система:", text);
|
||||||
|
Assert.DoesNotContain("Формат:", text);
|
||||||
|
Assert.DoesNotContain("Длительность:", text);
|
||||||
|
Assert.DoesNotContain("Описание:", text);
|
||||||
|
Assert.DoesNotContain("Ссылка:", text);
|
||||||
|
Assert.DoesNotContain("Адрес:", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user