Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0602052ea | |||
| 9709d09b15 | |||
| a391c51761 | |||
| e15652399b | |||
| 40b13db320 | |||
| e0ee8fc962 | |||
| 6707a2850c | |||
| d137c334d6 | |||
| 27f9ceb038 | |||
| f53c1f6aae | |||
| e59b0a78fd | |||
| b952be23eb | |||
| 4054d49ccb | |||
| d678c59105 | |||
| 20b4240a11 | |||
| e846a75ca1 | |||
| 29e5652477 | |||
| 02fc5bd106 | |||
| 6cd68493f1 | |||
| de121d7523 | |||
| 3c967dc3e3 | |||
| 7d5dd2ed0a | |||
| 7cb5b03cc2 | |||
| 014b5edd31 | |||
| bbd58142db | |||
| 956ec01583 | |||
| 5014ca5c58 | |||
| efd86bca0a | |||
| 2241568bac | |||
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad | |||
| 1f3fb6e89e |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.9.5
|
||||
VERSION: 3.11.1
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
@@ -70,6 +70,13 @@ jobs:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
run: |
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
@@ -78,7 +85,7 @@ jobs:
|
||||
# GitHub releases API; when a release is unpublished or
|
||||
# yanked, the script fails with
|
||||
# `unable to find '<tag>' - use 'latest' or see ...`
|
||||
# even when the release once existed. We hit this with
|
||||
# when the release once existed. We hit this with
|
||||
# v0.71.0.
|
||||
# 2. Docker Hub tags are content-addressed and rarely
|
||||
# removed, so a pinned image tag is much more stable.
|
||||
@@ -94,9 +101,16 @@ jobs:
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Pull images for scan
|
||||
run: |
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||
|
||||
- name: Scan Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
@@ -105,6 +119,7 @@ jobs:
|
||||
- name: Scan Discord Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
@@ -113,6 +128,7 @@ jobs:
|
||||
- name: Scan Web image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Trivy filesystem security scan
|
||||
run: |
|
||||
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]}"
|
||||
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."
|
||||
@@ -90,4 +90,11 @@ jobs:
|
||||
# ── 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,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.9.5</Version>
|
||||
<Version>3.11.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v3.6.0`.
|
||||
**Текущая версия:** `v3.11.1`.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 🤖 Telegram Bot
|
||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
|
||||
- **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация.
|
||||
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
|
||||
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||
@@ -25,7 +25,7 @@
|
||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
|
||||
|
||||
### Discord Bot
|
||||
- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале.
|
||||
- **Slash-команды `/newsession` и `/listsessions`**: `/newsession` ведёт ГМа по тому же wizard, что и в Telegram: тип, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка или адрес, видимость и публикация. `/listsessions` показывает расписание и управление сессиями.
|
||||
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
|
||||
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
|
||||
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
|
||||
@@ -127,7 +127,7 @@ docker compose up -d
|
||||
2. Создайте группу через `/newgroup`.
|
||||
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.
|
||||
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
@@ -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
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.5
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.1
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.5
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.1
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -86,7 +86,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.5
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.1
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -83,6 +83,16 @@
|
||||
"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": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.7, )",
|
||||
@@ -248,19 +258,10 @@
|
||||
"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": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.5.192",
|
||||
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
|
||||
"resolved": "2.5.301",
|
||||
"contentHash": "3PyBiSeKTfvtyzUv3+9eXGIw7vBBZ0GAc4k3+RVT0tz2vKv3l0pviiA2b6DrmHyDvj1Au8lSVDDw/wKPMxUQ4A=="
|
||||
},
|
||||
"Microsoft.Extensions.AI.Abstractions": {
|
||||
"type": "Transitive",
|
||||
|
||||
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем wget для healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libgssapi-krb5-2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем только AOT-результаты из билда
|
||||
|
||||
@@ -70,7 +70,17 @@ public sealed class CancelSessionHandler(
|
||||
|
||||
// 3. Загружаем весь батч для перерисовки
|
||||
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
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at",
|
||||
|
||||
@@ -5,11 +5,13 @@ using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using Telegram.Bot.Types;
|
||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||
|
||||
@@ -31,17 +33,23 @@ public sealed class CreateSessionHandler
|
||||
private readonly SharedCreateSessionHandler _shared;
|
||||
private readonly IWizardMessenger _messenger;
|
||||
private readonly ILogger<CreateSessionHandler> _log;
|
||||
private readonly IPlatformMessenger? _platformMessenger;
|
||||
private readonly NpgsqlDataSource? _dataSource;
|
||||
|
||||
public CreateSessionHandler(
|
||||
IWizardDraftRepository drafts,
|
||||
SharedCreateSessionHandler shared,
|
||||
IWizardMessenger messenger,
|
||||
ILogger<CreateSessionHandler> log)
|
||||
ILogger<CreateSessionHandler> log,
|
||||
IPlatformMessenger? platformMessenger = null,
|
||||
NpgsqlDataSource? dataSource = null)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_shared = shared;
|
||||
_messenger = messenger;
|
||||
_log = log;
|
||||
_platformMessenger = platformMessenger;
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,19 +114,24 @@ public sealed class CreateSessionHandler
|
||||
}
|
||||
|
||||
var commands = BuildCommands(draft, payload);
|
||||
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||
try
|
||||
{
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
await _shared.HandleAsync(cmd, ct);
|
||||
var result = await _shared.HandleAsync(cmd, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
created.Add((cmd, result));
|
||||
}
|
||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -142,9 +155,89 @@ public sealed class CreateSessionHandler
|
||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
|
||||
try
|
||||
{
|
||||
foreach (var item in created)
|
||||
{
|
||||
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
|
||||
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
|
||||
{
|
||||
if (_platformMessenger is null || _dataSource is null)
|
||||
{
|
||||
throw new InvalidOperationException("Session publication dependencies are not configured.");
|
||||
}
|
||||
|
||||
if (result.View is null || result.BatchId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Created session result does not contain publication data.");
|
||||
}
|
||||
|
||||
var group = command.Group;
|
||||
var topicCreatedByBot = false;
|
||||
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||
{
|
||||
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
|
||||
group = group with { ExternalThreadId = thread.ExternalThreadId };
|
||||
topicCreatedByBot = true;
|
||||
}
|
||||
|
||||
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
|
||||
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
|
||||
ct);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET thread_id = @ThreadId,
|
||||
batch_message_id = @BatchMessageId,
|
||||
topic_created_by_bot = @TopicCreatedByBot,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
""",
|
||||
new
|
||||
{
|
||||
result.BatchId,
|
||||
ThreadId = ParseNullableInt(group.ExternalThreadId),
|
||||
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
|
||||
TopicCreatedByBot = topicCreatedByBot
|
||||
});
|
||||
}
|
||||
|
||||
private static int ParseInt(string value) =>
|
||||
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
|
||||
private static int? ParseNullableInt(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
|
||||
// ── Build shared commands ────────────────────────────────────────
|
||||
// The shared handler creates one session per scheduled time in a
|
||||
// single transaction and assigns the same batch_id to all of them.
|
||||
@@ -200,15 +293,16 @@ public sealed class CreateSessionHandler
|
||||
User: user,
|
||||
Group: group,
|
||||
Title: p.Title ?? string.Empty,
|
||||
Link: string.Empty,
|
||||
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
|
||||
ScheduledTimes: scheduledTimes,
|
||||
MaxPlayers: maxPlayers,
|
||||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||
System: ParseSystem(p.System),
|
||||
Description: p.Description,
|
||||
Format: null,
|
||||
Format: p.Format?.ToString(),
|
||||
DurationMinutes: p.DurationMinutes,
|
||||
IsOneShot: isOneShot);
|
||||
IsOneShot: isOneShot,
|
||||
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
|
||||
}
|
||||
|
||||
private static GameSystem? ParseSystem(string? code)
|
||||
@@ -224,12 +318,16 @@ public sealed class CreateSessionHandler
|
||||
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||||
if (string.IsNullOrWhiteSpace(p.System)) 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.Type == WizardCreationType.Single)
|
||||
{
|
||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
||||
// MaxPlayers = null is a valid "♾ Без лимита" choice
|
||||
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -139,7 +139,13 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
scheduled_at AS ScheduledAt,
|
||||
status AS Status,
|
||||
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
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at
|
||||
|
||||
@@ -23,11 +23,18 @@ internal static class SessionListMessageRenderer
|
||||
|
||||
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
if (sessions.Count == 0 || !sessions.First().CanManage)
|
||||
if (sessions.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return sessions.First().CanManage
|
||||
? RenderManagerActions(sessions)
|
||||
: RenderPlayerActions(sessions);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PlatformMessageAction> RenderManagerActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
var actions = new List<PlatformMessageAction>();
|
||||
|
||||
foreach (var session in sessions)
|
||||
@@ -36,19 +43,19 @@ internal static class SessionListMessageRenderer
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"cancel_session:{session.Id}",
|
||||
$"❌ {dateTitle}",
|
||||
$"❌ Отменить {dateTitle}",
|
||||
$"cancel_session:{session.Id}"));
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"reschedule_session:{session.Id}",
|
||||
$"⏰ {dateTitle}",
|
||||
$"⏰ Перенести {dateTitle}",
|
||||
$"reschedule_session:{session.Id}"));
|
||||
|
||||
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"promote_waitlist:{session.Id}",
|
||||
$"⬆️ Из ожидания {dateTitle}",
|
||||
$"⬆️ С ожидания {dateTitle}",
|
||||
$"promote_waitlist:{session.Id}"));
|
||||
}
|
||||
|
||||
@@ -60,4 +67,31 @@ internal static class SessionListMessageRenderer
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PlatformMessageAction> RenderPlayerActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
var actions = new List<PlatformMessageAction>();
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
|
||||
if (session.IsUserActive || session.IsUserWaitlisted)
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"leave_session:{session.Id}",
|
||||
session.IsUserWaitlisted ? $"✖️ Выйти из ожидания {dateTitle}" : $"✖️ Выйти {dateTitle}",
|
||||
$"leave_session:{session.Id}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"join_session:{session.Id}",
|
||||
$"✅ Записаться {dateTitle}",
|
||||
$"join_session:{session.Id}"));
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
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();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the bot's command list with Telegram so users see the
|
||||
/// command menu when they type "/" in a chat.
|
||||
/// </summary>
|
||||
public sealed class TelegramCommandsSetupService(
|
||||
ITelegramBotClient bot,
|
||||
ILogger<TelegramCommandsSetupService> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var commands = new[]
|
||||
{
|
||||
new BotCommand { Command = "start", Description = "Начать работу с ботом" },
|
||||
new BotCommand { Command = "newsession", Description = "Создать новую игровую сессию" },
|
||||
new BotCommand { Command = "listsessions", Description = "Список предстоящих сессий" },
|
||||
new BotCommand { Command = "exportcalendar", Description = "Экспортировать расписание в ICS" },
|
||||
new BotCommand { Command = "help", Description = "Справка по командам" }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await bot.SetMyCommands(
|
||||
commands,
|
||||
scope: new BotCommandScopeAllPrivateChats(),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await bot.SetMyCommands(
|
||||
commands,
|
||||
scope: new BotCommandScopeAllGroupChats(),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
logger.LogInformation("Telegram command menu registered for private chats and groups.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to register Telegram command menu.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -405,19 +405,8 @@ public sealed class TelegramPlatformMessenger(
|
||||
|
||||
Ответьте кнопкой в групповом сообщении расписания.
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
|
||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.JoinLink => $"""
|
||||
🎮 <b>Игра начинается через 5 минут</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder => BuildOneHourReminderDirectText(notification),
|
||||
PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification),
|
||||
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||
|
||||
@@ -434,6 +423,39 @@ public sealed class TelegramPlatformMessenger(
|
||||
_ => 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) =>
|
||||
$"<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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||
@@ -38,7 +72,7 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
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 =>
|
||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
@@ -60,4 +94,14 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
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} мин";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +366,13 @@ public sealed class UpdateRouter(
|
||||
text: """
|
||||
GM-Relay — бот для управления игровыми сессиями.
|
||||
|
||||
/start — начать работу с ботом
|
||||
/newsession — создать новую игровую сессию
|
||||
/listsessions — список предстоящих сессий
|
||||
/exportcalendar — экспортировать расписание в ICS
|
||||
/help — эта справка
|
||||
|
||||
Пример создания сессии:
|
||||
/newsession
|
||||
Название: My Game
|
||||
Время: 15.05.2026 19:30
|
||||
@@ -377,10 +384,8 @@ public sealed class UpdateRouter(
|
||||
Игр: 4
|
||||
Интервал: 7
|
||||
|
||||
/listsessions — список предстоящих сессий
|
||||
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
|
||||
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||||
/help — эта справка
|
||||
""",
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN location_address TEXT;
|
||||
@@ -98,6 +98,7 @@ builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||
builder.Services.AddSingleton<UpdateRouter>();
|
||||
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
||||
builder.Services.AddHostedService<TelegramCommandsSetupService>();
|
||||
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||
builder.Services.AddHostedService<TelegramBotService>();
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordNewSessionHandler _handler;
|
||||
private readonly ILogger<DiscordNewSessionCommand> _logger;
|
||||
|
||||
public DiscordNewSessionCommand(DiscordNewSessionHandler handler, ILogger<DiscordNewSessionCommand> logger)
|
||||
{
|
||||
_handler = handler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[SlashCommand("newsession", "Create a new game session")]
|
||||
public async Task ExecuteAsync(
|
||||
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
|
||||
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
|
||||
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
|
||||
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
|
||||
Context.User.Id,
|
||||
Context.User.GetType().Name,
|
||||
Context.Interaction.GuildId,
|
||||
Context.Channel?.Id);
|
||||
|
||||
var guildId = Context.Interaction.GuildId
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
if (member is null)
|
||||
{
|
||||
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
|
||||
throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||
}
|
||||
|
||||
var resolvedPermissions = (ulong)member.Permissions;
|
||||
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||
|
||||
ulong guildOwnerId = 0;
|
||||
var guildName = guildId.ToString();
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
guildName = guild.Name;
|
||||
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
|
||||
guildId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
|
||||
}
|
||||
|
||||
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
|
||||
if (!timeResult.IsSuccess)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message($"X {timeResult.Error}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer the response to avoid Discord 3-second interaction timeout
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
|
||||
|
||||
var view = await _handler.HandleAsync(
|
||||
guildId: guildId.ToString(),
|
||||
channelId: Context.Channel!.Id.ToString(),
|
||||
groupName: guildName,
|
||||
userId: Context.User.Id,
|
||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: guildOwnerId,
|
||||
title: title,
|
||||
scheduledAt: timeResult.Value,
|
||||
maxPlayers: seats is null ? null : (int)seats.Value,
|
||||
joinLink: link,
|
||||
CancellationToken.None);
|
||||
|
||||
_logger.LogInformation("Session created successfully. Building render.");
|
||||
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
_logger.LogInformation("Sending success response.");
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":white_check_mark: **Session created successfully!**";
|
||||
message.Embeds = embeds;
|
||||
message.Components = actionRows;
|
||||
});
|
||||
|
||||
_logger.LogInformation("Success response sent.");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $":no_entry: {ex.Message}";
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":boom: An error occurred while creating the session.";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using System.Globalization;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
|
||||
|
||||
public sealed class DiscordNewSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
ILogger<DiscordNewSessionHandler> logger)
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
public static TimeParseResult ParseTimeInput(string input)
|
||||
{
|
||||
var trimmed = input.Trim();
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"yyyy-MM-dd HH:mm",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt1))
|
||||
{
|
||||
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"dd.MM.yyyy HH:mm",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt2))
|
||||
{
|
||||
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
||||
}
|
||||
|
||||
public async Task<SessionBatchViewModel> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
string groupName,
|
||||
ulong userId,
|
||||
string userDisplayName,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
string title,
|
||||
DateTimeOffset scheduledAt,
|
||||
int? maxPlayers,
|
||||
string? joinLink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
|
||||
? title
|
||||
: groupName.Trim();
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
{
|
||||
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
var transactionCommitted = false;
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@Name, 'Discord', @UserId, @Name)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE SET display_name = EXCLUDED.display_name,
|
||||
external_username = EXCLUDED.external_username",
|
||||
new { Name = userDisplayName, UserId = userId.ToString() },
|
||||
transaction);
|
||||
|
||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
|
||||
ON CONFLICT (platform, external_group_id)
|
||||
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
||||
DO UPDATE SET name = EXCLUDED.name,
|
||||
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
||||
RETURNING id",
|
||||
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING",
|
||||
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
transaction);
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
||||
RETURNING id",
|
||||
new
|
||||
{
|
||||
BatchId = batchId,
|
||||
GroupId = groupId,
|
||||
Title = title,
|
||||
Link = joinLink ?? string.Empty,
|
||||
ScheduledAt = scheduledAt.UtcDateTime,
|
||||
Status = SessionStatus.Planned,
|
||||
MaxPlayers = maxPlayers
|
||||
},
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
transactionCommitted = true;
|
||||
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
||||
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
||||
var parsedOptions = new List<DateTimeOffset>();
|
||||
foreach (var opt in options)
|
||||
{
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
|
||||
var result = DiscordTimeParser.ParseTimeInput(opt);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
@@ -85,7 +85,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
||||
parsedOptions.Add(result.Value);
|
||||
}
|
||||
|
||||
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
|
||||
var deadlineResult = DiscordTimeParser.ParseTimeInput(deadline);
|
||||
if (!deadlineResult.IsSuccess)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
return;
|
||||
|
||||
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();
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
|
||||
|
||||
public static class DiscordTimeParser
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
public static TimeParseResult ParseTimeInput(string input)
|
||||
{
|
||||
var trimmed = input.Trim();
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"yyyy-MM-dd HH:mm",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt1))
|
||||
{
|
||||
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"dd.MM.yyyy HH:mm",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt2))
|
||||
{
|
||||
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,9 @@ using Npgsql;
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Small lookup helper for Discord permission checks. The
|
||||
/// <see cref="DiscordNewSessionHandler"/> already runs the same SQL
|
||||
/// inline; this class is here so the wizard slash command can do the
|
||||
/// same check without duplicating the query string.
|
||||
/// Small lookup helper for Discord permission checks. The slash command
|
||||
/// and reschedule command both need to enumerate DB managers for a guild;
|
||||
/// this class centralises the query so it isn't duplicated.
|
||||
/// </summary>
|
||||
internal static class DiscordPermissionLookup
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Slash entry point for the Discord wizard. Mirrors the Telegram
|
||||
/// <c>/newsession-wizard</c> command: a fresh draft is created on
|
||||
/// <c>/newsession</c> command: a fresh draft is created on
|
||||
/// first invocation, the persisted first-step message is re-shown
|
||||
/// when the user already has an active draft, and the owner/co-GM
|
||||
/// permission check from <see cref="DiscordPermissionChecker"/> is
|
||||
@@ -44,7 +44,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
|
||||
[SlashCommand("newsession", "Пошаговое создание игры или пула")]
|
||||
public async Task ExecuteAsync(
|
||||
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
|
||||
{
|
||||
|
||||
@@ -132,6 +132,7 @@ public sealed class WizardInteractionDispatcher
|
||||
WizardStepNames.Cover,
|
||||
WizardStepNames.DateTime,
|
||||
WizardStepNames.Capacity,
|
||||
WizardStepNames.Location,
|
||||
WizardStepNames.PoolSlotDateTime,
|
||||
WizardStepNames.PoolSlotCapacity,
|
||||
"SystemFreeText",
|
||||
@@ -166,7 +167,7 @@ public sealed class WizardInteractionDispatcher
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
@@ -276,14 +277,14 @@ public sealed class WizardInteractionDispatcher
|
||||
// itself doesn't know about resume, so we just edit the
|
||||
// draft message via the messenger).
|
||||
// resume:restart → delete the draft and prompt the user to
|
||||
// re-run /newsession-wizard.
|
||||
// re-run /newsession.
|
||||
if (parts.Length >= 3 && parts[2] == "restart")
|
||||
{
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
|
||||
.WithContent("♻️ Мастер сброшен. Запустите /newsession заново.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
@@ -314,7 +315,7 @@ public sealed class WizardInteractionDispatcher
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
@@ -365,7 +366,7 @@ public sealed class WizardInteractionDispatcher
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ public static class DiscordWizardStep
|
||||
WizardStepNames.Duration => RenderDuration(),
|
||||
WizardStepNames.DateTime => RenderDateTime(),
|
||||
WizardStepNames.Capacity => RenderCapacity(),
|
||||
WizardStepNames.Format => RenderFormat(),
|
||||
WizardStepNames.Location => RenderLocation(payload),
|
||||
WizardStepNames.Visibility => RenderVisibility(),
|
||||
WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty<WizardClubOption>()),
|
||||
WizardStepNames.Publish => RenderPublish(),
|
||||
@@ -263,6 +265,29 @@ public static class DiscordWizardStep
|
||||
},
|
||||
OpenModalStep: WizardStepNames.Capacity);
|
||||
|
||||
private static DiscordWizardRender RenderFormat() => new(
|
||||
"🧭 Формат игры",
|
||||
"Выберите формат.",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("🌐 Online", WizardStepNames.Format, "online", ButtonStyle.Primary),
|
||||
ChoiceBtn("📍 Offline", WizardStepNames.Format, "offline", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderLocation(WizardPayload payload)
|
||||
{
|
||||
var isOnline = payload.Format == WizardSessionFormat.Online;
|
||||
return new DiscordWizardRender(
|
||||
isOnline ? "🔗 Ссылка" : "📍 Адрес",
|
||||
isOnline ? "Введите ссылку для подключения." : "Введите адрес места проведения.",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.Location);
|
||||
}
|
||||
|
||||
private static DiscordWizardRender RenderVisibility() => new(
|
||||
"🔒 Видимость",
|
||||
"Выберите, кто увидит сессию.",
|
||||
@@ -566,6 +591,20 @@ public static class DiscordWizardStep
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.Location => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Location),
|
||||
"🔗 Ссылка / 📍 Адрес",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Ссылка или адрес",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Location), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "https://… или адрес",
|
||||
MaxLength = WizardStepLimits.MaxLocationLength,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.PoolSlotDateTime => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.PoolSlotDateTime),
|
||||
"📅 Дата/время слота",
|
||||
|
||||
@@ -65,19 +65,32 @@ public sealed class DiscordWizardSubmitter
|
||||
return;
|
||||
}
|
||||
|
||||
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||
try
|
||||
{
|
||||
var commands = BuildCommands(draft, payload);
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
await _shared.HandleAsync(cmd, ct);
|
||||
var result = await _shared.HandleAsync(cmd, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
created.Add((cmd, result));
|
||||
}
|
||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
|
||||
// Success: replace the wizard message with a confirmation and
|
||||
// clean up the draft so the user can start a new one later.
|
||||
var confirmation = created.Count == 1
|
||||
? $"✅ Создано: {created[0].Command.Title}"
|
||||
: $"✅ Создано: {created[0].Command.Title} и ещё {created.Count - 1} сессия/сессии";
|
||||
await EditDraftMessageAsync(draft, confirmation, Array.Empty<WizardAction>(), ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
}
|
||||
@@ -90,7 +103,7 @@ public sealed class DiscordWizardSubmitter
|
||||
{
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
|
||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
@@ -140,7 +153,7 @@ public sealed class DiscordWizardSubmitter
|
||||
}
|
||||
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
pool.MaxPlayers ?? (pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers));
|
||||
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
@@ -164,15 +177,16 @@ public sealed class DiscordWizardSubmitter
|
||||
User: user,
|
||||
Group: group,
|
||||
Title: p.Title ?? string.Empty,
|
||||
Link: string.Empty,
|
||||
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
|
||||
ScheduledTimes: scheduledTimes,
|
||||
MaxPlayers: maxPlayers,
|
||||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||
System: ParseSystem(p.System),
|
||||
Description: p.Description,
|
||||
Format: null,
|
||||
Format: p.Format?.ToString(),
|
||||
DurationMinutes: p.DurationMinutes,
|
||||
IsOneShot: isOneShot);
|
||||
IsOneShot: isOneShot,
|
||||
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
|
||||
}
|
||||
|
||||
private static GameSystem? ParseSystem(string? code)
|
||||
@@ -188,12 +202,15 @@ public sealed class DiscordWizardSubmitter
|
||||
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||||
if (string.IsNullOrWhiteSpace(p.System)) 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.Type == WizardCreationType.Single)
|
||||
{
|
||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
||||
// MaxPlayers = null is a valid "♾ Без лимита" choice.
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -61,7 +61,6 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||
|
||||
@@ -57,6 +57,7 @@ public sealed class SendJoinLinkHandler(
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND s.status = @Confirmed
|
||||
AND btrim(s.join_link) <> ''
|
||||
AND (
|
||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||
OR (
|
||||
|
||||
@@ -15,4 +15,5 @@ public sealed record CreateSessionCommand(
|
||||
string? Description = null,
|
||||
string? Format = 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
|
||||
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);
|
||||
}
|
||||
else
|
||||
@@ -118,8 +124,8 @@ public sealed class CreateSessionHandler(
|
||||
{
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
|
||||
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, @LocationAddress)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
@@ -136,11 +142,23 @@ public sealed class CreateSessionHandler(
|
||||
command.Format,
|
||||
DurationMinutes = command.DurationMinutes,
|
||||
IsOneShot = command.IsOneShot,
|
||||
CoverImageUrl = command.ImageReference
|
||||
CoverImageUrl = command.ImageReference,
|
||||
command.LocationAddress
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class JoinSessionHandler(
|
||||
|
||||
// Загружаем весь батч для перерисовки
|
||||
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
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at",
|
||||
|
||||
@@ -161,7 +161,9 @@ public sealed class LeaveSessionHandler(
|
||||
scheduled_at AS ScheduledAt,
|
||||
status AS Status,
|
||||
max_players AS MaxPlayers,
|
||||
join_link AS JoinLink
|
||||
join_link AS JoinLink,
|
||||
format AS Format,
|
||||
location_address AS LocationAddress
|
||||
FROM sessions
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at
|
||||
|
||||
@@ -224,11 +224,31 @@ public sealed class GameCreationWizard
|
||||
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
|
||||
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||
|
||||
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
||||
case WizardStepNames.Capacity:
|
||||
if (payload.Type == WizardCreationType.Pool)
|
||||
{
|
||||
if (payload.Pool?.MaxPlayers is not null) return (null, "Лимит уже задан", payload);
|
||||
return int.TryParse(input, out var poolCap) && poolCap >= WizardStepLimits.MinCapacity && poolCap <= WizardStepLimits.MaxCapacity
|
||||
? (WizardStepNames.Format, SetPoolMaxPlayers(payload, poolCap), payload)
|
||||
: (null, "Лимит должен быть 1..50", payload);
|
||||
}
|
||||
|
||||
if (payload.Single?.MaxPlayers is not null) return (null, "Лимит уже задан", payload);
|
||||
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);
|
||||
|
||||
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:
|
||||
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
||||
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
||||
@@ -236,7 +256,7 @@ public sealed class GameCreationWizard
|
||||
|
||||
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
||||
return TryParseHours(input, out var pdur)
|
||||
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
|
||||
? (WizardStepNames.Capacity, SetDurationMinutes(payload, pdur), payload)
|
||||
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||
|
||||
case WizardStepNames.PoolSlotDateTime:
|
||||
@@ -264,6 +284,7 @@ public sealed class GameCreationWizard
|
||||
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
||||
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
|
||||
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
|
||||
WizardStepNames.Format => ApplyFormatChoice(payload, choice),
|
||||
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
|
||||
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
|
||||
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
|
||||
@@ -302,22 +323,31 @@ public sealed class GameCreationWizard
|
||||
{
|
||||
if (choice is "no_limit")
|
||||
{
|
||||
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
|
||||
return p.Type == WizardCreationType.Pool
|
||||
? (WizardStepNames.Format, SetPoolMaxPlayers(p, null))
|
||||
: (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.Type != WizardCreationType.Pool && p.Single?.MaxPlayers is null)
|
||||
{
|
||||
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||
}
|
||||
|
||||
return choice switch
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||
"waitlist:on" => (WizardStepNames.Format, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Format, SetWaitlist(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyFormatChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"online" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Online)),
|
||||
"offline" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Offline)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
|
||||
@@ -349,11 +379,12 @@ public sealed class GameCreationWizard
|
||||
{
|
||||
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
||||
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
|
||||
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
|
||||
? (WizardStepNames.Capacity, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
|
||||
: (null, "Неверный выбор"),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
|
||||
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
"add" => BeginNewPoolSlot(p),
|
||||
@@ -390,14 +421,16 @@ public sealed class GameCreationWizard
|
||||
WizardStepNames.System => WizardStepNames.Cover,
|
||||
WizardStepNames.Duration => WizardStepNames.System,
|
||||
WizardStepNames.DateTime => WizardStepNames.Duration,
|
||||
WizardStepNames.Capacity => WizardStepNames.DateTime,
|
||||
WizardStepNames.Visibility => WizardStepNames.Capacity,
|
||||
WizardStepNames.Capacity => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.DateTime,
|
||||
WizardStepNames.Format => WizardStepNames.Capacity,
|
||||
WizardStepNames.Location => WizardStepNames.Format,
|
||||
WizardStepNames.Visibility => WizardStepNames.Location,
|
||||
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
||||
WizardStepNames.Publish => WizardStepNames.PickClub,
|
||||
WizardStepNames.Confirm => WizardStepNames.Publish,
|
||||
|
||||
WizardStepNames.PoolSystemDuration => null, // first pool step
|
||||
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
|
||||
WizardStepNames.PoolAddSlots => WizardStepNames.Visibility,
|
||||
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
|
||||
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
|
||||
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
|
||||
@@ -437,11 +470,22 @@ public sealed class GameCreationWizard
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
||||
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
||||
private static string? SetPoolMaxPlayers(WizardPayload p, int? v)
|
||||
{ p.Pool ??= new WizardPoolInput(); p.Pool.MaxPlayers = v; return null; }
|
||||
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
|
||||
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
||||
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
|
||||
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
|
||||
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
|
||||
private static string? 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)
|
||||
{
|
||||
@@ -488,8 +532,8 @@ public sealed class GameCreationWizard
|
||||
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
|
||||
private static string? NextAfterDuration(WizardPayload p)
|
||||
{
|
||||
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
|
||||
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
|
||||
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Capacity;
|
||||
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
|
||||
}
|
||||
private static string? NextAfterVisibility(WizardPayload p)
|
||||
{
|
||||
@@ -500,6 +544,7 @@ public sealed class GameCreationWizard
|
||||
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
|
||||
}
|
||||
|
||||
|
||||
private static (string? sys, int? dur) SplitSystemDuration(string s)
|
||||
{
|
||||
var idx = s.IndexOf(':');
|
||||
|
||||
@@ -8,6 +8,9 @@ public enum WizardCreationType { Single, Pool }
|
||||
|
||||
public enum WizardVisibility { Public, Club, Members }
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<WizardSessionFormat>))]
|
||||
public enum WizardSessionFormat { Online, Offline }
|
||||
|
||||
public sealed class WizardSlotInput
|
||||
{
|
||||
public DateTimeOffset ScheduledAt { get; set; }
|
||||
@@ -30,6 +33,9 @@ public sealed class WizardPayload
|
||||
public string? ImageUrl { get; set; }
|
||||
public string? System { 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 Guid? ClubId { get; set; }
|
||||
public bool? PublishInShowcase { get; set; }
|
||||
@@ -44,6 +50,8 @@ public sealed class WizardPayload
|
||||
|
||||
public sealed class WizardPoolInput
|
||||
{
|
||||
public int? MaxPlayers { get; set; }
|
||||
|
||||
public List<WizardSlotInput> Slots { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,5 @@ public static class WizardStepLimits
|
||||
public const int MinCapacity = 1;
|
||||
public const int MinDurationHours = 1;
|
||||
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 DateTime = "DateTime";
|
||||
public const string Capacity = "Capacity";
|
||||
public const string Format = "Format";
|
||||
public const string Location = "Location";
|
||||
public const string Visibility = "Visibility";
|
||||
public const string PickClub = "PickClub";
|
||||
public const string Publish = "Publish";
|
||||
|
||||
@@ -30,6 +30,8 @@ public static class WizardStepViewBuilder
|
||||
WizardStepNames.Duration => BuildDuration(),
|
||||
WizardStepNames.DateTime => BuildDateTime(),
|
||||
WizardStepNames.Capacity => BuildCapacity(),
|
||||
WizardStepNames.Format => BuildFormat(),
|
||||
WizardStepNames.Location => BuildLocation(payload),
|
||||
WizardStepNames.Visibility => BuildVisibility(),
|
||||
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
||||
WizardStepNames.Publish => BuildPublish(),
|
||||
@@ -105,6 +107,22 @@ public static class WizardStepViewBuilder
|
||||
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildFormat() => (
|
||||
"🧭 Выберите формат игры.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("🌐 Online", WizardCallbackData.Choice(WizardStepNames.Format, "online"), WizardActionStyle.Primary),
|
||||
new("📍 Offline", WizardCallbackData.Choice(WizardStepNames.Format, "offline"), WizardActionStyle.Primary),
|
||||
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildLocation(WizardPayload payload) => payload.Format switch
|
||||
{
|
||||
WizardSessionFormat.Offline => ("📍 Введите адрес места проведения.", BackCancel()),
|
||||
_ => ("🔗 Введите ссылку для подключения к online-игре.", BackCancel()),
|
||||
};
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||
"🔒 Выберите видимость.",
|
||||
new List<WizardAction>
|
||||
@@ -150,6 +168,7 @@ public static class WizardStepViewBuilder
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||
AppendFormatLocation(sb, p);
|
||||
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
|
||||
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
|
||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||
@@ -204,6 +223,8 @@ public static class WizardStepViewBuilder
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||
AppendFormatLocation(sb, p);
|
||||
if (p.Pool?.MaxPlayers is { } poolMax) sb.AppendLine($"👥 Мест в пуле: {poolMax}");
|
||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
||||
@@ -245,4 +266,19 @@ public static class WizardStepViewBuilder
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,17 @@ using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||
public sealed record SessionListItemDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int PlayerCount,
|
||||
int WaitlistCount,
|
||||
bool CanManage,
|
||||
bool IsUserActive,
|
||||
bool IsUserWaitlisted);
|
||||
|
||||
public sealed record SessionListResult(
|
||||
IReadOnlyList<SessionListItemDto> Sessions,
|
||||
@@ -29,7 +39,27 @@ public sealed class ListSessionsHandler(
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.platform = @Platform
|
||||
AND manager_player.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
) AS CanManage,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM session_participants user_sp
|
||||
JOIN players user_p ON user_p.id = user_sp.player_id
|
||||
WHERE user_sp.session_id = s.id
|
||||
AND user_sp.is_gm = false
|
||||
AND user_sp.registration_status = @Active
|
||||
AND user_p.platform = @Platform
|
||||
AND user_p.external_user_id = @ExternalUserId
|
||||
) AS IsUserActive,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM session_participants user_sp
|
||||
JOIN players user_p ON user_p.id = user_sp.player_id
|
||||
WHERE user_sp.session_id = s.id
|
||||
AND user_sp.is_gm = false
|
||||
AND user_sp.registration_status = @Waitlisted
|
||||
AND user_p.platform = @Platform
|
||||
AND user_p.external_user_id = @ExternalUserId
|
||||
) AS IsUserWaitlisted
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
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();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
|
||||
@@ -81,6 +81,7 @@ public sealed class DbSessionTriggerStore(
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE g.platform = @Platform
|
||||
AND s.status = @Confirmed
|
||||
AND btrim(s.join_link) <> ''
|
||||
AND s.scheduled_at - @LeadTime <= @Now
|
||||
AND (
|
||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
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);
|
||||
|
||||
@@ -39,6 +39,12 @@ public static class SessionBatchViewBuilder
|
||||
session.Status,
|
||||
session.MaxPlayers,
|
||||
session.JoinLink,
|
||||
session.Format,
|
||||
session.LocationAddress,
|
||||
session.Description,
|
||||
session.System,
|
||||
session.DurationMinutes,
|
||||
session.IsOneShot,
|
||||
activePlayers.Count,
|
||||
activePlayers,
|
||||
waitlistedPlayers,
|
||||
|
||||
@@ -12,6 +12,12 @@ public sealed record SessionViewItem(
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
string JoinLink,
|
||||
string? Format,
|
||||
string? LocationAddress,
|
||||
string? Description,
|
||||
string? System,
|
||||
int? DurationMinutes,
|
||||
bool IsOneShot,
|
||||
int ActivePlayerCount,
|
||||
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
||||
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.9.5</div>
|
||||
<div class="nav-version">v3.11.1</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -119,7 +119,14 @@ internal sealed record WebBatchSessionRow(
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
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 WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||
internal sealed record WebPublicGroupRow(
|
||||
@@ -1508,7 +1515,14 @@ public sealed class SessionService(
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
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
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.batch_id = @BatchId
|
||||
@@ -1536,8 +1550,14 @@ public sealed class SessionService(
|
||||
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
||||
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)
|
||||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
|
||||
INSERT INTO sessions (
|
||||
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
|
||||
""",
|
||||
new
|
||||
@@ -1551,11 +1571,29 @@ public sealed class SessionService(
|
||||
ThreadId = threadId,
|
||||
sourceSession.TopicCreatedByBot,
|
||||
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);
|
||||
|
||||
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();
|
||||
@@ -1770,7 +1808,18 @@ public sealed class SessionService(
|
||||
},
|
||||
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();
|
||||
@@ -1897,7 +1946,7 @@ public sealed class SessionService(
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
|
||||
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();
|
||||
|
||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||
|
||||
@@ -16,15 +16,49 @@ public static class TelegramSessionBatchRenderer
|
||||
foreach (var session in view.Sessions)
|
||||
{
|
||||
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)
|
||||
{
|
||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||
@@ -37,7 +71,7 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
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 =>
|
||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
@@ -59,4 +93,14 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
|
||||
private static string FormatDuration(int minutes)
|
||||
{
|
||||
if (minutes <= 0) return "0 мин";
|
||||
var hours = minutes / 60;
|
||||
var mins = minutes % 60;
|
||||
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
|
||||
if (hours > 0) return $"{hours} ч";
|
||||
return $"{mins} мин";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordNewSessionHandlerTests
|
||||
{
|
||||
private static string GetRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
}
|
||||
|
||||
// --- Runtime tests for ParseTimeInput (static, no DB) ---
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
|
||||
{
|
||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput(
|
||||
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// 15:00 MSK = 12:00 UTC
|
||||
Assert.Equal(12, result.Value.Hour);
|
||||
Assert.Equal(0, result.Value.Minute);
|
||||
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
||||
{
|
||||
var expected = FutureDateAt1930();
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput(
|
||||
expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(expected.Year, result.Value.Year);
|
||||
Assert.Equal(expected.Month, result.Value.Month);
|
||||
Assert.Equal(expected.Day, result.Value.Day);
|
||||
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
|
||||
Assert.Equal(16, result.Value.Hour);
|
||||
Assert.Equal(30, result.Value.Minute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldRejectPastDate()
|
||||
{
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldParseRussianDateFormat()
|
||||
{
|
||||
var expected = FutureDateAt1930();
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput(
|
||||
expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(expected.Year, result.Value.Year);
|
||||
Assert.Equal(expected.Month, result.Value.Month);
|
||||
Assert.Equal(expected.Day, result.Value.Day);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldRejectInvalidFormat()
|
||||
{
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
|
||||
// --- Source-level structural tests ---
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldExist()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
Assert.True(File.Exists(handlerPath), "DiscordNewSessionHandler should exist.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldUseDapperForDatabaseAccess()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("QueryAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ExecuteAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ExecuteScalarAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldUseNpgsqlDataSource()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("NpgsqlDataSource", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldCheckPermissionsViaPermissionChecker()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("CanManageSchedule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldLoadCoGmPermissionsFromDiscordPlayers()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Matches(
|
||||
@"QueryAsync<ulong>[\s\S]*JOIN players p ON p\.id = gm\.player_id[\s\S]*p\.platform = 'Discord'[\s\S]*g\.external_group_id = @GuildId",
|
||||
source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldBePlatformNeutral()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.DoesNotContain("telegram_chat_id", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("telegram_id", source, StringComparison.Ordinal);
|
||||
Assert.Contains("platform = 'Discord'", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldUseTransactions()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("BeginTransactionAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("CommitAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
|
||||
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
|
||||
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldRespectCancellationToken()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("CancellationToken", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_ShouldRenderEmbedOnSuccess()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionCommand.cs");
|
||||
var source = File.ReadAllText(commandPath);
|
||||
|
||||
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
|
||||
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("groupName", source, StringComparison.Ordinal);
|
||||
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
|
||||
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static DateTimeOffset FutureDateAt1930()
|
||||
{
|
||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||
return new DateTimeOffset(
|
||||
future.Year,
|
||||
future.Month,
|
||||
future.Day,
|
||||
19,
|
||||
30,
|
||||
0,
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
@@ -54,7 +55,6 @@ public sealed class DiscordStartupTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(DiscordNewSessionCommand), "newsession")]
|
||||
[InlineData(typeof(DiscordListSessionsCommand), "listsessions")]
|
||||
[InlineData(typeof(DiscordRescheduleCommand), "reschedule")]
|
||||
public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName)
|
||||
@@ -76,15 +76,28 @@ public sealed class DiscordStartupTests
|
||||
{
|
||||
var service = new ApplicationCommandService<SlashCommandContext>();
|
||||
|
||||
service.AddModules(typeof(DiscordNewSessionCommand).Assembly);
|
||||
service.AddModules(typeof(DiscordListSessionsCommand).Assembly);
|
||||
|
||||
var commandNames = service.GetCommands()
|
||||
.Select(command => command.Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("listsessions", commandNames);
|
||||
Assert.Contains("reschedule", commandNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscordSessionSlashCommands_ShouldIncludeNewSessionWizard()
|
||||
{
|
||||
var service = new ApplicationCommandService<SlashCommandContext>();
|
||||
|
||||
service.AddModules(typeof(DiscordWizardCommand).Assembly);
|
||||
|
||||
var commandNames = service.GetCommands()
|
||||
.Select(command => command.Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("newsession", commandNames);
|
||||
Assert.Contains("listsessions", commandNames);
|
||||
Assert.Contains("reschedule", commandNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -114,7 +127,6 @@ public sealed class DiscordStartupTests
|
||||
{
|
||||
var program = ReadProgram();
|
||||
Assert.Contains("DiscordListSessionsHandler", program);
|
||||
Assert.Contains("DiscordNewSessionHandler", program);
|
||||
Assert.Contains("JoinSessionHandler", program);
|
||||
Assert.Contains("LeaveSessionHandler", program);
|
||||
Assert.Contains("DiscordPermissionChecker", program);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordTimeParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
|
||||
{
|
||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||
var result = DiscordTimeParser.ParseTimeInput(
|
||||
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// 15:00 MSK = 12:00 UTC
|
||||
Assert.Equal(12, result.Value.Hour);
|
||||
Assert.Equal(0, result.Value.Minute);
|
||||
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
||||
{
|
||||
var expected = FutureDateAt1930();
|
||||
var result = DiscordTimeParser.ParseTimeInput(
|
||||
expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(expected.Year, result.Value.Year);
|
||||
Assert.Equal(expected.Month, result.Value.Month);
|
||||
Assert.Equal(expected.Day, result.Value.Day);
|
||||
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
|
||||
Assert.Equal(16, result.Value.Hour);
|
||||
Assert.Equal(30, result.Value.Minute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldRejectPastDate()
|
||||
{
|
||||
var result = DiscordTimeParser.ParseTimeInput("2020-01-01 00:00");
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldParseRussianDateFormat()
|
||||
{
|
||||
var expected = FutureDateAt1930();
|
||||
var result = DiscordTimeParser.ParseTimeInput(
|
||||
expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(expected.Year, result.Value.Year);
|
||||
Assert.Equal(expected.Month, result.Value.Month);
|
||||
Assert.Equal(expected.Day, result.Value.Day);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldRejectInvalidFormat()
|
||||
{
|
||||
var result = DiscordTimeParser.ParseTimeInput("not-a-date");
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
|
||||
private static DateTimeOffset FutureDateAt1930()
|
||||
{
|
||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||
return new DateTimeOffset(
|
||||
future.Year,
|
||||
future.Month,
|
||||
future.Day,
|
||||
19,
|
||||
30,
|
||||
0,
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,29 @@ public sealed class DiscordWizardStepCapacityRenderTests
|
||||
.Select(b => b.Label ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
[Fact]
|
||||
public void RenderFormat_ContainsOnlineAndOfflineButtons()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Format };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Online", System.StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Offline", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WizardSessionFormat.Online, "🔗 Ссылка")]
|
||||
[InlineData(WizardSessionFormat.Offline, "📍 Адрес")]
|
||||
public void RenderLocation_ForFormat_OpensModalAndShowsPrompt(WizardSessionFormat format, string expectedTitle)
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Location };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload { Format = format });
|
||||
|
||||
Assert.Equal(expectedTitle, render.EmbedTitle);
|
||||
Assert.Equal(WizardStepNames.Location, render.OpenModalStep);
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
|
||||
@@ -82,4 +82,117 @@ public sealed class DiscordWizardSubmitterBuildCommandTests
|
||||
|
||||
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 = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.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 = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal("Offline", cmd.Format);
|
||||
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
|
||||
Assert.Equal(string.Empty, cmd.Link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenPoolMaxPlayersIsSet_PropagatesValueToMaxPlayers()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var slotTime = DateTimeOffset.UtcNow.AddDays(1);
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Pool,
|
||||
Title = "Pool",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Pool = new WizardPoolInput
|
||||
{
|
||||
MaxPlayers = 12,
|
||||
Slots = { new WizardSlotInput { ScheduledAt = slotTime, MaxPlayers = 8 } },
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { slotTime },
|
||||
payload.Pool.MaxPlayers,
|
||||
isOneShot: false);
|
||||
|
||||
Assert.Equal(12, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture<CreateSessionHandlerPostgresFixture>
|
||||
{
|
||||
public const string Name = "Create session handler PostgreSQL";
|
||||
}
|
||||
|
||||
public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return container.StartAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public async Task<string> CreateMigratedDatabaseAsync()
|
||||
{
|
||||
var databaseName = $"create_session_{Guid.NewGuid():N}";
|
||||
|
||||
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
|
||||
{
|
||||
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
|
||||
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
|
||||
{
|
||||
Database = databaseName,
|
||||
Timeout = 10,
|
||||
CommandTimeout = 30
|
||||
}.ConnectionString;
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
|
||||
foreach (var migration in GetMigrationPaths())
|
||||
{
|
||||
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
|
||||
{
|
||||
CommandTimeout = 30
|
||||
};
|
||||
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetMigrationPaths()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
|
||||
if (Directory.Exists(migrationsDirectory))
|
||||
{
|
||||
return Directory.GetFiles(migrationsDirectory, "V*.sql")
|
||||
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
|
||||
}
|
||||
}
|
||||
|
||||
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||
public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPostgresFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleAsync_NewPlatformGroup_AddsOwnerAndPersistsSession()
|
||||
{
|
||||
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
var sut = new CreateSessionHandler(dataSource);
|
||||
|
||||
var result = await sut.HandleAsync(
|
||||
new CreateSessionCommand(
|
||||
new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"),
|
||||
new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"),
|
||||
"Test Adventure",
|
||||
"https://vtt.example/game",
|
||||
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||
null,
|
||||
null,
|
||||
GameSystem.Dnd5e,
|
||||
"Integration regression test",
|
||||
"Online",
|
||||
240,
|
||||
true,
|
||||
"Online room notes"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(result.BatchId);
|
||||
Assert.NotNull(result.GroupId);
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync();
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @group_id
|
||||
AND gm.role = 'Owner'
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = '111111111'
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("group_id", result.GroupId.Value);
|
||||
|
||||
var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
|
||||
Assert.Equal(1, ownerCount);
|
||||
|
||||
await using var sessionCommand = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT join_link, format, location_address
|
||||
FROM sessions
|
||||
WHERE batch_id = @batch_id
|
||||
""",
|
||||
connection);
|
||||
sessionCommand.Parameters.AddWithValue("batch_id", result.BatchId.Value);
|
||||
|
||||
await using var reader = await sessionCommand.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.Equal("https://vtt.example/game", reader.GetString(0));
|
||||
Assert.Equal("Online", reader.GetString(1));
|
||||
Assert.Equal("Online room notes", reader.GetString(2));
|
||||
Assert.False(await reader.ReadAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_OfflineSession_PersistsFormatAndLocationAddress()
|
||||
{
|
||||
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
var sut = new CreateSessionHandler(dataSource);
|
||||
|
||||
var result = await sut.HandleAsync(
|
||||
new CreateSessionCommand(
|
||||
new PlatformUser(PlatformKind.Telegram, "333333333", "Offline GM", "offline_gm"),
|
||||
new PlatformGroup(PlatformKind.Telegram, "444444444", "Offline Group"),
|
||||
"Offline Adventure",
|
||||
string.Empty,
|
||||
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||
4,
|
||||
null,
|
||||
GameSystem.Dnd5e,
|
||||
"Offline integration regression test",
|
||||
"Offline",
|
||||
240,
|
||||
true,
|
||||
"Москва, ул. Кубиков, 12"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(result.BatchId);
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync();
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT join_link, format, location_address
|
||||
FROM sessions
|
||||
WHERE batch_id = @batch_id
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("batch_id", result.BatchId.Value);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.Equal(string.Empty, reader.GetString(0));
|
||||
Assert.Equal("Offline", reader.GetString(1));
|
||||
Assert.Equal("Москва, ул. Кубиков, 12", reader.GetString(2));
|
||||
Assert.False(await reader.ReadAsync());
|
||||
}
|
||||
}
|
||||
+76
@@ -82,4 +82,80 @@ public sealed class CreateSessionHandlerBuildCommandTests
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+102
-14
@@ -1,19 +1,107 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
|
||||
/// 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
|
||||
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||
public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture)
|
||||
{
|
||||
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
|
||||
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
|
||||
throw new NotImplementedException("See Skip reason above.");
|
||||
[Fact]
|
||||
public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs()
|
||||
{
|
||||
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
var drafts = new FakeWizardDraftRepository();
|
||||
var wizardMessenger = new FakeWizardMessenger();
|
||||
var platformMessenger = new FakePlatformMessenger();
|
||||
|
||||
var sut = new CreateSessionHandler(
|
||||
drafts,
|
||||
new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler(dataSource),
|
||||
wizardMessenger,
|
||||
NullLogger<CreateSessionHandler>.Instance,
|
||||
platformMessenger,
|
||||
dataSource);
|
||||
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "Тест публикации",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Confirm, payload, ownerId: 111111111);
|
||||
draft.ChatId = "-1003916537960";
|
||||
draft.DraftMessageId = "7";
|
||||
drafts.Seed(draft);
|
||||
|
||||
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||
|
||||
Assert.Single(platformMessenger.CreatedThreads);
|
||||
Assert.Equal("Тест публикации", platformMessenger.CreatedThreads[0].Title);
|
||||
Assert.Single(platformMessenger.SentSchedules);
|
||||
Assert.Equal("456", platformMessenger.SentSchedules[0].Group.ExternalThreadId);
|
||||
Assert.Contains(draft.Id, drafts.DeletedIds);
|
||||
Assert.Contains(wizardMessenger.Edits, edit => edit.Text.Contains("✅ Создано: 1 сессия", StringComparison.Ordinal));
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync();
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT thread_id, batch_message_id, topic_created_by_bot
|
||||
FROM sessions
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.Equal(456, reader.GetInt32(0));
|
||||
Assert.Equal(789, reader.GetInt32(1));
|
||||
Assert.True(reader.GetBoolean(2));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakePlatformMessenger : IPlatformMessenger
|
||||
{
|
||||
public List<(PlatformGroup Group, string Title)> CreatedThreads { get; } = new();
|
||||
|
||||
public List<PlatformScheduleMessage> SentSchedules { get; } = new();
|
||||
|
||||
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||
{
|
||||
CreatedThreads.Add((group, title));
|
||||
return Task.FromResult(new PlatformMessageRef(group.Platform, group.ExternalGroupId, "456", string.Empty));
|
||||
}
|
||||
|
||||
public Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
SentSchedules.Add(message);
|
||||
return Task.FromResult(new PlatformMessageRef(
|
||||
message.Group.Platform,
|
||||
message.Group.ExternalGroupId,
|
||||
message.Group.ExternalThreadId,
|
||||
"789"));
|
||||
}
|
||||
|
||||
public Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
+53
@@ -36,6 +36,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
@@ -69,6 +71,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
@@ -104,6 +108,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput { MaxPlayers = 4 },
|
||||
};
|
||||
@@ -135,6 +141,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Title = "P",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Pool = new WizardPoolInput(),
|
||||
};
|
||||
@@ -146,4 +154,49 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Assert.Single(messenger.Edits);
|
||||
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitDraftAsync_SingleWithNoLimit_DoesNotReportMaxPlayersAsMissing()
|
||||
{
|
||||
// Regression for #131: pressing "♾ Без лимита" sets MaxPlayers = null.
|
||||
// IsComplete must NOT flag that as a missing field; null means
|
||||
// "no player limit" and is a valid final state.
|
||||
var drafts = new FakeWizardDraftRepository();
|
||||
var messenger = new FakeWizardMessenger();
|
||||
|
||||
var sut = new CreateSessionHandler(
|
||||
drafts,
|
||||
shared: null!,
|
||||
messenger,
|
||||
NullLogger<CreateSessionHandler>.Instance);
|
||||
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Format = WizardSessionFormat.Online,
|
||||
JoinLink = "https://vtt.example/game",
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Confirm, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||
|
||||
// Validation must let the no-limit payload through. The shared
|
||||
// handler is null, so anything that reached the database call would
|
||||
// throw a NullReferenceException — that is caught by the retry
|
||||
// path and reported as a "💥 Ошибка:" edit, not a missing-fields
|
||||
// edit. Therefore we assert that NO edit mentions a missing field.
|
||||
Assert.NotEmpty(messenger.Edits);
|
||||
var lastEdit = messenger.Edits[^1].Text;
|
||||
Assert.DoesNotContain("Не заполнены", lastEdit, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -84,17 +84,24 @@ public sealed class GameCreationWizardCancelBackTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
|
||||
public async Task Back_FromPoolAddSlots_GoesToVisibility()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
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);
|
||||
|
||||
var data = WizardCallbackData.Back();
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+176
-5
@@ -20,8 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
||||
// Duration → DateTime (single, no maxPlayers yet)
|
||||
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
||||
// Capacity → Visibility (only explicit no-limit can skip numeric capacity)
|
||||
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
|
||||
// Capacity → Format (only explicit no-limit can skip numeric capacity)
|
||||
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
|
||||
// Visibility → Publish (public, no club)
|
||||
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
||||
// Visibility → PickClub
|
||||
@@ -46,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
|
||||
public async Task PoolSystemDuration_PreselectedButton_AdvancesToCapacity()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = new WizardPayload
|
||||
@@ -60,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("system", out var sys));
|
||||
@@ -69,6 +69,95 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
Assert.Equal(240, dur.GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PoolCapacity_Text_AdvancesToFormat_AndSetsMaxPlayers()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Pool,
|
||||
Title = "Pool",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Capacity, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
await wizard.HandleInteractionAsync(TextInteraction("10", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Format, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("pool", out var pool));
|
||||
Assert.True(pool.TryGetProperty("maxPlayers", out var maxPlayers));
|
||||
Assert.Equal(10, maxPlayers.GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PoolSystemDuration_FreeTextDuration_AdvancesToCapacity()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Pool,
|
||||
Title = "Pool",
|
||||
System = "Dnd5e",
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
await wizard.HandleInteractionAsync(TextInteraction("4", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("durationMinutes", out var dur));
|
||||
Assert.Equal(240, dur.GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Back_FromPoolCapacity_GoesToPoolSystemDuration()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Pool,
|
||||
Title = "Pool",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Capacity, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Back();
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("waitlist:on")]
|
||||
[InlineData("waitlist:off")]
|
||||
public async Task Capacity_WithMaxPlayersAndWaitlist_AdvancesToFormat(string waitlistChoice)
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1), MaxPlayers = 5 },
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Capacity, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, waitlistChoice);
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Format, draft.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||
{
|
||||
@@ -79,7 +168,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||
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);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("single", out var single));
|
||||
@@ -111,6 +200,78 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
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]
|
||||
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
|
||||
{
|
||||
@@ -182,6 +343,16 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
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
|
||||
{
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardD
|
||||
|
||||
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();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
+37
@@ -79,6 +79,39 @@ public sealed class WizardStepRenderTests
|
||||
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]
|
||||
public void VisibilityStep_HasAllFourVisibilityOptions()
|
||||
{
|
||||
@@ -135,10 +168,14 @@ public sealed class WizardStepRenderTests
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "My Game",
|
||||
Format = WizardSessionFormat.Offline,
|
||||
LocationAddress = "Москва, ул. Кубиков, 12",
|
||||
});
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
Assert.Contains("My Game", text);
|
||||
Assert.Contains("Offline", text);
|
||||
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||
var labels = ButtonLabels(kb);
|
||||
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||
|
||||
+74
-5
@@ -20,7 +20,9 @@ public sealed class SessionListMessageRendererTests
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
true)
|
||||
true,
|
||||
false,
|
||||
false)
|
||||
};
|
||||
|
||||
var text = SessionListMessageRenderer.RenderText(sessions);
|
||||
@@ -32,25 +34,92 @@ public sealed class SessionListMessageRendererTests
|
||||
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
|
||||
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
Assert.Contains(actions, a => a.Label == $"❌ Отменить {shortDate}");
|
||||
Assert.Contains(actions, a => a.Label == $"⏰ Перенести {shortDate}");
|
||||
Assert.Contains(actions, a => a.Label == $"⬆️ С ожидания {shortDate}");
|
||||
Assert.Contains(actions, a => a.Label == $"🗑 Удалить {shortDate}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldHideManagerActions_WhenUserCannotManage()
|
||||
public void Render_ShouldIncludeJoinAction_WhenPlayerIsNotRegistered()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionListItemDto(
|
||||
Guid.NewGuid(),
|
||||
sessionId,
|
||||
"Ravenloft",
|
||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false)
|
||||
};
|
||||
|
||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Contains(actions, a => a.Payload == $"join_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Label == $"✅ Записаться {shortDate}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldIncludeLeaveAction_WhenPlayerIsActive()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionListItemDto(
|
||||
sessionId,
|
||||
"Ravenloft",
|
||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
false)
|
||||
};
|
||||
|
||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Label == $"✖️ Выйти {shortDate}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldIncludeLeaveWaitlistAction_WhenPlayerIsWaitlisted()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionListItemDto(
|
||||
sessionId,
|
||||
"Ravenloft",
|
||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
false)
|
||||
false,
|
||||
false,
|
||||
true)
|
||||
};
|
||||
|
||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||
Assert.Empty(actions);
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Label == $"✖️ Выйти из ожидания {shortDate}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.Rendering;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Reflection;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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() =>
|
||||
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() =>
|
||||
new("Test batch", []);
|
||||
}
|
||||
|
||||
@@ -149,4 +149,36 @@ public sealed class SessionBatchViewBuilderTests
|
||||
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
|
||||
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[]
|
||||
{
|
||||
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(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[]
|
||||
{
|
||||
@@ -35,7 +35,7 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
Assert.Contains("Charlie", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Contains("Сессия отменена", text);
|
||||
Assert.Contains("Ссылка на игру", text);
|
||||
Assert.Contains("Ссылка:", text);
|
||||
Assert.Contains("https://example.com/game1", text);
|
||||
Assert.Contains("https://example.com/game2", text);
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
public void Render_ShouldShowWaitlistButtonWhenFull()
|
||||
{
|
||||
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 view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
@@ -130,16 +130,66 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
|
||||
Assert.DoesNotContain("Ссылка на игру", text);
|
||||
Assert.DoesNotContain("Ссылка:", text);
|
||||
Assert.Contains("📅", text);
|
||||
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]
|
||||
public void Render_ShouldEncodeHtmlInJoinLink()
|
||||
{
|
||||
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 view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
@@ -148,4 +198,77 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
Assert.Contains("a=1&b=2", text);
|
||||
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
|
||||
{
|
||||
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();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Web.Services;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web.Rendering;
|
||||
|
||||
public sealed class WebTelegramSessionBatchRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_ShouldShowStructuredGameCard()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(
|
||||
sessionId,
|
||||
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
"https://vtt.example/game",
|
||||
"Hybrid",
|
||||
"Moscow, Kubik Bar",
|
||||
"Mystery one-shot in Bamberg.",
|
||||
"D\u0026D 5e",
|
||||
240,
|
||||
true)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
|
||||
};
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
|
||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("🏷", text);
|
||||
Assert.Contains("Система:", text);
|
||||
Assert.Contains("D\u0026amp;D 5e", text);
|
||||
Assert.Contains("Формат:", text);
|
||||
Assert.Contains("Hybrid", text);
|
||||
Assert.Contains("Тип:", text);
|
||||
Assert.Contains("One-shot", text);
|
||||
Assert.Contains("⏱", text);
|
||||
Assert.Contains("Длительность:", text);
|
||||
Assert.Contains("4 ч", text);
|
||||
Assert.Contains("📝", text);
|
||||
Assert.Contains("Описание:", text);
|
||||
Assert.Contains("Mystery one-shot in Bamberg.", text);
|
||||
Assert.Contains("🔗", text);
|
||||
Assert.Contains("Ссылка:", text);
|
||||
Assert.Contains("📍", text);
|
||||
Assert.Contains("Адрес:", text);
|
||||
Assert.Contains("@alice", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Contains("Лист ожидания", text);
|
||||
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
Assert.Equal(2, buttons.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldHandleMissingOptionalFields()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("📅", text);
|
||||
Assert.Contains("👥", text);
|
||||
Assert.DoesNotContain("Система:", text);
|
||||
Assert.DoesNotContain("Формат:", text);
|
||||
Assert.DoesNotContain("Длительность:", text);
|
||||
Assert.DoesNotContain("Описание:", text);
|
||||
Assert.DoesNotContain("Ссылка:", text);
|
||||
Assert.DoesNotContain("Адрес:", text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user