From ea567a36eea1b06cd2fd97e4ba3702e01f1c50a3 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 4 Jun 2026 08:42:43 +0300 Subject: [PATCH] feat(wizard): add GameCreationWizard state-machine service --- .../Wizard/GameCreationWizard.cs | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs new file mode 100644 index 0000000..573ac80 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using Microsoft.Extensions.Logging; +using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; + +namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; + +/// +/// Central state machine for the game/pool creation wizard. +/// +public sealed class GameCreationWizard +{ + private readonly WizardDraftRepository _drafts; + private readonly ITelegramWizardMessenger _messenger; + private readonly ILogger _log; + + public GameCreationWizard( + WizardDraftRepository drafts, + ITelegramWizardMessenger messenger, + ILogger log) + { + _drafts = drafts; + _messenger = messenger; + _log = log; + } + + /// Handle a text or callback update from the owning GM. + public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct) + { + try + { + if (update.CallbackQuery is { } cb) + { + await HandleCallbackAsync(draft, cb, ct); + } + else if (update.Message is { } msg) + { + await HandleTextAsync(draft, msg, ct); + } + } + catch (WizardStorageException) + { + // Surface storage failure; do not crash the update loop. + if (update.CallbackQuery is { } cb2) + { + await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct); + } + } + catch (Exception ex) + { + _log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id); + if (update.CallbackQuery is { } cb3) + { + try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); } + catch { /* swallow — we're already in error path */ } + } + } + } + + private async Task HandleCallbackAsync(WizardDraft draft, CallbackQuery cb, CancellationToken ct) + { + if (!WizardCallbackData.TryParse(cb.Data, out var action, out var step, out var choice)) + { + await _messenger.AnswerCallbackAsync(cb.Id, "Неизвестная команда", ct); + return; + } + + switch (action) + { + case "cancel": + await _drafts.DeleteAsync(draft.Id, ct); + await _messenger.EditMessageTextAsync( + draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + "❌ Мастер отменён.", EmptyKeyboard, ct); + await _messenger.AnswerCallbackAsync(cb.Id, null, ct); + return; + + case "back": + ApplyBack(draft, step); + await PersistAndRenderAsync(draft, cb.Id, ct); + return; + + case "create": + // Routed by CreateSessionHandler, not here. + await _messenger.AnswerCallbackAsync(cb.Id, null, ct); + return; + + default: + // For "Choice" callbacks, action == step. + await ApplyChoiceAsync(draft, step, choice, cb.Id, ct); + return; + } + } + + private async Task HandleTextAsync(WizardDraft draft, Message msg, CancellationToken ct) + { + if (msg.Text is not { } text) + { + // Photo or other non-text — handle cover step only. + if (msg.Photo is { Length: > 0 } && draft.Step == WizardStepNames.Cover) + { + var fileId = msg.Photo[^1].FileId; + ApplyCoverPhoto(draft, fileId); + await PersistAndRenderAsync(draft, null, ct); + } + return; + } + + var (nextStep, error) = ApplyText(draft, text); + if (error is { } errMsg && draft.DraftMessageId is { } mid) + { + // Re-render the same step with ⚠️ prefix. + var payload = LoadPayload(draft); + var (rendered, kb) = WizardStep.Render(draft, payload, null); + await _messenger.EditMessageTextAsync( + draft.ChatId, draft.MessageThreadId, mid, + "⚠️ " + errMsg + "\n\n" + rendered, kb, ct); + return; + } + + if (nextStep is { } step) + { + draft.Step = step; + } + await PersistAndRenderAsync(draft, null, ct); + } + + private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct) + { + var (nextStep, error) = ApplyChoice(draft, step, choice); + if (error is { } err) + { + await _messenger.AnswerCallbackAsync(callbackId, err, ct); + return; + } + if (nextStep is { } s) + { + draft.Step = s; + } + await PersistAndRenderAsync(draft, callbackId, ct); + } + + private async Task PersistAndRenderAsync(WizardDraft draft, string? callbackId, CancellationToken ct) + { + draft.UpdatedAt = DateTimeOffset.UtcNow; + await _drafts.UpsertAsync(draft, ct); + var payload = LoadPayload(draft); + IReadOnlyList? clubs = null; + if (draft.Step == WizardStepNames.PickClub) + { + clubs = await _messenger.GetGmClubsAsync(draft.OwnerTelegramId, ct); + } + var (text, kb) = WizardStep.Render(draft, payload, clubs); + await _messenger.EditMessageTextAsync( + draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + text, kb, ct); + if (callbackId is { } id) + { + await _messenger.AnswerCallbackAsync(id, null, ct); + } + } + + // ── Text input dispatcher ───────────────────────────────────────── + private static (string? nextStep, string? error) ApplyText(WizardDraft draft, string input) + { + var payload = LoadPayload(draft); + switch (draft.Step) + { + case WizardStepNames.Title: + return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title) + ? (WizardStepNames.Description, SetTitle(payload, title)) + : (null, title); + + case WizardStepNames.Description: + if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null)); + return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc) + ? (WizardStepNames.Cover, SetDescription(payload, desc)) + : (null, desc); + + case WizardStepNames.Cover: + if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null)); + if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + return (NextAfterCover(payload), SetImageUrl(payload, input)); + return (null, "Некорректный URL"); + + case WizardStepNames.System when payload.System is null: + // "Other" branch — only active if free-text was offered. + return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys) + ? (WizardStepNames.Duration, SetSystem(payload, sys)) + : (null, sys); + + case WizardStepNames.Duration when payload.DurationMinutes is null: + return TryParseHours(input, out var durMin) + ? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin)) + : (null, "Неверная длительность (1..12 ч)"); + + case WizardStepNames.DateTime: + return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow + ? (WizardStepNames.Capacity, SetScheduledAt(payload, dt)) + : (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом"); + + case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: + return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity + ? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap)) + : (null, "Лимит должен быть 1..50"); + + case WizardStepNames.PoolSystemDuration when payload.System is null: + return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) + ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys)) + : (null, psys); + + case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null: + return TryParseHours(input, out var pdur) + ? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur)) + : (null, "Неверная длительность (1..12 ч)"); + + case WizardStepNames.PoolSlotDateTime: + return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow + ? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt)) + : (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом"); + + case WizardStepNames.PoolSlotCapacity: + return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity + ? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap)) + : (null, "Лимит должен быть 1..50"); + + default: + return (null, "Ожидается выбор кнопкой"); + } + } + + // ── Callback (button) dispatcher ────────────────────────────────── + private static (string? nextStep, string? error) ApplyChoice(WizardDraft draft, string step, string choice) + { + var payload = LoadPayload(draft); + return step switch + { + WizardStepNames.Type => ApplyTypeChoice(payload, choice), + WizardStepNames.System => ApplySystemChoice(payload, choice), + WizardStepNames.Duration => ApplyDurationChoice(payload, choice), + WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice), + WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice), + WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice), + WizardStepNames.Publish => ApplyPublishChoice(payload, choice), + WizardStepNames.PoolSystemDuration => ApplyPoolSystemDurationChoice(payload, choice), + WizardStepNames.PoolAddSlots => ApplyPoolAddSlotsChoice(payload, choice), + WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice), + _ => (null, "Неизвестный шаг"), + }; + } + + private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch + { + "single" => (WizardStepNames.Title, SetType(p, WizardCreationType.Single)), + "pool" => (WizardStepNames.Title, SetType(p, WizardCreationType.Pool)), + _ => (null, "Неизвестный выбор"), + }; + + private static (string?, string?) ApplySystemChoice(WizardPayload p, string choice) => choice switch + { + "_other" => (WizardStepNames.System, null), // stay, await text + "_skip" => (NextAfterSystem(p), SetSystem(p, null)), + { } code => (WizardStepNames.Duration, SetSystem(p, code)), + }; + + private static (string?, string?) ApplyDurationChoice(WizardPayload p, string choice) => choice switch + { + "_other" => (WizardStepNames.Duration, null), + "_skip" => (NextAfterDuration(p), SetDurationMinutes(p, null)), + { } d => int.TryParse(d, out var min) + ? (NextAfterDuration(p), SetDurationMinutes(p, min)) + : (null, "Неверная длительность"), + }; + + private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch + { + "waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)), + "waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)), + _ => (null, "Неизвестный выбор"), + }; + + private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch + { + "public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)), + "club" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Club)), + "members" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Members)), + "pickclub" => (WizardStepNames.PickClub, null), + _ => (null, "Неизвестный выбор"), + }; + + private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice) + => Guid.TryParse(choice, out var id) + ? (NextAfterVisibility(p), SetClubId(p, id)) + : (null, "Неверный идентификатор клуба"); + + private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch + { + "yes" => (WizardStepNames.Confirm, SetPublishInShowcase(p, true)), + "no" => (WizardStepNames.Confirm, SetPublishInShowcase(p, false)), + _ => (null, "Неизвестный выбор"), + }; + + private static (string?, string?) ApplyPoolSystemDurationChoice(WizardPayload p, string choice) => choice switch + { + "_custom" => (WizardStepNames.PoolSystemDuration, null), + { } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur) + ? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur)) + : (null, "Неверный выбор"), + _ => (null, "Неизвестный выбор"), + }; + + private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch + { + "add" => BeginNewPoolSlot(p), + "done" => p.Pool?.Slots.Count > 0 + ? (WizardStepNames.PoolConfirm, null) + : (null, "Добавьте хотя бы один слот"), + _ => (null, "Неизвестный выбор"), + }; + + private static (string?, string?) ApplyPoolSlotCapacityChoice(WizardPayload p, string choice) => choice switch + { + "waitlist:on" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, true)), + "waitlist:off" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, false)), + _ => (null, "Неизвестный выбор"), + }; + + // ── Back navigation ─────────────────────────────────────────────── + private static void ApplyBack(WizardDraft draft, string fromStep) + { + // The callback's "step" portion is the step the user is currently on (e.g. the + // Confirm button emits `wizard:back` with no step, in which case we fall back to + // the draft's current step). Both should produce the same result. + var current = string.IsNullOrEmpty(fromStep) ? draft.Step : fromStep; + var payload = LoadPayload(draft); + var previous = PreviousStep(current, payload); + if (previous is { } step) draft.Step = step; + } + + private static string? PreviousStep(string step, WizardPayload p) => step switch + { + WizardStepNames.Title => null, // first step + WizardStepNames.Description => WizardStepNames.Title, + WizardStepNames.Cover => WizardStepNames.Description, + WizardStepNames.System => WizardStepNames.Cover, + WizardStepNames.Duration => WizardStepNames.System, + WizardStepNames.DateTime => WizardStepNames.Duration, + WizardStepNames.Capacity => WizardStepNames.DateTime, + WizardStepNames.Visibility => WizardStepNames.Capacity, + WizardStepNames.PickClub => WizardStepNames.Visibility, + WizardStepNames.Publish => WizardStepNames.PickClub, + WizardStepNames.Confirm => WizardStepNames.Publish, + + WizardStepNames.PoolSystemDuration => null, // first pool step + WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration, + WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots, + WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime, + WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots, + _ => null, + }; + + // ── Payload I/O ─────────────────────────────────────────────────── + internal static WizardPayload LoadPayload(WizardDraft draft) + { + if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload(); + return System.Text.Json.JsonSerializer.Deserialize( + draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload(); + } + + private static void SavePayload(WizardDraft draft, WizardPayload payload) + { + draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize( + payload, WizardPayloadJsonContext.Default.WizardPayload); + } + + // Mutators — return the error message if any (kept here to centralise flow). + private static string? SetTitle(WizardPayload p, string v) { p.Title = v; return null; } + private static string? SetDescription(WizardPayload p, string? v) { p.Description = v; return null; } + private static string? SetImageUrl(WizardPayload p, string? v) { p.ImageUrl = v; p.ImageFileId = null; return null; } + private static void ApplyCoverPhoto(WizardDraft d, string fileId) + { + var p = LoadPayload(d); + p.ImageFileId = fileId; + p.ImageUrl = null; + SavePayload(d, p); + var next = NextAfterCover(p); + if (next is { } s) d.Step = s; + } + private static string? SetSystem(WizardPayload p, string? v) { p.System = v; return null; } + private static string? SetDurationMinutes(WizardPayload p, int? v){ p.DurationMinutes = v; return null; } + private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v) + { 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? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; } + private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; } + private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; } + private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; } + private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; } + + private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v) + { + p.Pool ??= new WizardPoolInput(); + var current = EnsureCurrentPoolSlot(p); + current.ScheduledAt = v; + return null; + } + private static string? SetCurrentSlotMaxPlayers(WizardPayload p, int v) + { + p.Pool ??= new WizardPoolInput(); + var current = EnsureCurrentPoolSlot(p); + current.MaxPlayers = v; + return null; + } + private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist) + { + p.Pool ??= new WizardPoolInput(); + var current = p.Pool.Slots.LastOrDefault(); + if (current is null) return "Слот не начат"; + current.Waitlist = waitlist; + return null; + } + private static (string? nextStep, string? error) BeginNewPoolSlot(WizardPayload p) + { + p.Pool ??= new WizardPoolInput(); + p.Pool.Slots.Add(new WizardSlotInput()); + return (WizardStepNames.PoolSlotDateTime, null); + } + private static WizardSlotInput EnsureCurrentPoolSlot(WizardPayload p) + { + // Slots added via BeginNewPoolSlot are always committed before they leave the + // PoolSlotCapacity step (CommitCurrentPoolSlot). If we somehow get here without + // a slot, start a new one to keep the flow recoverable. + p.Pool ??= new WizardPoolInput(); + var last = p.Pool.Slots.LastOrDefault(); + if (last is not null && last.MaxPlayers == 0) return last; + p.Pool.Slots.Add(new WizardSlotInput()); + return p.Pool.Slots[^1]; + } + + // ── Flow helpers ────────────────────────────────────────────────── + private static string? NextAfterCover(WizardPayload p) => p.Type == WizardCreationType.Pool + ? WizardStepNames.PoolSystemDuration : WizardStepNames.System; + private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration; + private static string? NextAfterDuration(WizardPayload p) + { + if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility; + return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime; + } + private static string? NextAfterVisibility(WizardPayload p) + { + if (p.Visibility is WizardVisibility.Club or WizardVisibility.Members) + { + if (p.ClubId is null) return WizardStepNames.PickClub; + } + return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish; + } + + private static (string? sys, int? dur) SplitSystemDuration(string s) + { + var idx = s.IndexOf(':'); + if (idx <= 0 || idx >= s.Length - 1) return (null, null); + var sys = s.Substring(0, idx); + if (!int.TryParse(s.Substring(idx + 1), out var durMin)) return (null, null); + return (sys, durMin); + } + + private static bool ValidateText( + string input, int maxLength, string emptyMsg, string tooLongMsg, out string trimmed) + { + trimmed = input.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + trimmed = emptyMsg; + return false; + } + if (trimmed.Length > maxLength) + { + trimmed = tooLongMsg; + return false; + } + return true; + } + + private static bool TryParseHours(string input, out int minutes) + { + minutes = 0; + var s = input.Trim(); + if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); + if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); + if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false; + if (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false; + minutes = (int)Math.Round(hours * 60); + return true; + } + + private static readonly InlineKeyboardMarkup EmptyKeyboard = new(Array.Empty()); +}