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());
+}