Compare commits

...

7 Commits

Author SHA1 Message Date
Toutsu 7d5dd2ed0a Merge pull request #138: feat(bot): add online/offline wizard locations
Deploy Telegram Bot / build-and-push (push) Successful in 31m4s
Deploy Telegram Bot / scan-images (push) Successful in 5m39s
Deploy Telegram Bot / deploy (push) Successful in 1m18s
2026-06-10 13:50:46 +03:00
Toutsu 7cb5b03cc2 fix(bot): skip join-link reminders without links
PR Checks / test-and-build (pull_request) Failing after 20m55s
Prevent offline sessions with empty join links from entering the 5-minute join-link notification flow, omit blank link lines from direct reminders, and add offline persistence/reminder regression coverage.
2026-06-10 12:23:48 +03:00
Toutsu 014b5edd31 feat(bot): add online/offline wizard locations
PR Checks / test-and-build (pull_request) Successful in 15m52s
Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages.

Bump version to 3.10.0.
2026-06-10 11:29:25 +03:00
Toutsu bbd58142db Merge pull request #137: fix(bot): publish wizard-created sessions (v3.9.9)
Deploy Telegram Bot / build-and-push (push) Successful in 8m28s
Deploy Telegram Bot / scan-images (push) Successful in 2m39s
Deploy Telegram Bot / deploy (push) Successful in 52s
2026-06-09 16:38:52 +03:00
Toutsu 956ec01583 fix(bot): publish wizard-created sessions
PR Checks / test-and-build (pull_request) Successful in 11m58s
After the shared create handler persists sessions, create a Telegram topic when needed, send the schedule/signup message, and store thread_id/batch_message_id/topic_created_by_bot for the batch. Add a Testcontainers regression test for the wizard SubmitDraftAsync happy path. Bump version to 3.9.9.
2026-06-09 16:16:36 +03:00
Toutsu 5014ca5c58 Merge pull request #134: fix(shared): bind platform when creating group manager (v3.9.8)
Deploy Telegram Bot / build-and-push (push) Successful in 8m46s
Deploy Telegram Bot / scan-images (push) Successful in 2m26s
Deploy Telegram Bot / deploy (push) Successful in 56s
2026-06-09 15:41:19 +03:00
Toutsu efd86bca0a fix(shared): bind platform when creating group manager
PR Checks / test-and-build (pull_request) Successful in 12m53s
Add a PostgreSQL integration regression test for new-platform-group session creation. The production failure was a missing Platform parameter in the group_managers insert, leaving @Platform in SQL and causing PostgreSQL 42883. Bump version to 3.9.8.
2026-06-09 15:16:54 +03:00
41 changed files with 974 additions and 84 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.9.7 VERSION: 3.10.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.9.7</Version> <Version>3.10.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+3 -3
View File
@@ -4,14 +4,14 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.6.0`. **Текущая версия:** `v3.10.0`.
--- ---
## ✨ Key Features ## ✨ Key Features
### 🤖 Telegram Bot ### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед). - **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация.
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи. - **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch. - **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
@@ -127,7 +127,7 @@ docker compose up -d
2. Создайте группу через `/newgroup`. 2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления. 3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord. 4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении. 5. Перезапустите Docker Compose (`docker compose up -d`), затем создайте расписание: в Telegram через `/newsession` выберите `Online` и URL подключения или `Offline` и адрес места проведения; в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`.
## 📚 Портфолио завершённых приключений ## 📚 Портфолио завершённых приключений
+13 -1
View File
@@ -1,4 +1,16 @@
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный) ## 🎯 Minor 3.10.0 — Online/offline format in /newsession wizard (issue #136)
### 🧩 Что вошло в релиз
- Telegram `/newsession` wizard теперь запрашивает формат `Online` / `Offline`.
- Для `Online` мастер вводит URL подключения; для `Offline` — адрес места проведения.
- Offline-адрес сохраняется в `sessions.location_address` через миграцию `V033__add_session_location_address.sql`.
- Telegram schedule messages показывают URL online-игры или адрес offline-встречи; Web duplicate Telegram renderer синхронизирован.
### 📦 Версия и деплой
- Версия обновлена до 3.10.0 (`Directory.Build.props`, `NavMenu.razor`, `.gitea/workflows/deploy.yml`).
- Docker-образы тегируются `3.10.0` в `compose.yaml`.
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync``PlatformNotSupportedException``GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась». В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync``PlatformNotSupportedException``GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.7 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.10.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.7 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.10.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -86,7 +86,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.7 image: git.codeanddice.ru/toutsu/gmrelay-web:3.10.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -70,7 +70,7 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at", ORDER BY scheduled_at",
@@ -5,11 +5,13 @@ using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Npgsql;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler; using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
@@ -31,17 +33,23 @@ public sealed class CreateSessionHandler
private readonly SharedCreateSessionHandler _shared; private readonly SharedCreateSessionHandler _shared;
private readonly IWizardMessenger _messenger; private readonly IWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log; private readonly ILogger<CreateSessionHandler> _log;
private readonly IPlatformMessenger? _platformMessenger;
private readonly NpgsqlDataSource? _dataSource;
public CreateSessionHandler( public CreateSessionHandler(
IWizardDraftRepository drafts, IWizardDraftRepository drafts,
SharedCreateSessionHandler shared, SharedCreateSessionHandler shared,
IWizardMessenger messenger, IWizardMessenger messenger,
ILogger<CreateSessionHandler> log) ILogger<CreateSessionHandler> log,
IPlatformMessenger? platformMessenger = null,
NpgsqlDataSource? dataSource = null)
{ {
_drafts = drafts; _drafts = drafts;
_shared = shared; _shared = shared;
_messenger = messenger; _messenger = messenger;
_log = log; _log = log;
_platformMessenger = platformMessenger;
_dataSource = dataSource;
} }
/// <summary> /// <summary>
@@ -106,19 +114,24 @@ public sealed class CreateSessionHandler
} }
var commands = BuildCommands(draft, payload); var commands = BuildCommands(draft, payload);
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
try try
{ {
foreach (var cmd in commands) foreach (var cmd in commands)
{ {
await _shared.HandleAsync(cmd, ct); var result = await _shared.HandleAsync(cmd, ct);
if (!result.Success)
{
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) catch (Exception ex)
{ {
@@ -142,9 +155,89 @@ public sealed class CreateSessionHandler
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelActions(), RetryCancelActions(),
ct); ct);
return;
} }
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
try
{
foreach (var item in created)
{
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
} }
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
{
if (_platformMessenger is null || _dataSource is null)
{
throw new InvalidOperationException("Session publication dependencies are not configured.");
}
if (result.View is null || result.BatchId is null)
{
throw new InvalidOperationException("Created session result does not contain publication data.");
}
var group = command.Group;
var topicCreatedByBot = false;
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
group = group with { ExternalThreadId = thread.ExternalThreadId };
topicCreatedByBot = true;
}
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
ct);
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET thread_id = @ThreadId,
batch_message_id = @BatchMessageId,
topic_created_by_bot = @TopicCreatedByBot,
updated_at = now()
WHERE batch_id = @BatchId
""",
new
{
result.BatchId,
ThreadId = ParseNullableInt(group.ExternalThreadId),
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
TopicCreatedByBot = topicCreatedByBot
});
}
private static int ParseInt(string value) =>
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value)
? null
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
// ── Build shared commands ──────────────────────────────────────── // ── Build shared commands ────────────────────────────────────────
// The shared handler creates one session per scheduled time in a // The shared handler creates one session per scheduled time in a
// single transaction and assigns the same batch_id to all of them. // single transaction and assigns the same batch_id to all of them.
@@ -200,15 +293,16 @@ public sealed class CreateSessionHandler
User: user, User: user,
Group: group, Group: group,
Title: p.Title ?? string.Empty, Title: p.Title ?? string.Empty,
Link: string.Empty, Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
ScheduledTimes: scheduledTimes, ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers, MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl, ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System), System: ParseSystem(p.System),
Description: p.Description, Description: p.Description,
Format: null, Format: p.Format?.ToString(),
DurationMinutes: p.DurationMinutes, DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot); IsOneShot: isOneShot,
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
} }
private static GameSystem? ParseSystem(string? code) private static GameSystem? ParseSystem(string? code)
@@ -224,6 +318,9 @@ public sealed class CreateSessionHandler
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название"); if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система"); if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность"); if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Format is null) missingFields.Add("формат");
if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка");
if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес");
if (p.Visibility is null) missingFields.Add("видимость"); if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single) if (p.Type == WizardCreationType.Single)
@@ -139,7 +139,9 @@ public sealed class PromoteWaitlistedPlayerHandler(
scheduled_at AS ScheduledAt, scheduled_at AS ScheduledAt,
status AS Status, status AS Status,
max_players AS MaxPlayers, max_players AS MaxPlayers,
join_link AS JoinLink join_link AS JoinLink,
format AS Format,
location_address AS LocationAddress
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at ORDER BY scheduled_at
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList(); new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -405,19 +405,8 @@ public sealed class TelegramPlatformMessenger(
Ответьте кнопкой в групповом сообщении расписания. Ответьте кнопкой в групповом сообщении расписания.
""", """,
PlatformDirectSessionNotificationKind.OneHourReminder => $""" PlatformDirectSessionNotificationKind.OneHourReminder => BuildOneHourReminderDirectText(notification),
⏰ <b>Игра начнётся примерно через 1 час</b> PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification),
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.JoinLink => $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.RescheduleApproved => $""" PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
✅ <b>Сессия перенесена по итогам голосования</b> ✅ <b>Сессия перенесена по итогам голосования</b>
@@ -434,6 +423,39 @@ public sealed class TelegramPlatformMessenger(
_ => BuildFallbackDirectText(notification) _ => BuildFallbackDirectText(notification)
}; };
private static string BuildOneHourReminderDirectText(PlatformDirectSessionNotification notification)
{
var lines = new List<string>
{
"⏰ <b>Игра начнётся примерно через 1 час</b>",
string.Empty,
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>",
$"📅 {notification.ScheduledAt.FormatMoscow()} (МСК)"
};
AppendJoinLinkLine(lines, notification.JoinLink);
return string.Join("\n", lines);
}
private static string BuildJoinLinkDirectText(PlatformDirectSessionNotification notification)
{
var lines = new List<string>
{
"🎮 <b>Игра начинается через 5 минут</b>",
string.Empty,
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>"
};
AppendJoinLinkLine(lines, notification.JoinLink);
return string.Join("\n", lines);
}
private static void AppendJoinLinkLine(List<string> lines, string? joinLink)
{
if (!string.IsNullOrWhiteSpace(joinLink))
{
lines.Add($"🔗 {System.Net.WebUtility.HtmlEncode(joinLink)}");
}
}
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) => private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)"; $"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
@@ -23,7 +23,14 @@ public static class TelegramSessionBatchRenderer
if (!string.IsNullOrEmpty(session.JoinLink)) if (!string.IsNullOrEmpty(session.JoinLink))
{ {
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n"; var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
}
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(session.LocationAddress))
{
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
} }
if (session.ActivePlayers.Count > 0) if (session.ActivePlayers.Count > 0)
@@ -0,0 +1,2 @@
ALTER TABLE sessions
ADD COLUMN location_address TEXT;
@@ -145,7 +145,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
return; return;
var sessions = (await connection.QueryAsync<SessionBatchDto>( var sessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList(); new { result.BatchId })).ToList();
var participants = (await connection.QueryAsync<ParticipantBatchDto>( var participants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -57,6 +57,7 @@ public sealed class SendJoinLinkHandler(
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId WHERE s.id = @SessionId
AND s.status = @Confirmed AND s.status = @Confirmed
AND btrim(s.join_link) <> ''
AND ( AND (
(g.platform = 'Telegram' AND s.link_message_id IS NULL) (g.platform = 'Telegram' AND s.link_message_id IS NULL)
OR ( OR (
@@ -15,4 +15,5 @@ public sealed record CreateSessionCommand(
string? Description = null, string? Description = null,
string? Format = null, string? Format = null,
int? DurationMinutes = null, int? DurationMinutes = null,
bool IsOneShot = false); bool IsOneShot = false,
string? LocationAddress = null);
@@ -82,7 +82,13 @@ public sealed class CreateSessionHandler(
AND p.external_user_id = @ExternalGmId AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING ON CONFLICT (group_id, player_id) DO NOTHING
""", """,
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }, new
{
GroupId = groupId,
Platform = platform,
ExternalGmId = externalUserId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
},
transaction); transaction);
} }
else else
@@ -118,8 +124,8 @@ public sealed class CreateSessionHandler(
{ {
var sessionId = await connection.ExecuteScalarAsync<Guid>( var sessionId = await connection.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url, location_address)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl) VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl, @LocationAddress)
RETURNING id; RETURNING id;
""", """,
new new
@@ -136,11 +142,19 @@ public sealed class CreateSessionHandler(
command.Format, command.Format,
DurationMinutes = command.DurationMinutes, DurationMinutes = command.DurationMinutes,
IsOneShot = command.IsOneShot, IsOneShot = command.IsOneShot,
CoverImageUrl = command.ImageReference CoverImageUrl = command.ImageReference,
command.LocationAddress
}, },
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link)); sessions.Add(new SessionBatchDto(
sessionId,
scheduledAt.UtcDateTime,
SessionStatus.Planned,
command.MaxPlayers,
command.Link,
command.Format,
command.LocationAddress));
} }
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
@@ -135,7 +135,7 @@ public sealed class JoinSessionHandler(
// Загружаем весь батч для перерисовки // Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at", ORDER BY scheduled_at",
@@ -161,7 +161,9 @@ public sealed class LeaveSessionHandler(
scheduled_at AS ScheduledAt, scheduled_at AS ScheduledAt,
status AS Status, status AS Status,
max_players AS MaxPlayers, max_players AS MaxPlayers,
join_link AS JoinLink join_link AS JoinLink,
format AS Format,
location_address AS LocationAddress
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at ORDER BY scheduled_at
@@ -226,9 +226,20 @@ public sealed class GameCreationWizard
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload) ? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload); : (null, "Лимит должен быть 1..50", payload);
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Online:
return Uri.TryCreate(input.Trim(), UriKind.Absolute, out var locationUri) &&
(locationUri.Scheme == Uri.UriSchemeHttp || locationUri.Scheme == Uri.UriSchemeHttps)
? (WizardStepNames.Visibility, SetJoinLink(payload, input.Trim()), payload)
: (null, "Некорректная ссылка", payload);
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Offline:
return ValidateText(input, WizardStepLimits.MaxLocationLength, "Адрес не может быть пустым", "Слишком длинный адрес", out var address)
? (WizardStepNames.Visibility, SetLocationAddress(payload, address), payload)
: (null, address, payload);
case WizardStepNames.PoolSystemDuration when payload.System is null: case WizardStepNames.PoolSystemDuration when payload.System is null:
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload) ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
@@ -236,7 +247,7 @@ public sealed class GameCreationWizard
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null: case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur) return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload) ? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload); : (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime: case WizardStepNames.PoolSlotDateTime:
@@ -264,6 +275,7 @@ public sealed class GameCreationWizard
WizardStepNames.System => ApplySystemChoice(payload, choice), WizardStepNames.System => ApplySystemChoice(payload, choice),
WizardStepNames.Duration => ApplyDurationChoice(payload, choice), WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice), WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
WizardStepNames.Format => ApplyFormatChoice(payload, choice),
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice), WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice), WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
WizardStepNames.Publish => ApplyPublishChoice(payload, choice), WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
@@ -302,7 +314,7 @@ public sealed class GameCreationWizard
{ {
if (choice is "no_limit") if (choice is "no_limit")
{ {
return (WizardStepNames.Visibility, SetMaxPlayers(p, null)); return (WizardStepNames.Format, SetMaxPlayers(p, null));
} }
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null) if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
@@ -312,12 +324,19 @@ public sealed class GameCreationWizard
return choice switch return choice switch
{ {
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)), "waitlist:on" => (WizardStepNames.Format, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)), "waitlist:off" => (WizardStepNames.Format, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"), _ => (null, "Неизвестный выбор"),
}; };
} }
private static (string?, string?) ApplyFormatChoice(WizardPayload p, string choice) => choice switch
{
"online" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Online)),
"offline" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Offline)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{ {
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)), "public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
@@ -349,7 +368,7 @@ public sealed class GameCreationWizard
{ {
"_custom" => (WizardStepNames.PoolSystemDuration, null), "_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur) { } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur)) ? (WizardStepNames.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"), : (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"), _ => (null, "Неизвестный выбор"),
}; };
@@ -391,13 +410,15 @@ public sealed class GameCreationWizard
WizardStepNames.Duration => WizardStepNames.System, WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration, WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime, WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity, WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity,
WizardStepNames.Location => WizardStepNames.Format,
WizardStepNames.Visibility => WizardStepNames.Location,
WizardStepNames.PickClub => WizardStepNames.Visibility, WizardStepNames.PickClub => WizardStepNames.Visibility,
WizardStepNames.Publish => WizardStepNames.PickClub, WizardStepNames.Publish => WizardStepNames.PickClub,
WizardStepNames.Confirm => WizardStepNames.Publish, WizardStepNames.Confirm => WizardStepNames.Publish,
WizardStepNames.PoolSystemDuration => null, // first pool step WizardStepNames.PoolSystemDuration => null, // first pool step
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration, WizardStepNames.PoolAddSlots => WizardStepNames.Visibility,
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots, WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime, WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots, WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
@@ -442,6 +463,15 @@ public sealed class GameCreationWizard
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; } private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; } private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; } private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
private static string? SetFormat(WizardPayload p, WizardSessionFormat v)
{
p.Format = v;
p.JoinLink = null;
p.LocationAddress = null;
return null;
}
private static string? SetJoinLink(WizardPayload p, string v) { p.JoinLink = v; p.LocationAddress = null; return null; }
private static string? SetLocationAddress(WizardPayload p, string v) { p.LocationAddress = v; p.JoinLink = null; return null; }
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v) private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
{ {
@@ -488,8 +518,8 @@ public sealed class GameCreationWizard
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration; private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
private static string? NextAfterDuration(WizardPayload p) private static string? NextAfterDuration(WizardPayload p)
{ {
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility; if (p.Type == WizardCreationType.Pool) return WizardStepNames.Format;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime; return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
} }
private static string? NextAfterVisibility(WizardPayload p) private static string? NextAfterVisibility(WizardPayload p)
{ {
@@ -8,6 +8,9 @@ public enum WizardCreationType { Single, Pool }
public enum WizardVisibility { Public, Club, Members } public enum WizardVisibility { Public, Club, Members }
[JsonConverter(typeof(JsonStringEnumConverter<WizardSessionFormat>))]
public enum WizardSessionFormat { Online, Offline }
public sealed class WizardSlotInput public sealed class WizardSlotInput
{ {
public DateTimeOffset ScheduledAt { get; set; } public DateTimeOffset ScheduledAt { get; set; }
@@ -30,6 +33,9 @@ public sealed class WizardPayload
public string? ImageUrl { get; set; } public string? ImageUrl { get; set; }
public string? System { get; set; } public string? System { get; set; }
public int? DurationMinutes { get; set; } public int? DurationMinutes { get; set; }
public WizardSessionFormat? Format { get; set; }
public string? JoinLink { get; set; }
public string? LocationAddress { get; set; }
public WizardVisibility? Visibility { get; set; } public WizardVisibility? Visibility { get; set; }
public Guid? ClubId { get; set; } public Guid? ClubId { get; set; }
public bool? PublishInShowcase { get; set; } public bool? PublishInShowcase { get; set; }
@@ -14,4 +14,5 @@ public static class WizardStepLimits
public const int MinCapacity = 1; public const int MinCapacity = 1;
public const int MinDurationHours = 1; public const int MinDurationHours = 1;
public const int MaxDurationHours = 12; public const int MaxDurationHours = 12;
public const int MaxLocationLength = 500;
} }
@@ -16,6 +16,8 @@ public static class WizardStepNames
public const string Duration = "Duration"; public const string Duration = "Duration";
public const string DateTime = "DateTime"; public const string DateTime = "DateTime";
public const string Capacity = "Capacity"; public const string Capacity = "Capacity";
public const string Format = "Format";
public const string Location = "Location";
public const string Visibility = "Visibility"; public const string Visibility = "Visibility";
public const string PickClub = "PickClub"; public const string PickClub = "PickClub";
public const string Publish = "Publish"; public const string Publish = "Publish";
@@ -30,6 +30,8 @@ public static class WizardStepViewBuilder
WizardStepNames.Duration => BuildDuration(), WizardStepNames.Duration => BuildDuration(),
WizardStepNames.DateTime => BuildDateTime(), WizardStepNames.DateTime => BuildDateTime(),
WizardStepNames.Capacity => BuildCapacity(), WizardStepNames.Capacity => BuildCapacity(),
WizardStepNames.Format => BuildFormat(),
WizardStepNames.Location => BuildLocation(payload),
WizardStepNames.Visibility => BuildVisibility(), WizardStepNames.Visibility => BuildVisibility(),
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()), WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => BuildPublish(), WizardStepNames.Publish => BuildPublish(),
@@ -105,6 +107,22 @@ public static class WizardStepViewBuilder
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary), new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
}); });
private static (string, IReadOnlyList<WizardAction>) BuildFormat() => (
"🧭 Выберите формат игры.",
new List<WizardAction>
{
new("🌐 Online", WizardCallbackData.Choice(WizardStepNames.Format, "online"), WizardActionStyle.Primary),
new("📍 Offline", WizardCallbackData.Choice(WizardStepNames.Format, "offline"), WizardActionStyle.Primary),
new("⬅️ Назад", WizardCallbackData.Back()),
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
});
private static (string, IReadOnlyList<WizardAction>) BuildLocation(WizardPayload payload) => payload.Format switch
{
WizardSessionFormat.Offline => ("📍 Введите адрес места проведения.", BackCancel()),
_ => ("🔗 Введите ссылку для подключения к online-игре.", BackCancel()),
};
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => ( private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
"🔒 Выберите видимость.", "🔒 Выберите видимость.",
new List<WizardAction> new List<WizardAction>
@@ -150,6 +168,7 @@ public static class WizardStepViewBuilder
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
AppendFormatLocation(sb, p);
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)"); if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}"); if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
@@ -204,6 +223,7 @@ public static class WizardStepViewBuilder
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
AppendFormatLocation(sb, p);
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):"); sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
@@ -245,4 +265,19 @@ public static class WizardStepViewBuilder
WizardVisibility.Members => "только для членов клуба", WizardVisibility.Members => "только для членов клуба",
_ => "не задана", _ => "не задана",
}; };
private static void AppendFormatLocation(StringBuilder sb, WizardPayload p)
{
if (p.Format is null) return;
sb.AppendLine($"🧭 Формат: {p.Format}");
if (p.Format == WizardSessionFormat.Online && !string.IsNullOrWhiteSpace(p.JoinLink))
{
sb.AppendLine($"🔗 Ссылка: {p.JoinLink}");
}
else if (p.Format == WizardSessionFormat.Offline && !string.IsNullOrWhiteSpace(p.LocationAddress))
{
sb.AppendLine($"📍 Адрес: {p.LocationAddress}");
}
}
} }
@@ -159,7 +159,7 @@ public sealed class HandleRescheduleTimeInputHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList(); new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -81,6 +81,7 @@ public sealed class DbSessionTriggerStore(
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE g.platform = @Platform WHERE g.platform = @Platform
AND s.status = @Confirmed AND s.status = @Confirmed
AND btrim(s.join_link) <> ''
AND s.scheduled_at - @LeadTime <= @Now AND s.scheduled_at - @LeadTime <= @Now
AND ( AND (
(g.platform = 'Telegram' AND s.link_message_id IS NULL) (g.platform = 'Telegram' AND s.link_message_id IS NULL)
@@ -1,4 +1,11 @@
namespace GmRelay.Shared.Rendering; namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink); public sealed record SessionBatchDto(
Guid SessionId,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
string JoinLink,
string? Format = null,
string? LocationAddress = null);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus); public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -39,6 +39,8 @@ public static class SessionBatchViewBuilder
session.Status, session.Status,
session.MaxPlayers, session.MaxPlayers,
session.JoinLink, session.JoinLink,
session.Format,
session.LocationAddress,
activePlayers.Count, activePlayers.Count,
activePlayers, activePlayers,
waitlistedPlayers, waitlistedPlayers,
@@ -12,6 +12,8 @@ public sealed record SessionViewItem(
string Status, string Status,
int? MaxPlayers, int? MaxPlayers,
string JoinLink, string JoinLink,
string? Format,
string? LocationAddress,
int ActivePlayerCount, int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers, IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers, IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
@@ -82,7 +82,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v3.9.7</div> <div class="nav-version">v3.10.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
+1 -1
View File
@@ -1897,7 +1897,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var sessions = (await conn.QueryAsync<SessionBatchDto>( var sessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { BatchId = batchId })).ToList(); new { BatchId = batchId })).ToList();
var participants = (await conn.QueryAsync<ParticipantBatchDto>( var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -22,7 +22,14 @@ public static class TelegramSessionBatchRenderer
if (!string.IsNullOrEmpty(session.JoinLink)) if (!string.IsNullOrEmpty(session.JoinLink))
{ {
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n"; var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
}
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(session.LocationAddress))
{
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
} }
if (session.ActivePlayers.Count > 0) if (session.ActivePlayers.Count > 0)
@@ -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(2);
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
public Task InitializeAsync()
{
return container.StartAsync().WaitAsync(ContainerTimeout);
}
public Task DisposeAsync()
{
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
}
public async Task<string> CreateMigratedDatabaseAsync()
{
var databaseName = $"create_session_{Guid.NewGuid():N}";
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
{
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
{
Database = databaseName,
Timeout = 10,
CommandTimeout = 30
}.ConnectionString;
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync().WaitAsync(ContainerTimeout);
foreach (var migration in GetMigrationPaths())
{
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
{
CommandTimeout = 30
};
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
return connectionString;
}
private static IReadOnlyList<string> GetMigrationPaths()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
if (Directory.Exists(migrationsDirectory))
{
return Directory.GetFiles(migrationsDirectory, "V*.sql")
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
.ToArray();
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
}
}
[Collection(CreateSessionHandlerPostgresCollection.Name)]
public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPostgresFixture fixture)
{
[Fact]
public async Task HandleAsync_NewPlatformGroup_AddsOwnerAndPersistsSession()
{
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new CreateSessionHandler(dataSource);
var result = await sut.HandleAsync(
new CreateSessionCommand(
new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"),
new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"),
"Test Adventure",
"https://vtt.example/game",
[DateTimeOffset.UtcNow.AddDays(1)],
null,
null,
GameSystem.Dnd5e,
"Integration regression test",
"Online",
240,
true,
"Online room notes"),
CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(result.BatchId);
Assert.NotNull(result.GroupId);
await using var connection = await dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(
"""
SELECT count(*)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @group_id
AND gm.role = 'Owner'
AND p.platform = 'Telegram'
AND p.external_user_id = '111111111'
""",
connection);
command.Parameters.AddWithValue("group_id", result.GroupId.Value);
var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
Assert.Equal(1, ownerCount);
await using var sessionCommand = new NpgsqlCommand(
"""
SELECT join_link, format, location_address
FROM sessions
WHERE batch_id = @batch_id
""",
connection);
sessionCommand.Parameters.AddWithValue("batch_id", result.BatchId.Value);
await using var reader = await sessionCommand.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal("https://vtt.example/game", reader.GetString(0));
Assert.Equal("Online", reader.GetString(1));
Assert.Equal("Online room notes", reader.GetString(2));
Assert.False(await reader.ReadAsync());
}
[Fact]
public async Task HandleAsync_OfflineSession_PersistsFormatAndLocationAddress()
{
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new CreateSessionHandler(dataSource);
var result = await sut.HandleAsync(
new CreateSessionCommand(
new PlatformUser(PlatformKind.Telegram, "333333333", "Offline GM", "offline_gm"),
new PlatformGroup(PlatformKind.Telegram, "444444444", "Offline Group"),
"Offline Adventure",
string.Empty,
[DateTimeOffset.UtcNow.AddDays(1)],
4,
null,
GameSystem.Dnd5e,
"Offline integration regression test",
"Offline",
240,
true,
"Москва, ул. Кубиков, 12"),
CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(result.BatchId);
await using var connection = await dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(
"""
SELECT join_link, format, location_address
FROM sessions
WHERE batch_id = @batch_id
""",
connection);
command.Parameters.AddWithValue("batch_id", result.BatchId.Value);
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal(string.Empty, reader.GetString(0));
Assert.Equal("Offline", reader.GetString(1));
Assert.Equal("Москва, ул. Кубиков, 12", reader.GetString(2));
Assert.False(await reader.ReadAsync());
}
}
@@ -82,4 +82,80 @@ public sealed class CreateSessionHandlerBuildCommandTests
Assert.Equal(5, cmd.MaxPlayers); Assert.Equal(5, cmd.MaxPlayers);
} }
[Fact]
public void BuildCommand_WhenFormatIsOnline_PropagatesFormatAndJoinLink()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 4,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal("Online", cmd.Format);
Assert.Equal("https://vtt.example/game", cmd.Link);
Assert.Null(cmd.LocationAddress);
}
[Fact]
public void BuildCommand_WhenFormatIsOffline_PropagatesFormatAndAddress()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Offline,
LocationAddress = "Москва, ул. Кубиков, 12",
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 4,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal("Offline", cmd.Format);
Assert.Equal(string.Empty, cmd.Link);
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
}
} }
@@ -1,19 +1,107 @@
using System; using GmRelay.Bot.Features.Sessions.CreateSession;
using Xunit; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary> [Collection(CreateSessionHandlerPostgresCollection.Name)]
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/> public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture)
/// on a single-game wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitSingleDraftTests
{ {
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")] [Fact]
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() => public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs()
throw new NotImplementedException("See Skip reason above."); {
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var drafts = new FakeWizardDraftRepository();
var wizardMessenger = new FakeWizardMessenger();
var platformMessenger = new FakePlatformMessenger();
var sut = new CreateSessionHandler(
drafts,
new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler(dataSource),
wizardMessenger,
NullLogger<CreateSessionHandler>.Instance,
platformMessenger,
dataSource);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "Тест публикации",
System = "Dnd5e",
DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = null,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload, ownerId: 111111111);
draft.ChatId = "-1003916537960";
draft.DraftMessageId = "7";
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(platformMessenger.CreatedThreads);
Assert.Equal("Тест публикации", platformMessenger.CreatedThreads[0].Title);
Assert.Single(platformMessenger.SentSchedules);
Assert.Equal("456", platformMessenger.SentSchedules[0].Group.ExternalThreadId);
Assert.Contains(draft.Id, drafts.DeletedIds);
Assert.Contains(wizardMessenger.Edits, edit => edit.Text.Contains("✅ Создано: 1 сессия", StringComparison.Ordinal));
await using var connection = await dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(
"""
SELECT thread_id, batch_message_id, topic_created_by_bot
FROM sessions
ORDER BY created_at DESC
LIMIT 1
""",
connection);
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal(456, reader.GetInt32(0));
Assert.Equal(789, reader.GetInt32(1));
Assert.True(reader.GetBoolean(2));
}
}
internal sealed class FakePlatformMessenger : IPlatformMessenger
{
public List<(PlatformGroup Group, string Title)> CreatedThreads { get; } = new();
public List<PlatformScheduleMessage> SentSchedules { get; } = new();
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
CreatedThreads.Add((group, title));
return Task.FromResult(new PlatformMessageRef(group.Platform, group.ExternalGroupId, "456", string.Empty));
}
public Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
SentSchedules.Add(message);
return Task.FromResult(new PlatformMessageRef(
message.Group.Platform,
message.Group.ExternalGroupId,
message.Group.ExternalThreadId,
"789"));
}
public Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => Task.CompletedTask;
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) => Task.CompletedTask;
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) => Task.CompletedTask;
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) => Task.CompletedTask;
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) => Task.CompletedTask;
} }
@@ -36,6 +36,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Title = "T", Title = "T",
System = "Dnd5e", System = "Dnd5e",
DurationMinutes = 240, DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Single = new WizardSingleInput Single = new WizardSingleInput
{ {
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7), ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
@@ -69,6 +71,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Type = WizardCreationType.Single, Type = WizardCreationType.Single,
Title = "T", Title = "T",
DurationMinutes = 240, DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public, Visibility = WizardVisibility.Public,
Single = new WizardSingleInput Single = new WizardSingleInput
{ {
@@ -104,6 +108,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Title = "T", Title = "T",
System = "Dnd5e", System = "Dnd5e",
DurationMinutes = 240, DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public, Visibility = WizardVisibility.Public,
Single = new WizardSingleInput { MaxPlayers = 4 }, Single = new WizardSingleInput { MaxPlayers = 4 },
}; };
@@ -135,6 +141,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Title = "P", Title = "P",
System = "Dnd5e", System = "Dnd5e",
DurationMinutes = 240, DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public, Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput(), Pool = new WizardPoolInput(),
}; };
@@ -168,6 +176,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
Title = "T", Title = "T",
System = "Dnd5e", System = "Dnd5e",
DurationMinutes = 240, DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public, Visibility = WizardVisibility.Public,
Single = new WizardSingleInput Single = new WizardSingleInput
{ {
@@ -84,17 +84,24 @@ public sealed class GameCreationWizardCancelBackTests
} }
[Fact] [Fact]
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration() public async Task Back_FromPoolAddSlots_GoesToVisibility()
{ {
var wizard = BuildWizard(out var drafts, out _); var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots, var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" }); new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Visibility = WizardVisibility.Public,
});
drafts.Seed(draft); drafts.Seed(draft);
var data = WizardCallbackData.Back(); var data = WizardCallbackData.Back();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step); Assert.Equal(WizardStepNames.Visibility, draft.Step);
} }
[Fact] [Fact]
@@ -20,8 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)] [InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet) // Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)] [InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility (only explicit no-limit can skip numeric capacity) // Capacity → Format (only explicit no-limit can skip numeric capacity)
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)] [InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
// Visibility → Publish (public, no club) // Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)] [InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub // Visibility → PickClub
@@ -46,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests
} }
[Fact] [Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility() public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat()
{ {
var wizard = BuildWizard(out var drafts, out _); var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload var payload = new WizardPayload
@@ -60,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240"); var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step); Assert.Equal(WizardStepNames.Format, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson); using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement; var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys)); Assert.True(root.TryGetProperty("system", out var sys));
@@ -79,7 +79,7 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"); var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step); Assert.Equal(WizardStepNames.Format, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson); using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement; var root = doc.RootElement;
Assert.True(root.TryGetProperty("single", out var single)); Assert.True(root.TryGetProperty("single", out var single));
@@ -111,6 +111,78 @@ public sealed class GameCreationWizardStepTransitionsTests
Assert.Equal(WizardStepNames.System, draft.Step); Assert.Equal(WizardStepNames.System, draft.Step);
} }
[Fact]
public async Task Format_OnlineChoice_AdvancesToLocationAndPersistsFormat()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Format, "online");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Location, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("format", out var format));
Assert.Equal("Online", format.GetString());
}
[Fact]
public async Task Format_OfflineChoice_AdvancesToLocationAndPersistsFormat()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Format, "offline");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Location, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("format", out var format));
Assert.Equal("Offline", format.GetString());
}
[Fact]
public async Task Location_TextForOnline_StoresJoinLinkAndAdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = PayloadForStep(WizardStepNames.Location);
payload.Format = WizardSessionFormat.Online;
var draft = NewDraft(WizardStepNames.Location, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("https://vtt.example/game", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("joinLink", out var joinLink));
Assert.Equal("https://vtt.example/game", joinLink.GetString());
Assert.False(root.TryGetProperty("locationAddress", out _));
}
[Fact]
public async Task Location_TextForOffline_StoresAddressAndAdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = PayloadForStep(WizardStepNames.Location);
payload.Format = WizardSessionFormat.Offline;
var draft = NewDraft(WizardStepNames.Location, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("Москва, ул. Кубиков, 12", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("locationAddress", out var address));
Assert.Equal("Москва, ул. Кубиков, 12", address.GetString());
Assert.False(root.TryGetProperty("joinLink", out _));
}
[Fact] [Fact]
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick() public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
{ {
@@ -182,6 +254,16 @@ public sealed class GameCreationWizardStepTransitionsTests
Title = "T", Title = "T",
System = "Dnd5e", System = "Dnd5e",
DurationMinutes = 240, DurationMinutes = 240,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
},
WizardStepNames.Format or WizardStepNames.Location => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
}, },
WizardStepNames.PickClub => new WizardPayload WizardStepNames.PickClub => new WizardPayload
{ {
@@ -79,6 +79,39 @@ public sealed class WizardStepRenderTests
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal)); Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
} }
[Fact]
public void FormatStep_HasOnlineAndOfflineButtons()
{
var (text, kb) = Render(WizardStepNames.Format);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Online", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Offline", StringComparison.Ordinal));
}
[Fact]
public void LocationStep_ForOnline_AsksForLink()
{
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Online });
Assert.Contains("ссыл", text, StringComparison.OrdinalIgnoreCase);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void LocationStep_ForOffline_AsksForAddress()
{
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Offline });
Assert.Contains("адрес", text, StringComparison.OrdinalIgnoreCase);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact] [Fact]
public void VisibilityStep_HasAllFourVisibilityOptions() public void VisibilityStep_HasAllFourVisibilityOptions()
{ {
@@ -135,10 +168,14 @@ public sealed class WizardStepRenderTests
{ {
Type = WizardCreationType.Single, Type = WizardCreationType.Single,
Title = "My Game", Title = "My Game",
Format = WizardSessionFormat.Offline,
LocationAddress = "Москва, ул. Кубиков, 12",
}); });
Assert.False(string.IsNullOrWhiteSpace(text)); Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("My Game", text); Assert.Contains("My Game", text);
Assert.Contains("Offline", text);
Assert.Contains("Москва, ул. Кубиков, 12", text);
var labels = ButtonLabels(kb); var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal)); Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal)); Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
@@ -0,0 +1,68 @@
using GmRelay.Bot.Tests.Features.Sessions.CreateSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Bot.Tests.Infrastructure.Scheduling;
[Collection(CreateSessionHandlerPostgresCollection.Name)]
public sealed class DbSessionTriggerStoreTests(CreateSessionHandlerPostgresFixture fixture)
{
[Fact]
public async Task GetSessionsNeedingJoinLinkAsync_IgnoresConfirmedSessionsWithoutJoinLink()
{
var connectionString = await fixture.CreateMigratedDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
await using var connection = await dataSource.OpenConnectionAsync();
var groupId = await InsertTelegramGroupAsync(connection);
var dueAt = DateTimeOffset.UtcNow.AddMinutes(4).UtcDateTime;
var onlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, "https://vtt.example/game", "Online");
var offlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, string.Empty, "Offline");
var sut = new DbSessionTriggerStore(dataSource, new PlatformSchedulerOptions(PlatformKind.Telegram));
var result = await sut.GetSessionsNeedingJoinLinkAsync(DateTimeOffset.UtcNow, CancellationToken.None);
Assert.Contains(onlineSessionId, result);
Assert.DoesNotContain(offlineSessionId, result);
}
private static async Task<Guid> InsertTelegramGroupAsync(NpgsqlConnection connection)
{
await using var command = new NpgsqlCommand(
"""
INSERT INTO game_groups (name, platform, external_group_id)
VALUES ('Trigger Test Group', 'Telegram', @ExternalGroupId)
RETURNING id
""",
connection);
command.Parameters.AddWithValue("ExternalGroupId", Guid.NewGuid().ToString("N"));
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Group insert failed."));
}
private static async Task<Guid> InsertSessionAsync(
NpgsqlConnection connection,
Guid groupId,
DateTime scheduledAt,
string joinLink,
string format)
{
await using var command = new NpgsqlCommand(
"""
INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, format)
VALUES (@GroupId, 'Trigger Test Session', @JoinLink, @ScheduledAt, @Status, @Format)
RETURNING id
""",
connection);
command.Parameters.AddWithValue("GroupId", groupId);
command.Parameters.AddWithValue("JoinLink", joinLink);
command.Parameters.AddWithValue("ScheduledAt", scheduledAt);
command.Parameters.AddWithValue("Status", SessionStatus.Confirmed);
command.Parameters.AddWithValue("Format", format);
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Session insert failed."));
}
}
@@ -2,6 +2,7 @@ using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using System.Reflection;
namespace GmRelay.Bot.Tests.Infrastructure.Telegram; namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
@@ -36,9 +37,35 @@ public sealed class TelegramPlatformMessengerTests
Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message); Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message);
} }
[Fact]
public void BuildDirectNotificationText_OneHourReminderWithoutJoinLink_ShouldNotRenderBlankLinkLine()
{
var notification = new PlatformDirectSessionNotification(
PlatformDirectSessionNotificationKind.OneHourReminder,
new PlatformUser(PlatformKind.Telegram, "123", "Player", "player"),
Guid.NewGuid(),
"Offline Game",
DateTime.UtcNow,
JoinLink: string.Empty);
var text = InvokeBuildDirectNotificationText(notification);
Assert.DoesNotContain("🔗", text);
}
private static TelegramPlatformMessenger CreateMessenger() => private static TelegramPlatformMessenger CreateMessenger() =>
new(null!, NullLogger<TelegramPlatformMessenger>.Instance); new(null!, NullLogger<TelegramPlatformMessenger>.Instance);
private static string InvokeBuildDirectNotificationText(PlatformDirectSessionNotification notification)
{
var method = typeof(TelegramPlatformMessenger).GetMethod(
"BuildDirectNotificationText",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
return Assert.IsType<string>(method.Invoke(null, new object[] { notification }));
}
private static SessionBatchViewModel CreateView() => private static SessionBatchViewModel CreateView() =>
new("Test batch", []); new("Test batch", []);
} }
@@ -135,6 +135,56 @@ public sealed class TelegramSessionBatchRendererTests
Assert.Equal(2, buttons.Count); Assert.Equal(2, buttons.Count);
} }
[Fact]
public void Render_ShouldShowOfflineAddress()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(
sessionId,
DateTime.UtcNow,
SessionStatus.Planned,
4,
"",
"Offline",
"Москва, ул. Кубиков, 12"),
};
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("📍 Адрес:", 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("🔗 Ссылка на игру", text);
Assert.Contains("https://vtt.example/game", text);
Assert.DoesNotContain("📍 Адрес:", text);
}
[Fact] [Fact]
public void Render_ShouldEncodeHtmlInJoinLink() public void Render_ShouldEncodeHtmlInJoinLink()
{ {