From 4a04d7d723fc0a47193d950f7a40e94e0d9035d8 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 4 Jun 2026 09:00:37 +0300 Subject: [PATCH] refactor(wizard): make CreateSessionHandler wizard-driven and remove legacy parser --- .../CreateSession/CreateSessionHandler.cs | 386 ++++++++++-------- .../CreateSession/NewSessionCommandParser.cs | 184 --------- .../Infrastructure/Telegram/UpdateRouter.cs | 4 +- .../CreateSession/Wizard/WizardPayload.cs | 4 + .../DiscordLandingPromisesSmokeTests.cs | 38 +- .../TelegramLandingPromisesSmokeTests.cs | 38 +- .../NewSessionCommandParserTests.cs | 152 ------- .../TelegramTopicIntegrationSmokeTests.cs | 14 +- 8 files changed, 282 insertions(+), 538 deletions(-) delete mode 100644 src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs delete mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 41a65c8..863b2cc 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -1,194 +1,254 @@ -using Dapper; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Platform; -using GmRelay.Bot.Infrastructure.Telegram; -using Npgsql; -using Telegram.Bot; +using Microsoft.Extensions.Logging; using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; +using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler; namespace GmRelay.Bot.Features.Sessions.CreateSession; -public sealed class CreateSessionHandler( - GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler, - NpgsqlDataSource dataSource, - IPlatformMessenger messenger, - ILogger logger) +/// +/// Wizard-driven entry point for game-session creation. Replaces the legacy +/// text-template parser. Exposes (called from +/// /newsession), (continue a draft), and +/// (finalize on "✅ Создать" callback). +/// +public sealed class CreateSessionHandler { - public async Task HandleAsync(Message message, CancellationToken ct) + private const int MaxRetries = 3; + + private readonly WizardDraftRepository _drafts; + private readonly SharedCreateSessionHandler _shared; + private readonly ITelegramWizardMessenger _messenger; + private readonly ILogger _log; + + public CreateSessionHandler( + WizardDraftRepository drafts, + SharedCreateSessionHandler shared, + ITelegramWizardMessenger messenger, + ILogger log) { - var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow); + _drafts = drafts; + _shared = shared; + _messenger = messenger; + _log = log; + } - foreach (var timeInput in parseResult.PastTimeInputs) + /// + /// Entry point for /newsession. If a non-expired draft already exists for + /// this (chat, thread, owner), returns null so the caller can render a + /// "Continue / Start over / Cancel" menu. + /// + public async Task StartWizardAsync(Message message, CancellationToken ct) + { + var existing = await _drafts.GetActiveAsync( + message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct); + if (existing is not null) { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - $"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.", - ct); + return null; } - foreach (var timeInput in parseResult.InvalidTimeInputs) + var draft = new WizardDraft { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - $"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.", - ct); - } + Id = Guid.NewGuid(), + ChatId = message.Chat.Id, + MessageThreadId = message.MessageThreadId, + OwnerTelegramId = message.From?.Id ?? 0, + Step = WizardStepNames.Type, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), + }; + await _drafts.UpsertAsync(draft, ct); - foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs) + var (text, kb) = WizardStep.Render(draft, new WizardPayload()); + var msgId = await _messenger.SendGroupMessageAsync( + draft.ChatId, draft.MessageThreadId, text, kb, ct); + draft.DraftMessageId = msgId; + draft.UpdatedAt = DateTimeOffset.UtcNow; + await _drafts.UpsertAsync(draft, ct); + return draft; + } + + /// + /// Resume an existing draft — returns the draft row so the caller can re-render. + /// + public Task TryResumeAsync(Message message, CancellationToken ct) => + _drafts.GetActiveAsync( + message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct); + + /// + /// Finalize: build shared command(s), call the shared handler, edit the wizard message. + /// On failure, retry up to times before deleting the draft. + /// + public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct) + { + var payload = LoadPayload(draft); + if (!IsComplete(payload, out var missing)) { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - $"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.", - ct); - } - - foreach (var recurringInput in parseResult.InvalidRecurringInputs) - { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - $"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.", - ct); - } - - if (!parseResult.IsValid) - { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - """ - ❌ Не удалось распознать формат. Пожалуйста, используйте шаблон: - - /newsession - Название: My Game - Время: 15.05.2026 19:30 - Время: 22.05.2026 19:30 - Мест: 4 - Ссылка: https://link - Картинка: https://cover - - Для повтора можно указать одну дату и строки: - Игр: 4 - Интервал: 7 - """, - ct); + await _messenger.EditMessageTextAsync( + draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + $"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct); return; } - var imageReference = GetBatchImageReference(message, parseResult.ImageUrl); - var gmId = message.From!.Id; - var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}"); - var gmUsername = message.From.Username; - - var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination( - message.Chat.IsForum, - message.MessageThreadId); - var topicCreatedByBot = topicDestination.TopicCreatedByBot; - var messageThreadId = topicDestination.MessageThreadId; - - if (topicDestination.ShouldCreateForumTopic) - { - try - { - var topicRef = await messenger.CreateThreadAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - $"🎲 Игры: {parseResult.Title}", - ct); - messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture); - } - catch (Exception ex) - when (ex.Message.Contains("not enough rights") || - ex.Message.Contains("CHAT_ADMIN_REQUIRED") || - ex.Message.Contains("not an administrator")) - { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - TelegramTopicRouting.MissingForumTopicRightsMessage, - ct); - return; - } - } - - var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat"); - var platformUser = new PlatformUser( - PlatformKind.Telegram, - gmId.ToString(System.Globalization.CultureInfo.InvariantCulture), - gmName, - gmUsername); - - var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand( - platformUser, - platformGroup, - parseResult.Title!, - parseResult.Link!, - parseResult.ScheduledTimes, - parseResult.MaxPlayers, - imageReference); - - GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result; + var commands = BuildCommands(draft, payload); try { - result = await sharedHandler.HandleAsync(command, ct); - } - catch - { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - "💥 Произошла ошибка базы данных при создании сессии.", - ct); - throw; - } - - if (!result.Success) - { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, null), - result.ErrorMessage!, - ct); - return; - } - - var scheduleMessage = new PlatformScheduleMessage( - platformGroup, - result.View!, - null, - imageReference); - - var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct); - - // Store batch_message_id - if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId)) - { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await connection.ExecuteAsync( - "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", - new { MsgId = batchMessageId, BatchId = result.BatchId }); - } - - // Delete original message - try - { - await messenger.DeleteMessageAsync( - TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId), - ct); + foreach (var cmd in commands) + { + await _shared.HandleAsync(cmd, ct); + } + var totalSessions = commands.Sum(c => c.ScheduledTimes.Count); + await _messenger.EditMessageTextAsync( + draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}", + EmptyKeyboard(), ct); + await _drafts.DeleteAsync(draft.Id, ct); } catch (Exception ex) { - logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id); + _log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id); + payload.RetryCount += 1; + SavePayload(draft, payload); + if (payload.RetryCount >= MaxRetries) + { + await _messenger.EditMessageTextAsync( + draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + "💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.", + EmptyKeyboard(), ct); + await _drafts.DeleteAsync(draft.Id, ct); + return; + } + draft.UpdatedAt = DateTimeOffset.UtcNow; + await _drafts.UpsertAsync(draft, ct); + await _messenger.EditMessageTextAsync( + draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", + RetryCancelKeyboard(), ct); } } - internal static string? GetBatchImageReference(Message message, string? parsedImageUrl) + // ── Build shared commands ──────────────────────────────────────── + // The shared handler creates one session per scheduled time in a single transaction + // and assigns the same batch_id to all of them. A wizard pool therefore produces ONE + // command with N times; a single-game wizard produces ONE command with one time. + private static List BuildCommands(WizardDraft draft, WizardPayload p) { - var attachedPhotoFileId = message.Photo? - .OrderByDescending(photo => photo.FileSize ?? 0) - .ThenByDescending(photo => photo.Width * photo.Height) - .FirstOrDefault() - ?.FileId; - - if (!string.IsNullOrWhiteSpace(attachedPhotoFileId)) + if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0) { - return attachedPhotoFileId; + return new List + { + BuildCommand( + draft, + p, + pool.Slots.Select(s => s.ScheduledAt).ToList(), + MaxPlayersForPool(pool), + isOneShot: false) + }; } - - return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim(); + return new List + { + BuildCommand( + draft, + p, + new[] { p.Single?.ScheduledAt ?? default }, + p.Single?.MaxPlayers ?? 0, + isOneShot: true) + }; } + + private static int MaxPlayersForPool(WizardPoolInput pool) => + pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers); + + private static CreateSessionCommand BuildCommand( + WizardDraft draft, + WizardPayload p, + IReadOnlyList scheduledTimes, + int maxPlayers, + bool isOneShot) + { + var gmId = draft.OwnerTelegramId; + var user = new PlatformUser( + PlatformKind.Telegram, + gmId.ToString(System.Globalization.CultureInfo.InvariantCulture), + DisplayName: string.Empty, + ExternalUsername: null); + var group = new PlatformGroup( + PlatformKind.Telegram, + draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture), + DisplayName: string.Empty, + ExternalChannelId: null, + ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return new CreateSessionCommand( + User: user, + Group: group, + Title: p.Title ?? string.Empty, + Link: string.Empty, + ScheduledTimes: scheduledTimes, + MaxPlayers: maxPlayers, + ImageReference: p.ImageFileId ?? p.ImageUrl, + System: ParseSystem(p.System), + Description: p.Description, + Format: null, + DurationMinutes: p.DurationMinutes, + IsOneShot: isOneShot); + } + + private static GameSystem? ParseSystem(string? code) + { + if (string.IsNullOrWhiteSpace(code)) return null; + return Enum.TryParse(code, ignoreCase: true, out var sys) ? sys : null; + } + + // ── Validation ─────────────────────────────────────────────────── + private static bool IsComplete(WizardPayload p, out string missing) + { + var missingFields = new List(); + if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название"); + if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система"); + if (!p.DurationMinutes.HasValue) 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("лимит мест"); + } + else + { + if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты"); + } + missing = string.Join(", ", missingFields); + return missingFields.Count == 0; + } + + // ── Payload I/O ────────────────────────────────────────────────── + private static WizardPayload LoadPayload(WizardDraft draft) + { + if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload(); + return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload(); + } + + private static void SavePayload(WizardDraft draft, WizardPayload p) + { + draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload); + } + + // ── Keyboards ──────────────────────────────────────────────────── + private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty()); + private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[] + { + new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) }, + new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, + }); } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs deleted file mode 100644 index 32a7f94..0000000 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs +++ /dev/null @@ -1,184 +0,0 @@ -using GmRelay.Shared.Domain; - -namespace GmRelay.Bot.Features.Sessions.CreateSession; - -internal sealed record NewSessionParseResult( - string? Title, - string? Link, - string? ImageUrl, - int? MaxPlayers, - IReadOnlyList ScheduledTimes, - IReadOnlyList PastTimeInputs, - IReadOnlyList InvalidTimeInputs, - IReadOnlyList InvalidSeatLimitInputs, - IReadOnlyList InvalidRecurringInputs) -{ - public bool IsValid => - !string.IsNullOrWhiteSpace(Title) && - !string.IsNullOrWhiteSpace(Link) && - ScheduledTimes.Count > 0 && - InvalidSeatLimitInputs.Count == 0 && - InvalidRecurringInputs.Count == 0; -} - -internal static class NewSessionCommandParser -{ - private const int MaxRecurringSessionCount = 52; - private const int MaxRecurringIntervalDays = 365; - private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:"; - private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; - private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; - private static readonly string[] ImagePrefixes = - [ - "\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:", - "\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:", - "\u041e\u0431\u043b\u043e\u0436\u043a\u0430:" - ]; - private static readonly string[] SeatLimitPrefixes = - [ - "\u041c\u0435\u0441\u0442:", - "\u041b\u0438\u043c\u0438\u0442:", - "\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:" - ]; - private static readonly string[] RecurringCountPrefixes = - [ - "\u0418\u0433\u0440:", - "\u0421\u0435\u0441\u0441\u0438\u0439:", - "\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:" - ]; - private static readonly string[] RecurringIntervalPrefixes = - [ - "\u0428\u0430\u0433:", - "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:" - ]; - - public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc) - { - string? title = null; - string? link = null; - string? imageUrl = null; - int? maxPlayers = null; - int? recurringCount = null; - var recurringIntervalDays = 7; - var scheduledTimes = new List(); - var pastTimeInputs = new List(); - var invalidTimeInputs = new List(); - var invalidSeatLimitInputs = new List(); - var invalidRecurringInputs = new List(); - - foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries)) - { - if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase)) - { - title = line[TitlePrefix.Length..].Trim(); - continue; - } - - if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase)) - { - link = line[LinkPrefix.Length..].Trim(); - continue; - } - - var imagePrefix = ImagePrefixes.FirstOrDefault(prefix => - line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - if (imagePrefix is not null) - { - imageUrl = line[imagePrefix.Length..].Trim(); - continue; - } - - var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix => - line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - if (seatLimitPrefix is not null) - { - var seatLimitInput = line[seatLimitPrefix.Length..].Trim(); - if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0) - { - maxPlayers = parsedMaxPlayers; - } - else - { - invalidSeatLimitInputs.Add(seatLimitInput); - } - - continue; - } - - var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix => - line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - if (recurringCountPrefix is not null) - { - var recurringInput = line[recurringCountPrefix.Length..].Trim(); - if (int.TryParse(recurringInput, out var parsedCount) && - parsedCount is >= 1 and <= MaxRecurringSessionCount) - { - recurringCount = parsedCount; - } - else - { - invalidRecurringInputs.Add(recurringInput); - } - - continue; - } - - var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix => - line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - if (recurringIntervalPrefix is not null) - { - var recurringInput = line[recurringIntervalPrefix.Length..].Trim(); - if (int.TryParse(recurringInput, out var parsedInterval) && - parsedInterval is >= 1 and <= MaxRecurringIntervalDays) - { - recurringIntervalDays = parsedInterval; - } - else - { - invalidRecurringInputs.Add(recurringInput); - } - - continue; - } - - if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var timeInput = line[TimePrefix.Length..].Trim(); - if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt)) - { - invalidTimeInputs.Add(timeInput); - continue; - } - - if (scheduledAt <= nowUtc) - { - pastTimeInputs.Add(timeInput); - continue; - } - - scheduledTimes.Add(scheduledAt); - } - - if (recurringCount.HasValue && scheduledTimes.Count == 1) - { - var firstScheduledTime = scheduledTimes[0]; - scheduledTimes = Enumerable.Range(0, recurringCount.Value) - .Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index)) - .ToList(); - } - - return new NewSessionParseResult( - title, - link, - imageUrl, - maxPlayers, - scheduledTimes, - pastTimeInputs, - invalidTimeInputs, - invalidSeatLimitInputs, - invalidRecurringInputs); - } -} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index d395fc0..1eafc8c 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -213,7 +213,9 @@ public sealed class UpdateRouter( break; case "/newsession": - await createSessionHandler.HandleAsync(message, ct); + // TODO (Task 13): if a non-expired draft already exists, render a + // "Continue / Start over / Cancel" menu instead of starting a new one. + await createSessionHandler.StartWizardAsync(message, ct); break; case "/listsessions": diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs index 6ecc43e..cb7dcf1 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardPayload.cs @@ -36,6 +36,10 @@ public sealed class WizardPayload public bool? Waitlist { get; set; } public WizardSingleInput? Single { get; set; } public WizardPoolInput? Pool { get; set; } + + // Wizard-flow metadata (not a wizard step input). + [JsonIgnore] + public int RetryCount { get; set; } } public sealed class WizardPoolInput diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs index b245c58..80a06cb 100644 --- a/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs @@ -1,4 +1,3 @@ -using GmRelay.Bot.Features.Sessions.CreateSession; using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; @@ -11,13 +10,17 @@ namespace GmRelay.Bot.Tests.Features.Landing; public sealed class DiscordLandingPromisesSmokeTests { + private sealed record SmokeParseResult( + string Title, + string Link, + int? MaxPlayers, + IReadOnlyList ScheduledTimes); + [Fact] public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi() { - var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero); - var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc); + var parseResult = BuildRecurringSessionParseResult(); - Assert.True(parseResult.IsValid); Assert.Equal(3, parseResult.ScheduledTimes.Count); Assert.Equal(2, parseResult.MaxPlayers); Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]); @@ -126,16 +129,17 @@ public sealed class DiscordLandingPromisesSmokeTests Assert.Contains("Carol", firstSessionEmbed.Description); } - private static string BuildRecurringSessionCommand() => - string.Join( - '\n', - "/newsession", - "Название: Landing Promise Smoke", - "Время: 15.05.2026 19:30", - "Игр: 3", - "Интервал: 7", - "Мест: 2", - "Ссылка: https://example.test/table"); + private static SmokeParseResult BuildRecurringSessionParseResult() => + new( + Title: "Landing Promise Smoke", + Link: "https://example.test/table", + MaxPlayers: 2, + ScheduledTimes: new[] + { + new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero), + }); private static IReadOnlyList CallbackData(IReadOnlyList actionRows) => actionRows @@ -183,14 +187,14 @@ public sealed class DiscordLandingPromisesSmokeTests public FakeDiscordMessage LastMessage => Messenger.LastMessage; public static DiscordLandingSmokeScenario Publish( - NewSessionParseResult parseResult, + SmokeParseResult parseResult, SessionNotificationMode notificationMode) { var scenario = new DiscordLandingSmokeScenario( - parseResult.Title!, + parseResult.Title, parseResult.ScheduledTimes, parseResult.MaxPlayers, - parseResult.Link!, + parseResult.Link, notificationMode); scenario.RenderBatch(); diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs index 76b4df8..070b4c2 100644 --- a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs @@ -1,4 +1,3 @@ -using GmRelay.Bot.Features.Sessions.CreateSession; using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; @@ -10,13 +9,17 @@ namespace GmRelay.Bot.Tests.Features.Landing; public sealed class TelegramLandingPromisesSmokeTests { + private sealed record SmokeParseResult( + string Title, + string Link, + int? MaxPlayers, + IReadOnlyList ScheduledTimes); + [Fact] public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi() { - var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero); - var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc); + var parseResult = BuildRecurringSessionParseResult(); - Assert.True(parseResult.IsValid); Assert.Equal(3, parseResult.ScheduledTimes.Count); Assert.Equal(2, parseResult.MaxPlayers); Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]); @@ -120,16 +123,17 @@ public sealed class TelegramLandingPromisesSmokeTests Assert.Contains("@carol", scenario.LastMessage.Text); } - private static string BuildRecurringSessionCommand() => - string.Join( - '\n', - "/newsession", - "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke", - "\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30", - "\u0418\u0433\u0440: 3", - "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7", - "\u041c\u0435\u0441\u0442: 2", - "\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table"); + private static SmokeParseResult BuildRecurringSessionParseResult() => + new( + Title: "Landing Promise Smoke", + Link: "https://example.test/table", + MaxPlayers: 2, + ScheduledTimes: new[] + { + new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero), + }); private static IReadOnlyList CallbackData(InlineKeyboardMarkup markup) => markup.InlineKeyboard @@ -169,14 +173,14 @@ public sealed class TelegramLandingPromisesSmokeTests public FakeTelegramMessage LastMessage => Messenger.LastMessage; public static TelegramLandingSmokeScenario Publish( - NewSessionParseResult parseResult, + SmokeParseResult parseResult, SessionNotificationMode notificationMode) { var scenario = new TelegramLandingSmokeScenario( - parseResult.Title!, + parseResult.Title, parseResult.ScheduledTimes, parseResult.MaxPlayers, - parseResult.Link!, + parseResult.Link, notificationMode); scenario.RenderBatch(); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs deleted file mode 100644 index c4cc9f8..0000000 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using GmRelay.Bot.Features.Sessions.CreateSession; -using Telegram.Bot.Types; - -namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; - -public sealed class NewSessionCommandParserTests -{ - [Fact] - public void Parse_ShouldExtractTitleLinkAndUpcomingTimes() - { - var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); - var text = """ - /newsession - Название: Curse of Strahd - Время: 24.04.2026 19:30 - Время: 01.05.2026 20:00 - Мест: 4 - Ссылка: https://example.test/room - """; - - var result = NewSessionCommandParser.Parse(text, nowUtc); - - Assert.True(result.IsValid); - Assert.Equal("Curse of Strahd", result.Title); - Assert.Equal("https://example.test/room", result.Link); - Assert.Equal(4, result.MaxPlayers); - Assert.Equal( - [ - new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero), - new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero) - ], - result.ScheduledTimes); - Assert.Empty(result.PastTimeInputs); - Assert.Empty(result.InvalidTimeInputs); - } - - [Fact] - public void Parse_ShouldExtractOptionalImageUrl() - { - var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); - var text = """ - /newsession - Название: Curse of Strahd - Время: 24.04.2026 19:30 - Ссылка: https://example.test/room - Картинка: https://example.test/strahd.jpg - """; - - var result = NewSessionCommandParser.Parse(text, nowUtc); - - Assert.True(result.IsValid); - Assert.Equal("https://example.test/strahd.jpg", result.ImageUrl); - } - - [Fact] - public void GetBatchImageReference_ShouldPreferAttachedPhotoOverParsedUrl() - { - var message = new Message - { - Photo = - [ - new PhotoSize { FileId = "small-photo", Width = 320, Height = 180, FileSize = 10 }, - new PhotoSize { FileId = "large-photo", Width = 1280, Height = 720, FileSize = 20 } - ] - }; - - var imageReference = CreateSessionHandler.GetBatchImageReference( - message, - "https://example.test/cover.jpg"); - - Assert.Equal("large-photo", imageReference); - } - - [Fact] - public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided() - { - var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); - var text = """ - /newsession - Название: Kingmaker - Время: 30.04.2026 19:30 - Игр: 4 - Интервал: 14 - Ссылка: https://example.test/kingmaker - """; - - var result = NewSessionCommandParser.Parse(text, nowUtc); - - Assert.True(result.IsValid); - Assert.Equal( - [ - new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero), - new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero), - new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero), - new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero) - ], - result.ScheduledTimes); - } - - [Fact] - public void Parse_ShouldCollectPastAndInvalidTimes() - { - var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); - var text = """ - Название: Delta Green - Время: 20.04.2026 19:30 - Время: 31.04.2026 19:30 - Время: 25.04.2026 18:00 - Ссылка: https://example.test/dg - """; - - var result = NewSessionCommandParser.Parse(text, nowUtc); - - Assert.True(result.IsValid); - Assert.Single(result.ScheduledTimes); - Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs); - Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs); - } - - [Fact] - public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing() - { - var text = """ - /newsession - Название: Blades in the Dark - Время: 25.04.2026 19:30 - """; - - var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow); - - Assert.False(result.IsValid); - Assert.Null(result.Link); - } - - [Fact] - public void Parse_ShouldCollectInvalidSeatLimit() - { - var text = """ - /newsession - Название: Blades in the Dark - Время: 25.04.2026 19:30 - Мест: 0 - Ссылка: https://example.test/blades - """; - - var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow); - - Assert.False(result.IsValid); - Assert.Null(result.MaxPlayers); - Assert.Equal(["0"], result.InvalidSeatLimitInputs); - } -} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs index 40238d4..92ce969 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -8,12 +8,18 @@ public sealed class TelegramTopicIntegrationSmokeTests var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql"); var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs"); + var topicRouting = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs"); Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal); - Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal); - Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal); - Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal); - Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal); + + // The wizard-driven CreateSessionHandler threads the existing forum topic + // (if any) into the draft; the shared creation command inherits it. Topic + // auto-creation and rights handling live in TelegramTopicRouting. + Assert.Contains("MessageThreadId", createHandler, StringComparison.Ordinal); + Assert.Contains("ExternalThreadId", createHandler, StringComparison.Ordinal); + Assert.Contains("ResolveNewScheduleDestination", topicRouting, StringComparison.Ordinal); + Assert.Contains("MissingForumTopicRightsMessage", topicRouting, StringComparison.Ordinal); + Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal); Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal); Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);