From 9709d09b153074907a44a6054d813e09748c7344 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 15 Jun 2026 17:49:53 +0300 Subject: [PATCH 1/2] feat(discord): make /newsession identical to Telegram wizard - Remove legacy DiscordNewSessionCommand/Handler and their tests. - Rename /newsession-wizard to /newsession. - Add shared pool capacity step before Format/Location. - Render Format and Location in Discord wizard; Location uses a modal. - Propagate Format, JoinLink and LocationAddress in BuildCommand. - Publish created sessions through existing IPlatformMessenger pipeline. - Update README, version bump to 3.11.1, sync compose/deploy/NavMenu. --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 4 +- compose.yaml | 6 +- .../Sessions/DiscordNewSessionCommand.cs | 128 ---------- .../Sessions/DiscordNewSessionHandler.cs | 156 ------------ .../Sessions/DiscordRescheduleCommand.cs | 4 +- .../Features/Sessions/DiscordTimeParser.cs | 45 ++++ .../Wizard/DiscordPermissionLookup.cs | 7 +- .../Sessions/Wizard/DiscordWizardCommand.cs | 4 +- .../Wizard/DiscordWizardInteractionModule.cs | 11 +- .../Sessions/Wizard/DiscordWizardStep.cs | 39 +++ .../Sessions/Wizard/DiscordWizardSubmitter.cs | 47 ++-- src/GmRelay.DiscordBot/Program.cs | 1 - .../Wizard/GameCreationWizard.cs | 27 ++- .../CreateSession/Wizard/WizardPayload.cs | 2 + .../Wizard/WizardStepViewBuilder.cs | 1 + .../Components/Layout/NavMenu.razor | 2 +- .../Discord/DiscordNewSessionHandlerTests.cs | 225 ------------------ .../Discord/DiscordStartupTests.cs | 22 +- .../DiscordWizardStepCapacityRenderTests.cs | 23 ++ ...DiscordWizardSubmitterBuildCommandTests.cs | 113 +++++++++ .../GameCreationWizardStepTransitionsTests.cs | 51 +++- 23 files changed, 362 insertions(+), 560 deletions(-) delete mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs delete mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordTimeParser.cs delete mode 100644 tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index df6f51b..b872149 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.11.0 + VERSION: 3.11.1 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 06cef49..d496a0b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.11.0 + 3.11.1 net10.0 preview enable diff --git a/README.md b/README.md index 91f0600..2f1d672 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v3.10.0`. +**Текущая версия:** `v3.11.1`. --- @@ -25,7 +25,7 @@ - **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах. ### Discord Bot -- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале. +- **Slash-команды `/newsession` и `/listsessions`**: `/newsession` ведёт ГМа по тому же wizard, что и в Telegram: тип, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка или адрес, видимость и публикация. `/listsessions` показывает расписание и управление сессиями. - **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message. - **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP. - **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback. diff --git a/compose.yaml b/compose.yaml index 9937501..0d8b690 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.1 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.1 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.1 restart: always depends_on: db: diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs deleted file mode 100644 index 19e8af6..0000000 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs +++ /dev/null @@ -1,128 +0,0 @@ -using GmRelay.DiscordBot.Rendering; -using NetCord; -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; - -namespace GmRelay.DiscordBot.Features.Sessions; - -public class DiscordNewSessionCommand : ApplicationCommandModule -{ - private readonly DiscordNewSessionHandler _handler; - private readonly ILogger _logger; - - public DiscordNewSessionCommand(DiscordNewSessionHandler handler, ILogger logger) - { - _handler = handler; - _logger = logger; - } - - [SlashCommand("newsession", "Create a new game session")] - public async Task ExecuteAsync( - [SlashCommandParameter(Name = "title", Description = "Game title")] string title, - [SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time, - [SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null, - [SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null) - { - _logger.LogInformation( - "newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}", - Context.User.Id, - Context.User.GetType().Name, - Context.Interaction.GuildId, - Context.Channel?.Id); - - var guildId = Context.Interaction.GuildId - ?? throw new InvalidOperationException("This command can only be used in a guild."); - - var member = Context.User as GuildInteractionUser; - if (member is null) - { - _logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name); - throw new InvalidOperationException("Guild member data not available in interaction."); - } - - var resolvedPermissions = (ulong)member.Permissions; - _logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions); - - ulong guildOwnerId = 0; - var guildName = guildId.ToString(); - try - { - var guild = await Context.Client.Rest.GetGuildAsync(guildId); - guildOwnerId = guild.OwnerId; - guildName = guild.Name; - _logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId); - } - catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - { - _logger.LogWarning( - ex, - "Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload", - guildId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId); - } - - var timeResult = DiscordNewSessionHandler.ParseTimeInput(time); - if (!timeResult.IsSuccess) - { - await Context.Interaction.SendResponseAsync( - InteractionCallback.Message($"X {timeResult.Error}")); - return; - } - - // Defer the response to avoid Discord 3-second interaction timeout - await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage()); - - try - { - _logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id); - - var view = await _handler.HandleAsync( - guildId: guildId.ToString(), - channelId: Context.Channel!.Id.ToString(), - groupName: guildName, - userId: Context.User.Id, - userDisplayName: Context.User.GlobalName ?? Context.User.Username, - resolvedPermissions: resolvedPermissions, - guildOwnerId: guildOwnerId, - title: title, - scheduledAt: timeResult.Value, - maxPlayers: seats is null ? null : (int)seats.Value, - joinLink: link, - CancellationToken.None); - - _logger.LogInformation("Session created successfully. Building render."); - - var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); - - _logger.LogInformation("Sending success response."); - - await Context.Interaction.ModifyResponseAsync(message => - { - message.Content = ":white_check_mark: **Session created successfully!**"; - message.Embeds = embeds; - message.Components = actionRows; - }); - - _logger.LogInformation("Success response sent."); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id); - await Context.Interaction.ModifyResponseAsync(message => - { - message.Content = $":no_entry: {ex.Message}"; - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId); - await Context.Interaction.ModifyResponseAsync(message => - { - message.Content = ":boom: An error occurred while creating the session."; - }); - } - } -} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs deleted file mode 100644 index 5de5f36..0000000 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs +++ /dev/null @@ -1,156 +0,0 @@ -using Dapper; -using GmRelay.DiscordBot.Infrastructure.Discord; -using GmRelay.Shared.Domain; -using GmRelay.Shared.Rendering; -using Npgsql; -using System.Globalization; - -namespace GmRelay.DiscordBot.Features.Sessions; - -public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error); - -public sealed class DiscordNewSessionHandler( - NpgsqlDataSource dataSource, - DiscordPermissionChecker permissionChecker, - ILogger logger) -{ - private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); - - public static TimeParseResult ParseTimeInput(string input) - { - var trimmed = input.Trim(); - - if (DateTime.TryParseExact( - trimmed, - "yyyy-MM-dd HH:mm", - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var dt1)) - { - var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime(); - if (offset < DateTimeOffset.UtcNow) - return new TimeParseResult(false, default, "Дата находится в прошлом."); - - return new TimeParseResult(true, offset, null); - } - - if (DateTime.TryParseExact( - trimmed, - "dd.MM.yyyy HH:mm", - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var dt2)) - { - var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime(); - if (offset < DateTimeOffset.UtcNow) - return new TimeParseResult(false, default, "Дата находится в прошлом."); - - return new TimeParseResult(true, offset, null); - } - - return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm"); - } - - public async Task HandleAsync( - string guildId, - string channelId, - string groupName, - ulong userId, - string userDisplayName, - ulong resolvedPermissions, - ulong guildOwnerId, - string title, - DateTimeOffset scheduledAt, - int? maxPlayers, - string? joinLink, - CancellationToken cancellationToken) - { - await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); - var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal) - ? title - : groupName.Trim(); - - var dbManagerUserIds = await connection.QueryAsync( - @"SELECT CAST(p.external_user_id AS BIGINT) - FROM group_managers gm - JOIN players p ON p.id = gm.player_id - JOIN game_groups g ON g.id = gm.group_id - WHERE g.platform = 'Discord' - AND p.platform = 'Discord' - AND g.external_group_id = @GuildId", - new { GuildId = guildId }); - - if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) - { - throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии."); - } - - await using var transaction = await connection.BeginTransactionAsync(cancellationToken); - var transactionCommitted = false; - try - { - await connection.ExecuteAsync( - @"INSERT INTO players (display_name, platform, external_user_id, external_username) - VALUES (@Name, 'Discord', @UserId, @Name) - ON CONFLICT (platform, external_user_id) - WHERE platform IS NOT NULL AND external_user_id IS NOT NULL - DO UPDATE SET display_name = EXCLUDED.display_name, - external_username = EXCLUDED.external_username", - new { Name = userDisplayName, UserId = userId.ToString() }, - transaction); - - var groupId = await connection.ExecuteScalarAsync( - @"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id) - VALUES (@GroupName, 'Discord', @GuildId, @ChannelId) - ON CONFLICT (platform, external_group_id) - WHERE platform IS NOT NULL AND external_group_id IS NOT NULL - DO UPDATE SET name = EXCLUDED.name, - external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id) - RETURNING id", - new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId }, - transaction); - - await connection.ExecuteAsync( - @"INSERT INTO group_managers (group_id, player_id, role) - SELECT @GroupId, p.id, @OwnerRole - FROM players p - WHERE p.platform = 'Discord' AND p.external_user_id = @UserId - ON CONFLICT (group_id, player_id) DO NOTHING", - new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue }, - transaction); - - var batchId = Guid.NewGuid(); - var sessionId = await connection.ExecuteScalarAsync( - @"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players) - VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers) - RETURNING id", - new - { - BatchId = batchId, - GroupId = groupId, - Title = title, - Link = joinLink ?? string.Empty, - ScheduledAt = scheduledAt.UtcDateTime, - Status = SessionStatus.Planned, - MaxPlayers = maxPlayers - }, - transaction); - - await transaction.CommitAsync(cancellationToken); - transactionCommitted = true; - logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId); - - var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) }; - return SessionBatchViewBuilder.Build(title, sessions, Array.Empty()); - } - catch - { - if (!transactionCommitted) - { - await transaction.RollbackAsync(cancellationToken); - } - - throw; - } - } -} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs index 09ffc8e..20cc4f4 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs @@ -75,7 +75,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule(); foreach (var opt in options) { - var result = DiscordNewSessionHandler.ParseTimeInput(opt); + var result = DiscordTimeParser.ParseTimeInput(opt); if (!result.IsSuccess) { await Context.Interaction.SendResponseAsync( @@ -85,7 +85,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule -/// Small lookup helper for Discord permission checks. The -/// already runs the same SQL -/// inline; this class is here so the wizard slash command can do the -/// same check without duplicating the query string. +/// Small lookup helper for Discord permission checks. The slash command +/// and reschedule command both need to enumerate DB managers for a guild; +/// this class centralises the query so it isn't duplicated. /// internal static class DiscordPermissionLookup { diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs index 96743ab..58c0d2c 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs @@ -13,7 +13,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard; /// /// Slash entry point for the Discord wizard. Mirrors the Telegram -/// /newsession-wizard command: a fresh draft is created on +/// /newsession command: a fresh draft is created on /// first invocation, the persisted first-step message is re-shown /// when the user already has an active draft, and the owner/co-GM /// permission check from is @@ -44,7 +44,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule= 3 && parts[2] == "restart") { await _drafts.DeleteAsync(draft.Id, ct); _contextStore.Remove(draft.Id); await context.Interaction.SendResponseAsync(InteractionCallback.Message( new InteractionMessageProperties() - .WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.") + .WithContent("♻️ Мастер сброшен. Запустите /newsession заново.") .WithFlags(MessageFlags.Ephemeral))); return; } @@ -314,7 +315,7 @@ public sealed class WizardInteractionDispatcher { await context.Interaction.SendResponseAsync(InteractionCallback.Message( new InteractionMessageProperties() - .WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.") + .WithContent("📭 Нет активного мастера. Запустите /newsession.") .WithFlags(MessageFlags.Ephemeral))); return; } @@ -365,7 +366,7 @@ public sealed class WizardInteractionDispatcher { await context.Interaction.SendResponseAsync(InteractionCallback.Message( new InteractionMessageProperties() - .WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.") + .WithContent("📭 Нет активного мастера. Запустите /newsession.") .WithFlags(MessageFlags.Ephemeral))); return; } diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs index ff3a596..b6bff60 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs @@ -60,6 +60,8 @@ public static class DiscordWizardStep WizardStepNames.Duration => RenderDuration(), WizardStepNames.DateTime => RenderDateTime(), WizardStepNames.Capacity => RenderCapacity(), + WizardStepNames.Format => RenderFormat(), + WizardStepNames.Location => RenderLocation(payload), WizardStepNames.Visibility => RenderVisibility(), WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty()), WizardStepNames.Publish => RenderPublish(), @@ -263,6 +265,29 @@ public static class DiscordWizardStep }, OpenModalStep: WizardStepNames.Capacity); + private static DiscordWizardRender RenderFormat() => new( + "🧭 Формат игры", + "Выберите формат.", + new[] + { + Row(ChoiceBtn("🌐 Online", WizardStepNames.Format, "online", ButtonStyle.Primary), + ChoiceBtn("📍 Offline", WizardStepNames.Format, "offline", ButtonStyle.Primary)), + Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)), + }, + OpenModalStep: null); + + private static DiscordWizardRender RenderLocation(WizardPayload payload) + { + var isOnline = payload.Format == WizardSessionFormat.Online; + return new DiscordWizardRender( + isOnline ? "🔗 Ссылка" : "📍 Адрес", + isOnline ? "Введите ссылку для подключения." : "Введите адрес места проведения.", + new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"), + ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) }, + OpenModalStep: WizardStepNames.Location); + } + private static DiscordWizardRender RenderVisibility() => new( "🔒 Видимость", "Выберите, кто увидит сессию.", @@ -566,6 +591,20 @@ public static class DiscordWizardStep Required = true, }), }), + WizardStepNames.Location => new ModalProperties( + ModalCustomId(WizardStepNames.Location), + "🔗 Ссылка / 📍 Адрес", + new IModalComponentProperties[] + { + new LabelProperties( + "Ссылка или адрес", + new TextInputProperties(ModalCustomId(WizardStepNames.Location), TextInputStyle.Short) + { + Placeholder = "https://… или адрес", + MaxLength = WizardStepLimits.MaxLocationLength, + Required = true, + }), + }), WizardStepNames.PoolSlotDateTime => new ModalProperties( ModalCustomId(WizardStepNames.PoolSlotDateTime), "📅 Дата/время слота", diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs index 8e067b2..ee613dc 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs @@ -12,6 +12,7 @@ using GmRelay.Shared.Platform; using Microsoft.Extensions.Logging; using NetCord; using NetCord.Rest; +using Npgsql; namespace GmRelay.DiscordBot.Features.Sessions.Wizard; @@ -31,19 +32,25 @@ public sealed class DiscordWizardSubmitter private readonly IWizardDraftRepository _drafts; private readonly IWizardContextStore _contextStore; private readonly ILogger _log; + private readonly IPlatformMessenger? _platformMessenger; + private readonly NpgsqlDataSource? _dataSource; public DiscordWizardSubmitter( CreateSessionHandler shared, RestClient rest, IWizardDraftRepository drafts, IWizardContextStore contextStore, - ILogger log) + ILogger log, + IPlatformMessenger? platformMessenger = null, + NpgsqlDataSource? dataSource = null) { _shared = shared; _rest = rest; _drafts = drafts; _contextStore = contextStore; _log = log; + _platformMessenger = platformMessenger; + _dataSource = dataSource; } /// @@ -65,21 +72,25 @@ public sealed class DiscordWizardSubmitter return; } + var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>(); try { var commands = BuildCommands(draft, payload); foreach (var cmd in commands) { - await _shared.HandleAsync(cmd, ct); + var result = await _shared.HandleAsync(cmd, ct); + if (!result.Success) + { + await EditDraftMessageAsync( + draft, + result.ErrorMessage ?? "❌ Не удалось создать сессию.", + Array.Empty(), + ct); + return; + } + + created.Add((cmd, result)); } - var totalSessions = commands.Sum(c => c.ScheduledTimes.Count); - await EditDraftMessageAsync( - draft, - $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}", - Array.Empty(), - ct); - await _drafts.DeleteAsync(draft.Id, ct); - _contextStore.Remove(draft.Id); } catch (Exception ex) { @@ -90,7 +101,7 @@ public sealed class DiscordWizardSubmitter { await EditDraftMessageAsync( draft, - "💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.", + "💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.", Array.Empty(), ct); await _drafts.DeleteAsync(draft.Id, ct); @@ -140,7 +151,7 @@ public sealed class DiscordWizardSubmitter } private static int MaxPlayersForPool(WizardPoolInput pool) => - pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers); + pool.MaxPlayers ?? (pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers)); internal static CreateSessionCommand BuildCommand( WizardDraft draft, @@ -164,15 +175,16 @@ public sealed class DiscordWizardSubmitter User: user, Group: group, Title: p.Title ?? string.Empty, - Link: string.Empty, + Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty, ScheduledTimes: scheduledTimes, MaxPlayers: maxPlayers, ImageReference: p.ImageFileId ?? p.ImageUrl, System: ParseSystem(p.System), Description: p.Description, - Format: null, + Format: p.Format?.ToString(), DurationMinutes: p.DurationMinutes, - IsOneShot: isOneShot); + IsOneShot: isOneShot, + LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null); } private static GameSystem? ParseSystem(string? code) @@ -188,12 +200,15 @@ public sealed class DiscordWizardSubmitter if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название"); if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система"); if (!p.DurationMinutes.HasValue) missingFields.Add("длительность"); + if (p.Format is null) missingFields.Add("формат"); + if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка"); + if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес"); if (p.Visibility is null) missingFields.Add("видимость"); if (p.Type == WizardCreationType.Single) { if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время"); - if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест"); + // MaxPlayers = null is a valid "♾ Без лимита" choice. } else { diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index aeb5b41..28eb273 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -61,7 +61,6 @@ builder.Services.AddSingleton(sp => builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs index 4b2ad98..215f207 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -224,7 +224,16 @@ public sealed class GameCreationWizard ? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload) : (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); - case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: + case WizardStepNames.Capacity: + if (payload.Type == WizardCreationType.Pool) + { + if (payload.Pool?.MaxPlayers is not null) return (null, "Лимит уже задан", payload); + return int.TryParse(input, out var poolCap) && poolCap >= WizardStepLimits.MinCapacity && poolCap <= WizardStepLimits.MaxCapacity + ? (WizardStepNames.Format, SetPoolMaxPlayers(payload, poolCap), payload) + : (null, "Лимит должен быть 1..50", payload); + } + + if (payload.Single?.MaxPlayers is not null) return (null, "Лимит уже задан", payload); return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity ? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload) : (null, "Лимит должен быть 1..50", payload); @@ -314,10 +323,12 @@ public sealed class GameCreationWizard { if (choice is "no_limit") { - return (WizardStepNames.Format, SetMaxPlayers(p, null)); + return p.Type == WizardCreationType.Pool + ? (WizardStepNames.Format, SetPoolMaxPlayers(p, null)) + : (WizardStepNames.Format, SetMaxPlayers(p, null)); } - if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null) + if (choice is "waitlist:on" or "waitlist:off" && p.Type != WizardCreationType.Pool && p.Single?.MaxPlayers is null) { return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»"); } @@ -368,11 +379,12 @@ public sealed class GameCreationWizard { "_custom" => (WizardStepNames.PoolSystemDuration, null), { } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur) - ? (WizardStepNames.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur)) + ? (WizardStepNames.Capacity, SetSystem(p, sys) ?? SetDurationMinutes(p, dur)) : (null, "Неверный выбор"), _ => (null, "Неизвестный выбор"), }; + private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch { "add" => BeginNewPoolSlot(p), @@ -410,7 +422,7 @@ public sealed class GameCreationWizard WizardStepNames.Duration => WizardStepNames.System, WizardStepNames.DateTime => WizardStepNames.Duration, WizardStepNames.Capacity => WizardStepNames.DateTime, - WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity, + WizardStepNames.Format => WizardStepNames.Capacity, WizardStepNames.Location => WizardStepNames.Format, WizardStepNames.Visibility => WizardStepNames.Location, WizardStepNames.PickClub => WizardStepNames.Visibility, @@ -458,6 +470,8 @@ public sealed class GameCreationWizard { p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; } private static string? SetMaxPlayers(WizardPayload p, int? v) { p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; } + private static string? SetPoolMaxPlayers(WizardPayload p, int? v) + { p.Pool ??= new WizardPoolInput(); p.Pool.MaxPlayers = v; return null; } private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; } private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; } private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; } @@ -518,7 +532,7 @@ 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.Format; + if (p.Type == WizardCreationType.Pool) return WizardStepNames.Capacity; return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime; } private static string? NextAfterVisibility(WizardPayload p) @@ -530,6 +544,7 @@ public sealed class GameCreationWizard return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish; } + private static (string? sys, int? dur) SplitSystemDuration(string s) { var idx = s.IndexOf(':'); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs index 39a3bb6..0fd5200 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs @@ -50,6 +50,8 @@ public sealed class WizardPayload public sealed class WizardPoolInput { + public int? MaxPlayers { get; set; } + public List Slots { get; set; } = new(); } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs index b37eca3..3a9c1b9 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs @@ -224,6 +224,7 @@ public static class WizardStepViewBuilder if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); AppendFormatLocation(sb, p); + if (p.Pool?.MaxPlayers is { } poolMax) sb.AppendLine($"👥 Мест в пуле: {poolMax}"); sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); sb.AppendLine(); sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):"); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 3901427..6e71826 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -82,7 +82,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs deleted file mode 100644 index d72e9d5..0000000 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs +++ /dev/null @@ -1,225 +0,0 @@ -using GmRelay.DiscordBot.Features.Sessions; - -namespace GmRelay.Bot.Tests.Discord; - -public sealed class DiscordNewSessionHandlerTests -{ - private static string GetRepoRoot() - { - var dir = AppContext.BaseDirectory; - while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) - { - dir = Directory.GetParent(dir)?.FullName; - } - - return dir ?? throw new InvalidOperationException("Could not find repo root"); - } - - // --- Runtime tests for ParseTimeInput (static, no DB) --- - - [Fact] - public void ParseTimeInput_ShouldTreatInputAsMoscowTime() - { - var future = DateTimeOffset.UtcNow.AddDays(7); - var result = DiscordNewSessionHandler.ParseTimeInput( - future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture)); - - Assert.True(result.IsSuccess); - // 15:00 MSK = 12:00 UTC - Assert.Equal(12, result.Value.Hour); - Assert.Equal(0, result.Value.Minute); - Assert.Equal(TimeSpan.Zero, result.Value.Offset); - } - - [Fact] - public void ParseTimeInput_ShouldParseDiscordDateFormat() - { - var expected = FutureDateAt1930(); - var result = DiscordNewSessionHandler.ParseTimeInput( - expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)); - - Assert.True(result.IsSuccess); - Assert.Equal(expected.Year, result.Value.Year); - Assert.Equal(expected.Month, result.Value.Month); - Assert.Equal(expected.Day, result.Value.Day); - // Input is treated as Moscow time; 19:30 MSK = 16:30 UTC - Assert.Equal(16, result.Value.Hour); - Assert.Equal(30, result.Value.Minute); - } - - [Fact] - public void ParseTimeInput_ShouldRejectPastDate() - { - var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00"); - Assert.False(result.IsSuccess); - } - - [Fact] - public void ParseTimeInput_ShouldParseRussianDateFormat() - { - var expected = FutureDateAt1930(); - var result = DiscordNewSessionHandler.ParseTimeInput( - expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture)); - - Assert.True(result.IsSuccess); - Assert.Equal(expected.Year, result.Value.Year); - Assert.Equal(expected.Month, result.Value.Month); - Assert.Equal(expected.Day, result.Value.Day); - } - - [Fact] - public void ParseTimeInput_ShouldRejectInvalidFormat() - { - var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date"); - Assert.False(result.IsSuccess); - Assert.NotNull(result.Error); - } - - // --- Source-level structural tests --- - - [Fact] - public void Handler_ShouldExist() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - Assert.True(File.Exists(handlerPath), "DiscordNewSessionHandler should exist."); - } - - [Fact] - public void Handler_ShouldUseDapperForDatabaseAccess() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Contains("QueryAsync", source, StringComparison.Ordinal); - Assert.Contains("ExecuteAsync", source, StringComparison.Ordinal); - Assert.Contains("ExecuteScalarAsync", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldUseNpgsqlDataSource() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Contains("NpgsqlDataSource", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldCheckPermissionsViaPermissionChecker() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Contains("CanManageSchedule", source, StringComparison.Ordinal); - Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldLoadCoGmPermissionsFromDiscordPlayers() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Matches( - @"QueryAsync[\s\S]*JOIN players p ON p\.id = gm\.player_id[\s\S]*p\.platform = 'Discord'[\s\S]*g\.external_group_id = @GuildId", - source); - } - - [Fact] - public void Handler_ShouldBePlatformNeutral() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.DoesNotContain("telegram_chat_id", source, StringComparison.Ordinal); - Assert.DoesNotContain("telegram_id", source, StringComparison.Ordinal); - Assert.Contains("platform = 'Discord'", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldUseTransactions() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Contains("BeginTransactionAsync", source, StringComparison.Ordinal); - Assert.Contains("CommitAsync", source, StringComparison.Ordinal); - Assert.Contains("RollbackAsync", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal); - Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal); - Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldRespectCancellationToken() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Contains("CancellationToken", source, StringComparison.Ordinal); - } - - [Fact] - public void Command_ShouldRenderEmbedOnSuccess() - { - var repoRoot = GetRepoRoot(); - var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionCommand.cs"); - var source = File.ReadAllText(commandPath); - - Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal); - Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal); - } - - [Fact] - public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards() - { - var repoRoot = GetRepoRoot(); - var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); - var source = File.ReadAllText(handlerPath); - - Assert.Contains("groupName", source, StringComparison.Ordinal); - Assert.Contains("displayGroupName", source, StringComparison.Ordinal); - Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal); - } - - private static DateTimeOffset FutureDateAt1930() - { - var future = DateTimeOffset.UtcNow.AddDays(7); - return new DateTimeOffset( - future.Year, - future.Month, - future.Day, - 19, - 30, - 0, - TimeSpan.Zero); - } -} diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs index f625356..fc2ebd3 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Reflection; using GmRelay.DiscordBot.Features.Sessions; +using GmRelay.DiscordBot.Features.Sessions.Wizard; using NetCord.Services.ApplicationCommands; namespace GmRelay.Bot.Tests.Discord; @@ -54,7 +55,6 @@ public sealed class DiscordStartupTests } [Theory] - [InlineData(typeof(DiscordNewSessionCommand), "newsession")] [InlineData(typeof(DiscordListSessionsCommand), "listsessions")] [InlineData(typeof(DiscordRescheduleCommand), "reschedule")] public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName) @@ -76,15 +76,28 @@ public sealed class DiscordStartupTests { var service = new ApplicationCommandService(); - service.AddModules(typeof(DiscordNewSessionCommand).Assembly); + service.AddModules(typeof(DiscordListSessionsCommand).Assembly); + + var commandNames = service.GetCommands() + .Select(command => command.Name) + .ToArray(); + + Assert.Contains("listsessions", commandNames); + Assert.Contains("reschedule", commandNames); + } + + [Fact] + public void DiscordSessionSlashCommands_ShouldIncludeNewSessionWizard() + { + var service = new ApplicationCommandService(); + + service.AddModules(typeof(DiscordWizardCommand).Assembly); var commandNames = service.GetCommands() .Select(command => command.Name) .ToArray(); Assert.Contains("newsession", commandNames); - Assert.Contains("listsessions", commandNames); - Assert.Contains("reschedule", commandNames); } [Fact] @@ -114,7 +127,6 @@ public sealed class DiscordStartupTests { var program = ReadProgram(); Assert.Contains("DiscordListSessionsHandler", program); - Assert.Contains("DiscordNewSessionHandler", program); Assert.Contains("JoinSessionHandler", program); Assert.Contains("LeaveSessionHandler", program); Assert.Contains("DiscordPermissionChecker", program); diff --git a/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardStepCapacityRenderTests.cs b/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardStepCapacityRenderTests.cs index f5fe3a5..e2bd626 100644 --- a/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardStepCapacityRenderTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardStepCapacityRenderTests.cs @@ -56,6 +56,29 @@ public sealed class DiscordWizardStepCapacityRenderTests .Select(b => b.Label ?? string.Empty) .ToList(); + [Fact] + public void RenderFormat_ContainsOnlineAndOfflineButtons() + { + var draft = new WizardDraft { Step = WizardStepNames.Format }; + var render = DiscordWizardStep.Render(draft, new WizardPayload()); + + var labels = ExtractButtonLabels(render); + Assert.Contains(labels, l => l.Contains("Online", System.StringComparison.Ordinal)); + Assert.Contains(labels, l => l.Contains("Offline", System.StringComparison.Ordinal)); + } + + [Theory] + [InlineData(WizardSessionFormat.Online, "🔗 Ссылка")] + [InlineData(WizardSessionFormat.Offline, "📍 Адрес")] + public void RenderLocation_ForFormat_OpensModalAndShowsPrompt(WizardSessionFormat format, string expectedTitle) + { + var draft = new WizardDraft { Step = WizardStepNames.Location }; + var render = DiscordWizardStep.Render(draft, new WizardPayload { Format = format }); + + Assert.Equal(expectedTitle, render.EmbedTitle); + Assert.Equal(WizardStepNames.Location, render.OpenModalStep); + } + private static System.Collections.Generic.List ExtractButtons( DiscordWizardStep.DiscordWizardRender render) => render.Components diff --git a/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardSubmitterBuildCommandTests.cs b/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardSubmitterBuildCommandTests.cs index d0d87ed..4a40ef2 100644 --- a/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardSubmitterBuildCommandTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/Wizard/DiscordWizardSubmitterBuildCommandTests.cs @@ -82,4 +82,117 @@ public sealed class DiscordWizardSubmitterBuildCommandTests Assert.Equal(5, cmd.MaxPlayers); } + + [Fact] + public void BuildCommand_WhenFormatIsOnline_PropagatesFormatAndJoinLink() + { + var draft = new WizardDraft + { + Id = Guid.NewGuid(), + ChatId = "42", + OwnerId = "100", + Step = "confirm", + }; + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Format = WizardSessionFormat.Online, + JoinLink = "https://vtt.example/game", + Single = new WizardSingleInput + { + ScheduledAt = DateTimeOffset.UtcNow.AddDays(1), + MaxPlayers = 5, + }, + }; + + var cmd = DiscordWizardSubmitter.BuildCommand( + draft, + payload, + new[] { payload.Single!.ScheduledAt!.Value }, + payload.Single.MaxPlayers, + isOneShot: true); + + Assert.Equal("Online", cmd.Format); + Assert.Equal("https://vtt.example/game", cmd.Link); + Assert.Null(cmd.LocationAddress); + } + + [Fact] + public void BuildCommand_WhenFormatIsOffline_PropagatesFormatAndAddress() + { + var draft = new WizardDraft + { + Id = Guid.NewGuid(), + ChatId = "42", + OwnerId = "100", + Step = "confirm", + }; + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Format = WizardSessionFormat.Offline, + LocationAddress = "Москва, ул. Кубиков, 12", + Single = new WizardSingleInput + { + ScheduledAt = DateTimeOffset.UtcNow.AddDays(1), + MaxPlayers = 5, + }, + }; + + var cmd = DiscordWizardSubmitter.BuildCommand( + draft, + payload, + new[] { payload.Single!.ScheduledAt!.Value }, + payload.Single.MaxPlayers, + isOneShot: true); + + Assert.Equal("Offline", cmd.Format); + Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress); + Assert.Equal(string.Empty, cmd.Link); + } + + [Fact] + public void BuildCommand_WhenPoolMaxPlayersIsSet_PropagatesValueToMaxPlayers() + { + var draft = new WizardDraft + { + Id = Guid.NewGuid(), + ChatId = "42", + OwnerId = "100", + Step = "confirm", + }; + var slotTime = DateTimeOffset.UtcNow.AddDays(1); + var payload = new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Format = WizardSessionFormat.Online, + JoinLink = "https://vtt.example/game", + Pool = new WizardPoolInput + { + MaxPlayers = 12, + Slots = { new WizardSlotInput { ScheduledAt = slotTime, MaxPlayers = 8 } }, + }, + }; + + var cmd = DiscordWizardSubmitter.BuildCommand( + draft, + payload, + new[] { slotTime }, + payload.Pool.MaxPlayers, + isOneShot: false); + + Assert.Equal(12, cmd.MaxPlayers); + } } 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 60c85ec..ea48e99 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs @@ -46,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests } [Fact] - public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat() + public async Task PoolSystemDuration_PreselectedButton_AdvancesToCapacity() { var wizard = BuildWizard(out var drafts, out _); var payload = new WizardPayload @@ -60,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240"); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); - Assert.Equal(WizardStepNames.Format, draft.Step); + Assert.Equal(WizardStepNames.Capacity, draft.Step); using var doc = JsonDocument.Parse(draft.PayloadJson); var root = doc.RootElement; Assert.True(root.TryGetProperty("system", out var sys)); @@ -69,6 +69,53 @@ public sealed class GameCreationWizardStepTransitionsTests Assert.Equal(240, dur.GetInt32()); } + [Fact] + public async Task PoolCapacity_Text_AdvancesToFormat_AndSetsMaxPlayers() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + DurationMinutes = 240, + }; + var draft = NewDraft(WizardStepNames.Capacity, payload); + drafts.Seed(draft); + + await wizard.HandleInteractionAsync(TextInteraction("10", ownerId: draft.OwnerId), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Format, draft.Step); + using var doc = JsonDocument.Parse(draft.PayloadJson); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("pool", out var pool)); + Assert.True(pool.TryGetProperty("maxPlayers", out var maxPlayers)); + Assert.Equal(10, maxPlayers.GetInt32()); + } + + [Theory] + [InlineData("waitlist:on")] + [InlineData("waitlist:off")] + public async Task Capacity_WithMaxPlayersAndWaitlist_AdvancesToFormat(string waitlistChoice) + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "T", + System = "Dnd5e", + DurationMinutes = 240, + Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1), MaxPlayers = 5 }, + }; + var draft = NewDraft(WizardStepNames.Capacity, payload); + drafts.Seed(draft); + + var data = WizardCallbackData.Choice(WizardStepNames.Capacity, waitlistChoice); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Format, draft.Step); + } + [Fact] public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull() { From e0602052ea6a788788a3b4d2e3bbcc532535844c Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 15 Jun 2026 18:37:23 +0300 Subject: [PATCH 2/2] review fixes: complete success path, pool capacity navigation, time parser tests - DiscordWizardSubmitter.SubmitAsync: confirm success, delete draft, clear context. - GameCreationWizard: pool free-text duration now advances to Capacity. - PreviousStep(Capacity) returns PoolSystemDuration for pools. - Remove unused optional IPlatformMessenger/NpgsqlDataSource from submitter. - Add DiscordTimeParserTests preserving ParseTimeInput coverage. --- .../Sessions/Wizard/DiscordWizardSubmitter.cs | 18 +++-- .../Wizard/GameCreationWizard.cs | 4 +- .../Discord/DiscordTimeParserTests.cs | 77 +++++++++++++++++++ .../GameCreationWizardStepTransitionsTests.cs | 42 ++++++++++ 4 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 tests/GmRelay.Bot.Tests/Discord/DiscordTimeParserTests.cs diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs index ee613dc..3f281a1 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs @@ -12,7 +12,6 @@ using GmRelay.Shared.Platform; using Microsoft.Extensions.Logging; using NetCord; using NetCord.Rest; -using Npgsql; namespace GmRelay.DiscordBot.Features.Sessions.Wizard; @@ -32,25 +31,19 @@ public sealed class DiscordWizardSubmitter private readonly IWizardDraftRepository _drafts; private readonly IWizardContextStore _contextStore; private readonly ILogger _log; - private readonly IPlatformMessenger? _platformMessenger; - private readonly NpgsqlDataSource? _dataSource; public DiscordWizardSubmitter( CreateSessionHandler shared, RestClient rest, IWizardDraftRepository drafts, IWizardContextStore contextStore, - ILogger log, - IPlatformMessenger? platformMessenger = null, - NpgsqlDataSource? dataSource = null) + ILogger log) { _shared = shared; _rest = rest; _drafts = drafts; _contextStore = contextStore; _log = log; - _platformMessenger = platformMessenger; - _dataSource = dataSource; } /// @@ -91,6 +84,15 @@ public sealed class DiscordWizardSubmitter created.Add((cmd, result)); } + + // Success: replace the wizard message with a confirmation and + // clean up the draft so the user can start a new one later. + var confirmation = created.Count == 1 + ? $"✅ Создано: {created[0].Command.Title}" + : $"✅ Создано: {created[0].Command.Title} и ещё {created.Count - 1} сессия/сессии"; + await EditDraftMessageAsync(draft, confirmation, Array.Empty(), ct); + await _drafts.DeleteAsync(draft.Id, ct); + _contextStore.Remove(draft.Id); } catch (Exception ex) { diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs index 215f207..5dadf70 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -256,7 +256,7 @@ public sealed class GameCreationWizard case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null: return TryParseHours(input, out var pdur) - ? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload) + ? (WizardStepNames.Capacity, SetDurationMinutes(payload, pdur), payload) : (null, "Неверная длительность (1..12 ч)", payload); case WizardStepNames.PoolSlotDateTime: @@ -421,7 +421,7 @@ public sealed class GameCreationWizard WizardStepNames.System => WizardStepNames.Cover, WizardStepNames.Duration => WizardStepNames.System, WizardStepNames.DateTime => WizardStepNames.Duration, - WizardStepNames.Capacity => WizardStepNames.DateTime, + WizardStepNames.Capacity => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.DateTime, WizardStepNames.Format => WizardStepNames.Capacity, WizardStepNames.Location => WizardStepNames.Format, WizardStepNames.Visibility => WizardStepNames.Location, diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordTimeParserTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordTimeParserTests.cs new file mode 100644 index 0000000..930613b --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordTimeParserTests.cs @@ -0,0 +1,77 @@ +using GmRelay.DiscordBot.Features.Sessions; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordTimeParserTests +{ + [Fact] + public void ParseTimeInput_ShouldTreatInputAsMoscowTime() + { + var future = DateTimeOffset.UtcNow.AddDays(7); + var result = DiscordTimeParser.ParseTimeInput( + future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture)); + + Assert.True(result.IsSuccess); + // 15:00 MSK = 12:00 UTC + Assert.Equal(12, result.Value.Hour); + Assert.Equal(0, result.Value.Minute); + Assert.Equal(TimeSpan.Zero, result.Value.Offset); + } + + [Fact] + public void ParseTimeInput_ShouldParseDiscordDateFormat() + { + var expected = FutureDateAt1930(); + var result = DiscordTimeParser.ParseTimeInput( + expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)); + + Assert.True(result.IsSuccess); + Assert.Equal(expected.Year, result.Value.Year); + Assert.Equal(expected.Month, result.Value.Month); + Assert.Equal(expected.Day, result.Value.Day); + // Input is treated as Moscow time; 19:30 MSK = 16:30 UTC + Assert.Equal(16, result.Value.Hour); + Assert.Equal(30, result.Value.Minute); + } + + [Fact] + public void ParseTimeInput_ShouldRejectPastDate() + { + var result = DiscordTimeParser.ParseTimeInput("2020-01-01 00:00"); + Assert.False(result.IsSuccess); + } + + [Fact] + public void ParseTimeInput_ShouldParseRussianDateFormat() + { + var expected = FutureDateAt1930(); + var result = DiscordTimeParser.ParseTimeInput( + expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture)); + + Assert.True(result.IsSuccess); + Assert.Equal(expected.Year, result.Value.Year); + Assert.Equal(expected.Month, result.Value.Month); + Assert.Equal(expected.Day, result.Value.Day); + } + + [Fact] + public void ParseTimeInput_ShouldRejectInvalidFormat() + { + var result = DiscordTimeParser.ParseTimeInput("not-a-date"); + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + } + + private static DateTimeOffset FutureDateAt1930() + { + var future = DateTimeOffset.UtcNow.AddDays(7); + return new DateTimeOffset( + future.Year, + future.Month, + future.Day, + 19, + 30, + 0, + TimeSpan.Zero); + } +} 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 ea48e99..9ece600 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs @@ -93,6 +93,48 @@ public sealed class GameCreationWizardStepTransitionsTests Assert.Equal(10, maxPlayers.GetInt32()); } + [Fact] + public async Task PoolSystemDuration_FreeTextDuration_AdvancesToCapacity() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + }; + var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload); + drafts.Seed(draft); + + await wizard.HandleInteractionAsync(TextInteraction("4", ownerId: draft.OwnerId), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.Capacity, draft.Step); + using var doc = JsonDocument.Parse(draft.PayloadJson); + var root = doc.RootElement; + Assert.True(root.TryGetProperty("durationMinutes", out var dur)); + Assert.Equal(240, dur.GetInt32()); + } + + [Fact] + public async Task Back_FromPoolCapacity_GoesToPoolSystemDuration() + { + var wizard = BuildWizard(out var drafts, out _); + var payload = new WizardPayload + { + Type = WizardCreationType.Pool, + Title = "Pool", + System = "Dnd5e", + DurationMinutes = 240, + }; + var draft = NewDraft(WizardStepNames.Capacity, payload); + drafts.Seed(draft); + + var data = WizardCallbackData.Back(); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); + + Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step); + } + [Theory] [InlineData("waitlist:on")] [InlineData("waitlist:off")]