diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 27297b8..ad039ab 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.9.9 + VERSION: 3.10.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index cd125aa..fb9d8c8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.9.9 + 3.10.0 net10.0 preview enable diff --git a/README.md b/README.md index eadc38d..91f0600 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v3.6.0`. +**Текущая версия:** `v3.10.0`. --- ## ✨ Key Features ### 🤖 Telegram Bot -- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед). +- **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация. - **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи. - **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. @@ -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`. ## 📚 Портфолио завершённых приключений diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c55dfa9..523cf1e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 «не цеплялась». diff --git a/compose.yaml b/compose.yaml index f1cd68a..bd5f083 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.9 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.10.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.9 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.10.0 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.9 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.10.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 56fffce..acd5776 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -70,7 +70,7 @@ public sealed class CancelSessionHandler( // 3. Загружаем весь батч для перерисовки var batchSessions = await connection.QueryAsync( - @"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", diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 9a4f797..bdf382f 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -293,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) @@ -317,6 +318,9 @@ 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) diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs index 0152913..fba243f 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -139,7 +139,9 @@ 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 FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index 78b48bf..4daba17 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService( await using var connection = await dataSource.OpenConnectionAsync(ct); var batchSessions = (await connection.QueryAsync( - "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 batchParticipants = (await connection.QueryAsync( diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs index 38757c1..fb38de9 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs @@ -405,19 +405,8 @@ public sealed class TelegramPlatformMessenger( Ответьте кнопкой в групповом сообщении расписания. """, - PlatformDirectSessionNotificationKind.OneHourReminder => $""" - ⏰ Игра начнётся примерно через 1 час - - 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} - 📅 {notification.ScheduledAt.FormatMoscow()} (МСК) - 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} - """, - PlatformDirectSessionNotificationKind.JoinLink => $""" - 🎮 Игра начинается через 5 минут - - 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} - 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} - """, + PlatformDirectSessionNotificationKind.OneHourReminder => BuildOneHourReminderDirectText(notification), + PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification), PlatformDirectSessionNotificationKind.RescheduleApproved => $""" ✅ Сессия перенесена по итогам голосования @@ -434,6 +423,39 @@ public sealed class TelegramPlatformMessenger( _ => BuildFallbackDirectText(notification) }; + private static string BuildOneHourReminderDirectText(PlatformDirectSessionNotification notification) + { + var lines = new List + { + "⏰ Игра начнётся примерно через 1 час", + string.Empty, + $"📌 {System.Net.WebUtility.HtmlEncode(notification.Title)}", + $"📅 {notification.ScheduledAt.FormatMoscow()} (МСК)" + }; + AppendJoinLinkLine(lines, notification.JoinLink); + return string.Join("\n", lines); + } + + private static string BuildJoinLinkDirectText(PlatformDirectSessionNotification notification) + { + var lines = new List + { + "🎮 Игра начинается через 5 минут", + string.Empty, + $"📌 {System.Net.WebUtility.HtmlEncode(notification.Title)}" + }; + AppendJoinLinkLine(lines, notification.JoinLink); + return string.Join("\n", lines); + } + + private static void AppendJoinLinkLine(List lines, string? joinLink) + { + if (!string.IsNullOrWhiteSpace(joinLink)) + { + lines.Add($"🔗 {System.Net.WebUtility.HtmlEncode(joinLink)}"); + } + } + private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) => $"{System.Net.WebUtility.HtmlEncode(notification.Title)}\n{notification.ScheduledAt.FormatMoscow()} (МСК)"; diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs index 4089c0f..296e271 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramSessionBatchRenderer.cs @@ -23,7 +23,14 @@ public static class TelegramSessionBatchRenderer if (!string.IsNullOrEmpty(session.JoinLink)) { - messageText += $"🔗 Ссылка на игру\n"; + var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink); + messageText += $"🔗 Ссылка на игру: {encodedLink}\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) diff --git a/src/GmRelay.Bot/Migrations/V033__add_session_location_address.sql b/src/GmRelay.Bot/Migrations/V033__add_session_location_address.sql new file mode 100644 index 0000000..095fa4e --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V033__add_session_location_address.sql @@ -0,0 +1,2 @@ +ALTER TABLE sessions +ADD COLUMN location_address TEXT; diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs index a92dd7e..b609232 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs @@ -145,7 +145,7 @@ public sealed class DiscordRescheduleVotingDeadlineService( return; var sessions = (await connection.QueryAsync( - "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( diff --git a/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs index 5f550a0..40a7466 100644 --- a/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs +++ b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -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 ( diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs index d92f51f..d78d640 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs @@ -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); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs index 0e2c7f4..7ef9779 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -124,8 +124,8 @@ public sealed class CreateSessionHandler( { var sessionId = await connection.ExecuteScalarAsync( """ - 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 @@ -142,11 +142,19 @@ 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)); } await transaction.CommitAsync(ct); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs index 5a297ad..73bb46a 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -135,7 +135,7 @@ public sealed class JoinSessionHandler( // Загружаем весь батч для перерисовки var batchSessions = await connection.QueryAsync( - @"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", diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs index 5353c19..d6b28cf 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs @@ -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 diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs index cea0e52..4b2ad98 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -226,9 +226,20 @@ public sealed class GameCreationWizard case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity - ? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload) + ? (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 +247,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.Format, SetDurationMinutes(payload, pdur), payload) : (null, "Неверная длительность (1..12 ч)", payload); case WizardStepNames.PoolSlotDateTime: @@ -264,6 +275,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,7 +314,7 @@ public sealed class GameCreationWizard { 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) @@ -312,12 +324,19 @@ public sealed class GameCreationWizard 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,7 +368,7 @@ 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.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur)) : (null, "Неверный выбор"), _ => (null, "Неизвестный выбор"), }; @@ -391,13 +410,15 @@ public sealed class GameCreationWizard WizardStepNames.Duration => WizardStepNames.System, WizardStepNames.DateTime => WizardStepNames.Duration, 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.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, @@ -442,6 +463,15 @@ public sealed class GameCreationWizard 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 +518,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.Format; + return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime; } private static string? NextAfterVisibility(WizardPayload p) { diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs index cb7dcf1..39a3bb6 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs @@ -8,6 +8,9 @@ public enum WizardCreationType { Single, Pool } public enum WizardVisibility { Public, Club, Members } +[JsonConverter(typeof(JsonStringEnumConverter))] +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; } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs index 2cacb2e..f2168d1 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs @@ -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; } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs index 725e6ac..2aaad84 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs @@ -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"; diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs index 8bff399..b37eca3 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs @@ -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()), WizardStepNames.Publish => BuildPublish(), @@ -105,6 +107,22 @@ public static class WizardStepViewBuilder new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary), }); + private static (string, IReadOnlyList) BuildFormat() => ( + "🧭 Выберите формат игры.", + new List + { + 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) BuildLocation(WizardPayload payload) => payload.Format switch + { + WizardSessionFormat.Offline => ("📍 Введите адрес места проведения.", BackCancel()), + _ => ("🔗 Введите ссылку для подключения к online-игре.", BackCancel()), + }; + private static (string, IReadOnlyList) BuildVisibility() => ( "🔒 Выберите видимость.", new List @@ -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,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); sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); sb.AppendLine(); sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):"); @@ -245,4 +265,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}"); + } + } } diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index b8effad..2488f27 100644 --- a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -159,7 +159,7 @@ public sealed class HandleRescheduleTimeInputHandler( await transaction.CommitAsync(ct); var batchSessions = (await connection.QueryAsync( - "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( diff --git a/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs b/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs index 99e34b0..6471d3c 100644 --- a/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs +++ b/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs @@ -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) diff --git a/src/GmRelay.Shared/Rendering/SessionBatchDto.cs b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs index 3ff4a2c..5bc4acf 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchDto.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchDto.cs @@ -1,4 +1,11 @@ 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); diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs index 2644428..a4ac299 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewBuilder.cs @@ -39,6 +39,8 @@ public static class SessionBatchViewBuilder session.Status, session.MaxPlayers, session.JoinLink, + session.Format, + session.LocationAddress, activePlayers.Count, activePlayers, waitlistedPlayers, diff --git a/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs index 3782757..7e38f24 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchViewModel.cs @@ -12,6 +12,8 @@ public sealed record SessionViewItem( string Status, int? MaxPlayers, string JoinLink, + string? Format, + string? LocationAddress, int ActivePlayerCount, IReadOnlyList ActivePlayers, IReadOnlyList WaitlistedPlayers, diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 1e26b46..cd44f25 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -82,7 +82,7 @@ - + diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 9cb2a86..0d62e73 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -1897,7 +1897,7 @@ public sealed class SessionService( await using var conn = await dataSource.OpenConnectionAsync(); var sessions = (await conn.QueryAsync( - "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(); var participants = (await conn.QueryAsync( diff --git a/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs index 6e41396..e2d60dd 100644 --- a/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs +++ b/src/GmRelay.Web/Services/TelegramSessionBatchRenderer.cs @@ -22,7 +22,14 @@ public static class TelegramSessionBatchRenderer if (!string.IsNullOrEmpty(session.JoinLink)) { - messageText += $"🔗 Ссылка на игру\n"; + var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink); + messageText += $"🔗 Ссылка на игру: {encodedLink}\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) diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs index 94f4c1f..fc857c5 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs @@ -95,7 +95,7 @@ public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPos new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"), new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"), "Test Adventure", - string.Empty, + "https://vtt.example/game", [DateTimeOffset.UtcNow.AddDays(1)], null, null, @@ -103,7 +103,8 @@ public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPos "Integration regression test", "Online", 240, - true), + true, + "Online room notes"), CancellationToken.None); Assert.True(result.Success, result.ErrorMessage); @@ -126,5 +127,66 @@ public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPos 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()); } } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerBuildCommandTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerBuildCommandTests.cs index 885d457..04041cf 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerBuildCommandTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerBuildCommandTests.cs @@ -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); + } } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs index f0744a0..c0cea73 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs @@ -33,6 +33,8 @@ public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHand Title = "Тест публикации", System = "Dnd5e", DurationMinutes = 240, + Format = WizardSessionFormat.Online, + JoinLink = "https://vtt.example/game", Visibility = WizardVisibility.Public, Single = new WizardSingleInput { diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs index 81fb996..69434a9 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs @@ -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(), }; @@ -168,6 +176,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 { diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs index 428ace6..5ecb8b8 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs @@ -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] diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs index bf9b549..60c85ec 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs @@ -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_AdvancesToFormat() { 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.Format, draft.Step); using var doc = JsonDocument.Parse(draft.PayloadJson); var root = doc.RootElement; Assert.True(root.TryGetProperty("system", out var sys)); @@ -79,7 +79,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 +111,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 +254,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 { diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs index 9367b10..fb8c7c7 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs @@ -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)); diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/DbSessionTriggerStoreTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/DbSessionTriggerStoreTests.cs new file mode 100644 index 0000000..be0a815 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/DbSessionTriggerStoreTests.cs @@ -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 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 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.")); + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs index 7652435..0dbdf80 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs @@ -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.Instance); + private static string InvokeBuildDirectNotificationText(PlatformDirectSessionNotification notification) + { + var method = typeof(TelegramPlatformMessenger).GetMethod( + "BuildDirectNotificationText", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + return Assert.IsType(method.Invoke(null, new object[] { notification })); + } + private static SessionBatchViewModel CreateView() => new("Test batch", []); } diff --git a/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs index 904fec9..ef582ac 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/TelegramSessionBatchRendererTests.cs @@ -135,6 +135,56 @@ public sealed class TelegramSessionBatchRendererTests 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(); + + 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(); + + 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] public void Render_ShouldEncodeHtmlInJoinLink() {