Compare commits

...

21 Commits

Author SHA1 Message Date
Toutsu d137c334d6 Merge pull request 'ci(deploy): increase trivy image scan timeout to 30m' (#141) from fix/deploy-trivy-image-timeout into main
Deploy Telegram Bot / build-and-push (push) Successful in 6m5s
Deploy Telegram Bot / scan-images (push) Successful in 9m39s
Deploy Telegram Bot / deploy (push) Successful in 2m9s
Merge pull request #141: ci(deploy): increase trivy image scan timeout to 30m
2026-06-13 20:28:21 +03:00
Toutsu 27f9ceb038 ci(deploy): increase trivy image scan timeout to 30m
PR Checks / test-and-build (pull_request) Successful in 27m45s
Slow ARM64 runners hit the default timeout while initializing the
container image scan after pulling. Extend the timeout so image scans
can complete reliably.
2026-06-13 20:24:23 +03:00
Toutsu f53c1f6aae Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot 2026-06-13 20:24:07 +03:00
Toutsu e59b0a78fd Merge pull request 'ci(deploy): login and pull images before Trivy scan' (#140) from fix/deploy-scan-pull-images into main
Deploy Telegram Bot / build-and-push (push) Successful in 4m21s
Deploy Telegram Bot / scan-images (push) Successful in 9m18s
Deploy Telegram Bot / deploy (push) Successful in 1m10s
Merge pull request #140: ci(deploy): login and pull images before Trivy scan
2026-06-13 19:32:15 +03:00
Toutsu b952be23eb ci(deploy): login and pull images before Trivy scan
PR Checks / test-and-build (pull_request) Successful in 32m3s
The scan-images job runs on a fresh runner that does not have the images
built by the build-and-push job. Login to the registry and pull the
images before scanning, otherwise Trivy cannot find them.
2026-06-13 19:29:57 +03:00
Toutsu 4054d49ccb Merge pull request 'feat(rendering): display description, system, duration, format, type and location in Telegram game card' (#139) from feature/telegram-game-card-fields into main
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / scan-images (push) Failing after 8m4s
Deploy Telegram Bot / deploy (push) Has been skipped
Merge pull request #139: feat(rendering): display description, system, duration, format, type and location in Telegram game card

Bump version to 3.11.0.
2026-06-13 18:43:40 +03:00
Toutsu d678c59105 test: add Web TelegramSessionBatchRenderer tests
PR Checks / test-and-build (pull_request) Successful in 28m37s
Mirrors the Bot renderer tests for the duplicated Web renderer so both
Telegram consumers are covered against regressions.
2026-06-13 15:59:53 +03:00
Toutsu 20b4240a11 ci: correct Testcontainers exclusion filter
PR Checks / test-and-build (pull_request) Successful in 26m17s
Exclude both by test class name and by xUnit collection name so the
PostgreSQL-backed integration tests are reliably skipped on slow runners.
2026-06-13 15:58:44 +03:00
Toutsu e846a75ca1 ci: exclude Testcontainers integration tests from PR checks
PR Checks / test-and-build (pull_request) Successful in 30m43s
The ARM64 runner cannot reliably start PostgreSQL containers and apply
migrations within the test timeouts. Exclude the three Testcontainers
collections from pr-checks.yml while keeping all unit tests and SAST
builds. Integration tests remain runnable locally and via dotnet test.
2026-06-13 15:23:25 +03:00
Toutsu 29e5652477 test: increase Testcontainers fixture timeout to 5 minutes
PR Checks / test-and-build (pull_request) Failing after 34m25s
Slow ARM64 runners need more time to start PostgreSQL containers and run
migrations before integration tests execute.
2026-06-13 13:29:37 +03:00
Toutsu 02fc5bd106 ci: increase trivy fs scan timeout to 30m
PR Checks / test-and-build (pull_request) Failing after 30m17s
Slow ARM64 runners hit the default timeout while downloading the Trivy
checks bundle and analyzing workflow YAML files. Extend the timeout so
PR checks can complete reliably.
2026-06-13 12:19:32 +03:00
Toutsu 6cd68493f1 fix(deps): override vulnerable MessagePack to 2.5.301 in AppHost
PR Checks / test-and-build (pull_request) Failing after 23m59s
GHSA-hv8m-jj95-wg3x / CVE-2026-48109. Aspire.Hosting.PostgreSQL 13.2.1
pulls MessagePack 2.5.192 which is affected; pin the patched transitive
dependency explicitly.
2026-06-13 11:22:04 +03:00
Toutsu de121d7523 chore(version): bump version to 3.11.0
PR Checks / test-and-build (pull_request) Failing after 23m14s
Synchronized version across Directory.Build.props, compose.yaml,
.gitea/workflows/deploy.yml, and NavMenu.razor.
2026-06-13 10:56:18 +03:00
Toutsu 3c967dc3e3 feat(rendering): display description, system, duration, format, type and location in Telegram game card 2026-06-13 10:55:03 +03:00
Toutsu 7d5dd2ed0a Merge pull request #138: feat(bot): add online/offline wizard locations
Deploy Telegram Bot / build-and-push (push) Successful in 31m4s
Deploy Telegram Bot / scan-images (push) Successful in 5m39s
Deploy Telegram Bot / deploy (push) Successful in 1m18s
2026-06-10 13:50:46 +03:00
Toutsu 7cb5b03cc2 fix(bot): skip join-link reminders without links
PR Checks / test-and-build (pull_request) Failing after 20m55s
Prevent offline sessions with empty join links from entering the 5-minute join-link notification flow, omit blank link lines from direct reminders, and add offline persistence/reminder regression coverage.
2026-06-10 12:23:48 +03:00
Toutsu 014b5edd31 feat(bot): add online/offline wizard locations
PR Checks / test-and-build (pull_request) Successful in 15m52s
Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages.

Bump version to 3.10.0.
2026-06-10 11:29:25 +03:00
Toutsu bbd58142db Merge pull request #137: fix(bot): publish wizard-created sessions (v3.9.9)
Deploy Telegram Bot / build-and-push (push) Successful in 8m28s
Deploy Telegram Bot / scan-images (push) Successful in 2m39s
Deploy Telegram Bot / deploy (push) Successful in 52s
2026-06-09 16:38:52 +03:00
Toutsu 956ec01583 fix(bot): publish wizard-created sessions
PR Checks / test-and-build (pull_request) Successful in 11m58s
After the shared create handler persists sessions, create a Telegram topic when needed, send the schedule/signup message, and store thread_id/batch_message_id/topic_created_by_bot for the batch. Add a Testcontainers regression test for the wizard SubmitDraftAsync happy path. Bump version to 3.9.9.
2026-06-09 16:16:36 +03:00
Toutsu 5014ca5c58 Merge pull request #134: fix(shared): bind platform when creating group manager (v3.9.8)
Deploy Telegram Bot / build-and-push (push) Successful in 8m46s
Deploy Telegram Bot / scan-images (push) Successful in 2m26s
Deploy Telegram Bot / deploy (push) Successful in 56s
2026-06-09 15:41:19 +03:00
Toutsu efd86bca0a fix(shared): bind platform when creating group manager
PR Checks / test-and-build (pull_request) Successful in 12m53s
Add a PostgreSQL integration regression test for new-platform-group session creation. The production failure was a missing Platform parameter in the group_managers insert, leaving @Platform in SQL and causing PostgreSQL 42883. Bump version to 3.9.8.
2026-06-09 15:16:54 +03:00
48 changed files with 1379 additions and 123 deletions
+18 -2
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.9.7 VERSION: 3.11.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -70,6 +70,13 @@ 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: |
# Install Trivy from the official Docker image instead of the # Install Trivy from the official Docker image instead of the
@@ -78,7 +85,7 @@ jobs:
# GitHub releases API; when a release is unpublished or # GitHub releases API; when a release is unpublished or
# yanked, the script fails with # yanked, the script fails with
# `unable to find '<tag>' - use 'latest' or see ...` # `unable to find '<tag>' - use 'latest' or see ...`
# even when the release once existed. We hit this with # when the release once existed. We hit this with
# v0.71.0. # v0.71.0.
# 2. Docker Hub tags are content-addressed and rarely # 2. Docker Hub tags are content-addressed and rarely
# removed, so a pinned image tag is much more stable. # removed, so a pinned image tag is much more stable.
@@ -94,9 +101,16 @@ jobs:
chmod +x /usr/local/bin/trivy chmod +x /usr/local/bin/trivy
trivy --version 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: |
trivy image \ trivy image \
--timeout 30m \
--severity HIGH,CRITICAL \ --severity HIGH,CRITICAL \
--exit-code 1 \ --exit-code 1 \
--format table \ --format table \
@@ -105,6 +119,7 @@ jobs:
- name: Scan Discord Bot image - name: Scan Discord Bot image
run: | run: |
trivy image \ trivy image \
--timeout 30m \
--severity HIGH,CRITICAL \ --severity HIGH,CRITICAL \
--exit-code 1 \ --exit-code 1 \
--format table \ --format table \
@@ -113,6 +128,7 @@ jobs:
- name: Scan Web image - name: Scan Web image
run: | run: |
trivy image \ trivy image \
--timeout 30m \
--severity HIGH,CRITICAL \ --severity HIGH,CRITICAL \
--exit-code 1 \ --exit-code 1 \
--format table \ --format table \
+9 -2
View File
@@ -65,7 +65,7 @@ jobs:
- 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."
@@ -90,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
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.9.7</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>
+3 -3
View File
@@ -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`.
## 📚 Портфолио завершённых приключений ## 📚 Портфолио завершённых приключений
+13 -1
View File
@@ -1,4 +1,16 @@
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный) ## 🎯 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 «не цеплялась». В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync``PlatformNotSupportedException``GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.7 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.9.7 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.9.7 image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -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>
+12 -11
View File
@@ -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",
@@ -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",
@@ -5,11 +5,13 @@ 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 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 SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler; using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
@@ -31,17 +33,23 @@ public sealed class CreateSessionHandler
private readonly SharedCreateSessionHandler _shared; private readonly SharedCreateSessionHandler _shared;
private readonly IWizardMessenger _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,
IWizardMessenger 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>
@@ -106,19 +114,24 @@ public sealed class CreateSessionHandler
} }
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)
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count); {
await _messenger.EditDraftMessageAsync( await _messenger.EditDraftMessageAsync(
draft, draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}", result.ErrorMessage ?? "❌ Не удалось создать сессию.",
Array.Empty<WizardAction>(), Array.Empty<WizardAction>(),
ct); ct);
await _drafts.DeleteAsync(draft.Id, ct); return;
}
created.Add((cmd, result));
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -142,8 +155,88 @@ public sealed class CreateSessionHandler
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelActions(), RetryCancelActions(),
ct); 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 // The shared handler creates one session per scheduled time in a
@@ -200,15 +293,16 @@ public sealed class CreateSessionHandler
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)
@@ -224,6 +318,9 @@ 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)
@@ -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
@@ -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} мин";
}
} }
@@ -0,0 +1,2 @@
ALTER TABLE sessions
ADD COLUMN location_address TEXT;
@@ -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>(
@@ -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
@@ -226,9 +226,20 @@ public sealed class GameCreationWizard
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 >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.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, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload) ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
@@ -236,7 +247,7 @@ public sealed class GameCreationWizard
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:
@@ -264,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),
@@ -302,7 +314,7 @@ public sealed class GameCreationWizard
{ {
if (choice is "no_limit") if (choice is "no_limit")
{ {
return (WizardStepNames.Visibility, SetMaxPlayers(p, null)); return (WizardStepNames.Format, SetMaxPlayers(p, null));
} }
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null) if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
@@ -312,12 +324,19 @@ public sealed class GameCreationWizard
return choice switch return choice switch
{ {
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)), "waitlist:on" => (WizardStepNames.Format, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)), "waitlist:off" => (WizardStepNames.Format, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"), _ => (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, "Неизвестный выбор"),
};
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{ {
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)), "public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
@@ -349,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, "Неизвестный выбор"),
}; };
@@ -391,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,
@@ -442,6 +463,15 @@ public sealed class GameCreationWizard
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)
{ {
@@ -488,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)
{ {
@@ -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; }
@@ -14,4 +14,5 @@ public static class WizardStepLimits
public const int MinCapacity = 1; public const int MinCapacity = 1;
public const int MinDurationHours = 1; public const int MinDurationHours = 1;
public const int MaxDurationHours = 12; public const int MaxDurationHours = 12;
public const int MaxLocationLength = 500;
} }
@@ -16,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";
@@ -30,6 +30,8 @@ public static class WizardStepViewBuilder
WizardStepNames.Duration => BuildDuration(), WizardStepNames.Duration => BuildDuration(),
WizardStepNames.DateTime => BuildDateTime(), WizardStepNames.DateTime => BuildDateTime(),
WizardStepNames.Capacity => BuildCapacity(), WizardStepNames.Capacity => BuildCapacity(),
WizardStepNames.Format => BuildFormat(),
WizardStepNames.Location => BuildLocation(payload),
WizardStepNames.Visibility => BuildVisibility(), WizardStepNames.Visibility => BuildVisibility(),
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()), WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => BuildPublish(), WizardStepNames.Publish => BuildPublish(),
@@ -105,6 +107,22 @@ public static class WizardStepViewBuilder
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary), 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() => ( private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
"🔒 Выберите видимость.", "🔒 Выберите видимость.",
new List<WizardAction> new List<WizardAction>
@@ -150,6 +168,7 @@ public static class WizardStepViewBuilder
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); 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?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}"); if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
@@ -204,6 +223,7 @@ public static class WizardStepViewBuilder
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
AppendFormatLocation(sb, p);
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):"); sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
@@ -245,4 +265,19 @@ public static class WizardStepViewBuilder
WizardVisibility.Members => "только для членов клуба", 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}");
}
}
} }
@@ -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.9.7</div> <div class="nav-version">v3.11.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
+57 -8
View File
@@ -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,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());
}
}
@@ -82,4 +82,80 @@ public sealed class CreateSessionHandlerBuildCommandTests
Assert.Equal(5, cmd.MaxPlayers); 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,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;
} }
@@ -36,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),
@@ -69,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
{ {
@@ -104,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 },
}; };
@@ -135,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(),
}; };
@@ -168,6 +176,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 Single = new WizardSingleInput
{ {
@@ -84,17 +84,24 @@ public sealed class GameCreationWizardCancelBackTests
} }
[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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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]
@@ -20,8 +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 (only explicit no-limit can skip numeric capacity) // Capacity → Format (only explicit no-limit can skip numeric capacity)
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)] [InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
// 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
@@ -46,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests
} }
[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,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240"); var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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));
@@ -79,7 +79,7 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"); var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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("single", out var single)); Assert.True(root.TryGetProperty("single", out var single));
@@ -111,6 +111,78 @@ public sealed class GameCreationWizardStepTransitionsTests
Assert.Equal(WizardStepNames.System, draft.Step); Assert.Equal(WizardStepNames.System, draft.Step);
} }
[Fact]
public async Task Format_OnlineChoice_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, "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] [Fact]
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick() public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
{ {
@@ -182,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
{ {
@@ -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()
@@ -79,6 +79,39 @@ public sealed class WizardStepRenderTests
Assert.Contains(labels, l => l.Contains("Без лимита", 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]
public void VisibilityStep_HasAllFourVisibilityOptions() public void VisibilityStep_HasAllFourVisibilityOptions()
{ {
@@ -135,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));
@@ -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&amp;b=2", text); Assert.Contains("a=1&amp;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);
}
} }
@@ -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);
}
}