diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs
new file mode 100644
index 0000000..c5b603c
--- /dev/null
+++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Dapper;
+using Npgsql;
+
+namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
+
+///
+/// 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.
+///
+internal static class DiscordPermissionLookup
+{
+ public static async Task> LoadManagerUserIdsAsync(
+ NpgsqlDataSource dataSource,
+ ulong guildId,
+ CancellationToken cancellationToken)
+ {
+ const string sql = """
+ 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
+ """;
+
+ await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
+ var rows = await connection.QueryAsync(
+ new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
+ return rows.ToList();
+ }
+}
diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs
new file mode 100644
index 0000000..60c2654
--- /dev/null
+++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using GmRelay.DiscordBot.Infrastructure.Discord;
+using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
+using NetCord;
+using NetCord.Rest;
+using NetCord.Services.ApplicationCommands;
+using Npgsql;
+
+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
+/// 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
+/// applied before any draft is created.
+///
+public sealed class DiscordWizardCommand : ApplicationCommandModule
+{
+ private readonly DiscordWizardMessenger _messenger;
+ private readonly DiscordPermissionChecker _permissions;
+ private readonly IWizardDraftRepository _drafts;
+ private readonly IWizardContextStore _contextStore;
+ private readonly NpgsqlDataSource _dataSource;
+ private readonly ILogger _log;
+
+ public DiscordWizardCommand(
+ DiscordWizardMessenger messenger,
+ DiscordPermissionChecker permissions,
+ IWizardDraftRepository drafts,
+ IWizardContextStore contextStore,
+ NpgsqlDataSource dataSource,
+ ILogger log)
+ {
+ _messenger = messenger;
+ _permissions = permissions;
+ _drafts = drafts;
+ _contextStore = contextStore;
+ _dataSource = dataSource;
+ _log = log;
+ }
+
+ [SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
+ public async Task ExecuteAsync(
+ [SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
+ {
+ var guildId = Context.Interaction.GuildId
+ ?? throw new InvalidOperationException("This command can only be used in a guild.");
+
+ var channel = Context.Channel
+ ?? throw new InvalidOperationException("Channel data not available in interaction.");
+ var channelId = channel.Id.ToString(CultureInfo.InvariantCulture);
+
+ var member = Context.User as GuildInteractionUser
+ ?? throw new InvalidOperationException("Guild member data not available in interaction.");
+ var resolvedPermissions = (ulong)member.Permissions;
+ var userId = Context.User.Id.ToString(CultureInfo.InvariantCulture);
+
+ // Slash commands don't expose a CancellationToken on the context;
+ // the REST call already has its own per-request cancellation. We
+ // pass CancellationToken.None for the DB calls — they're cheap and
+ // the host's shutdown will tear them down.
+ var ct = CancellationToken.None;
+ var ownerId = userId;
+ ulong guildOwnerId = 0;
+ try
+ {
+ var guild = await Context.Client.Rest.GetGuildAsync(guildId);
+ guildOwnerId = guild.OwnerId;
+ }
+ catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ _log.LogWarning(
+ ex,
+ "Bot is not a REST member of guild {GuildId}; falling back to permissions from interaction payload.",
+ guildId);
+ }
+
+ // Permission check: server owner, guild admin, or DB manager
+ // for the Discord game_group.
+ var dbManagerIds = await DiscordPermissionLookup.LoadManagerUserIdsAsync(
+ _dataSource, guildId, ct);
+ if (!_permissions.CanManageSchedule(guildOwnerId, Context.User.Id, dbManagerIds, resolvedPermissions))
+ {
+ await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
+ new InteractionMessageProperties()
+ .WithContent("⛔ Только owner, администратор или manager могут создавать сессии.")
+ .WithFlags(MessageFlags.Ephemeral)));
+ return;
+ }
+
+ // If there's already a draft, offer Continue / Start over.
+ var existing = await _drafts.GetActiveAsync("Discord", ownerId, ct);
+ if (existing is not null && existing.Id != Guid.Empty)
+ {
+ await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
+ new InteractionMessageProperties()
+ .WithContent("📝 У вас уже есть активный мастер. Продолжить?")
+ .WithFlags(MessageFlags.Ephemeral)
+ .WithComponents(BuildResumeRow(existing.Id))));
+ return;
+ }
+
+ var draft = new WizardDraft
+ {
+ Id = Guid.NewGuid(),
+ ChatId = guildId.ToString(CultureInfo.InvariantCulture),
+ MessageThreadId = null,
+ OwnerId = ownerId,
+ Platform = "Discord",
+ Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
+ ? WizardStepNames.Title
+ : WizardStepNames.Type,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow,
+ ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
+ };
+ // If the user passed `mode=pool` we pre-seed the payload so the
+ // wizard's own branching lands on the pool flow.
+ if (NormalizeMode(mode) is { } mt)
+ {
+ draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
+ new WizardPayload { Type = mt },
+ WizardPayloadJsonContext.Default.WizardPayload);
+ }
+
+ // Stash the context BEFORE sending so the messenger can both
+ // send the message and persist the returned message id back.
+ _contextStore.Set(draft.Id, new DiscordWizardContext(
+ GuildId: guildId.ToString(CultureInfo.InvariantCulture),
+ ChannelId: channelId,
+ MessageId: string.Empty,
+ ThreadId: null));
+
+ // Render + send the first step. Defer the response so we can
+ // show the wizard message in the channel.
+ await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
+ MessageFlags.Ephemeral));
+
+ try
+ {
+ var payload = LoadPayload(draft);
+ var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
+ var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
+ draft.DraftMessageId = msgId;
+ draft.UpdatedAt = DateTimeOffset.UtcNow;
+ await _drafts.UpsertAsync(draft, ct);
+
+ await Context.Interaction.ModifyResponseAsync(msg =>
+ {
+ msg.Content = "🎲 Мастер запущен. См. сообщение ниже.";
+ msg.Flags = MessageFlags.Ephemeral;
+ });
+ }
+ catch (Exception ex)
+ {
+ _log.LogError(ex, "Failed to start wizard for user {OwnerId} in guild {GuildId}", ownerId, guildId);
+ _contextStore.Remove(draft.Id);
+ try
+ {
+ await _drafts.DeleteAsync(draft.Id, ct);
+ }
+ catch
+ {
+ /* best effort */
+ }
+ await Context.Interaction.ModifyResponseAsync(msg =>
+ {
+ msg.Content = "💥 Не удалось запустить мастер. Попробуйте позже.";
+ msg.Flags = MessageFlags.Ephemeral;
+ });
+ }
+ }
+
+ private static WizardCreationType? NormalizeMode(string? mode) =>
+ mode?.ToLowerInvariant() switch
+ {
+ "single" or "одну" or "one" => WizardCreationType.Single,
+ "pool" or "пул" => WizardCreationType.Pool,
+ _ => null,
+ };
+
+ private static WizardPayload LoadPayload(WizardDraft draft) =>
+ string.IsNullOrEmpty(draft.PayloadJson)
+ ? new WizardPayload()
+ : System.Text.Json.JsonSerializer.Deserialize(
+ draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
+
+ private static IReadOnlyList BuildResumeRow(Guid draftId)
+ {
+ var row = new ActionRowProperties();
+ row.Add(new ButtonProperties(
+ DiscordWizardStep.ButtonCustomId("resume", "continue"),
+ "▶️ Продолжить",
+ ButtonStyle.Primary));
+ row.Add(new ButtonProperties(
+ DiscordWizardStep.ButtonCustomId("resume", "restart"),
+ "🔄 Заново",
+ ButtonStyle.Secondary));
+ row.Add(new ButtonProperties(
+ DiscordWizardStep.ButtonCustomId("cancel", "1"),
+ "❌ Отмена",
+ ButtonStyle.Danger));
+ return new IMessageComponentProperties[] { row };
+ }
+}
diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardContextStore.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardContextStore.cs
new file mode 100644
index 0000000..5cbd82d
--- /dev/null
+++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardContextStore.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Concurrent;
+
+namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
+
+///
+/// Snapshot of where the wizard's draft message lives. The messenger
+/// needs this to re-send / re-edit the message after a 15-minute
+/// interaction token has expired.
+///
+/// Discord guild (server) id as a decimal string.
+/// Channel id where the draft message was posted.
+/// Id of the currently active draft message.
+/// Optional thread id; null for top-level channel posts.
+public sealed record DiscordWizardContext(
+ string GuildId,
+ string ChannelId,
+ string MessageId,
+ string? ThreadId);
+
+///
+/// In-memory store of draft → context lookups. Lives for the lifetime of
+/// the process; the wizard's 24-hour expiry is enforced by
+/// WizardDraftCleanupService, so this cache is allowed to hold
+/// entries until the draft is finalized or explicitly removed.
+///
+public interface IWizardContextStore
+{
+ void Set(Guid draftId, DiscordWizardContext context);
+
+ bool TryGet(Guid draftId, out DiscordWizardContext context);
+
+ void Remove(Guid draftId);
+}
+
+///
+/// Thread-safe in-memory implementation. Concurrent dictionary keyed by
+/// is sufficient for a single-process Discord bot.
+///
+public sealed class DiscordWizardContextStore : IWizardContextStore
+{
+ private readonly ConcurrentDictionary store = new();
+
+ public void Set(Guid draftId, DiscordWizardContext context) =>
+ store[draftId] = context;
+
+ public bool TryGet(Guid draftId, out DiscordWizardContext context) =>
+ store.TryGetValue(draftId, out context!);
+
+ public void Remove(Guid draftId) => store.TryRemove(draftId, out _);
+}
diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs
new file mode 100644
index 0000000..8485ef3
--- /dev/null
+++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs
@@ -0,0 +1,239 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Dapper;
+using GmRelay.DiscordBot.Infrastructure.Discord;
+using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
+using GmRelay.Shared.Platform;
+using NetCord;
+using NetCord.Rest;
+using Npgsql;
+
+namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
+
+///
+/// Discord-side implementation of .
+/// Translates the platform-neutral wizard contract into NetCord REST
+/// calls and ephemeral follow-ups. The messenger has no access to the
+/// live interaction context — that lives on the inbound handler — so
+/// stashes the toast text in
+/// ; the inbound module
+/// drains the cache and ships the actual SendResponseAsync call
+/// via the existing helper used by DiscordPlatformMessenger.
+///
+public sealed class DiscordWizardMessenger : IWizardMessenger
+{
+ private readonly RestClient _rest;
+ private readonly NpgsqlDataSource _dataSource;
+ private readonly DiscordInteractionReplyCache _replies;
+ private readonly IWizardContextStore _contextStore;
+ private readonly ILogger? _log;
+
+ public DiscordWizardMessenger(
+ RestClient rest,
+ NpgsqlDataSource dataSource,
+ DiscordInteractionReplyCache replies,
+ IWizardContextStore contextStore)
+ : this(rest, dataSource, replies, contextStore, logger: null)
+ {
+ }
+
+ public DiscordWizardMessenger(
+ RestClient rest,
+ NpgsqlDataSource dataSource,
+ DiscordInteractionReplyCache replies,
+ IWizardContextStore contextStore,
+ ILogger? logger)
+ {
+ _rest = rest;
+ _dataSource = dataSource;
+ _replies = replies;
+ _contextStore = contextStore;
+ _log = logger;
+ }
+
+ public async Task EditDraftMessageAsync(
+ WizardDraft draft,
+ string text,
+ IReadOnlyList keyboard,
+ CancellationToken ct)
+ {
+ if (!_contextStore.TryGet(draft.Id, out var ctx))
+ {
+ // No stored context (e.g. service restart, draft from another
+ // process). Fall back to sending a brand new message — the
+ // caller will persist the returned id.
+ return await SendDraftMessageAsync(draft, text, keyboard, ct);
+ }
+
+ if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
+ !ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
+ {
+ // Context is corrupt — recreate the message.
+ return await SendDraftMessageAsync(draft, text, keyboard, ct);
+ }
+
+ try
+ {
+ var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
+ await _rest.ModifyMessageAsync(
+ channelId,
+ messageId,
+ options =>
+ {
+ options.Embeds = embed is null ? null : new[] { embed };
+ options.Components = rows;
+ });
+ return ctx.MessageId;
+ }
+ catch (RestException ex) when (IsExpiredOrUnknownMessage(ex))
+ {
+ // Message was deleted or interaction token expired —
+ // recreate the message in the original channel.
+ _log?.LogWarning(
+ ex,
+ "Edit failed for draft {DraftId} (channel={ChannelId}, message={MessageId}); re-sending.",
+ draft.Id,
+ ctx.ChannelId,
+ ctx.MessageId);
+ return await SendDraftMessageAsync(draft, text, keyboard, ct);
+ }
+ }
+
+ public async Task SendDraftMessageAsync(
+ WizardDraft draft,
+ string text,
+ IReadOnlyList keyboard,
+ CancellationToken ct)
+ {
+ if (!_contextStore.TryGet(draft.Id, out var ctx))
+ {
+ throw new InvalidOperationException(
+ $"Cannot send wizard message: no context for draft {draft.Id}.");
+ }
+
+ if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId))
+ {
+ throw new InvalidOperationException(
+ $"Wizard draft {draft.Id} has un-parseable channel id '{ctx.ChannelId}'.");
+ }
+
+ var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
+ var message = await _rest.SendMessageAsync(
+ channelId,
+ new MessageProperties()
+ .WithEmbeds(embed is null ? null : new[] { embed })
+ .WithComponents(rows));
+
+ var newMessageId = message.Id.ToString(CultureInfo.InvariantCulture);
+ _contextStore.Set(draft.Id, ctx with { MessageId = newMessageId });
+ return newMessageId;
+ }
+
+ public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
+ {
+ // The wizard's "answer" is just a toast shown to the user.
+ // Stash it in the existing reply cache; the inbound interaction
+ // module drains it once the wizard returns. ShowAlert=false so
+ // it appears as a quiet follow-up rather than a popup.
+ _replies.Store(new PlatformInteractionReply(
+ InteractionId: interactionId,
+ Text: text ?? string.Empty,
+ ShowAlert: false));
+ return Task.CompletedTask;
+ }
+
+ public async Task> GetOwnerClubsAsync(
+ string ownerId, CancellationToken ct)
+ {
+ // The Telegram messenger enumerates game_groups the owner
+ // manages as a GM (V008 added group_managers with role
+ // 'Owner'|'CoGm'). Discord follows the same convention.
+ const string sql = """
+ SELECT g.id AS ClubId,
+ g.name AS Name
+ FROM game_groups g
+ JOIN group_managers gm ON gm.group_id = g.id
+ JOIN players p ON p.id = gm.player_id
+ WHERE p.platform = @Platform
+ AND p.external_user_id = @ExternalId
+ AND gm.role IN ('Owner', 'CoGm')
+ GROUP BY g.id, g.name
+ ORDER BY g.name
+ """;
+ await using var conn = await _dataSource.OpenConnectionAsync(ct);
+ var rows = await conn.QueryAsync(
+ new CommandDefinition(
+ sql,
+ new { Platform = "Discord", ExternalId = ownerId },
+ cancellationToken: ct));
+ return rows.AsList();
+ }
+
+ // ── Embed + component construction ────────────────────────────────
+ private (EmbedProperties? embed, IReadOnlyList rows) BuildEmbedAndRows(
+ WizardDraft draft,
+ string text,
+ IReadOnlyList keyboard)
+ {
+ // Embeds have a hard 4096-char limit — truncate to 3900 so the
+ // wizard's own prefix/suffix additions still fit.
+ var safeText = Truncate(text, 3900);
+ var rows = BuildActionRowsFromActions(keyboard);
+ return (BuildEmbed(safeText), rows);
+ }
+
+ private static EmbedProperties BuildEmbed(string description) =>
+ new EmbedProperties()
+ .WithTitle("Мастер создания сессии")
+ .WithDescription(description)
+ .WithColor(new Color(0x5865F2));
+
+ private static string Truncate(string text, int max) =>
+ text.Length <= max ? text : text[..max];
+
+ private static IReadOnlyList BuildActionRowsFromActions(
+ IReadOnlyList actions)
+ {
+ if (actions.Count == 0)
+ {
+ return Array.Empty();
+ }
+ var rows = new List();
+ // Discord allows up to 5 buttons per ActionRow. Lay them out
+ // left-to-right, 5 per row.
+ foreach (var chunk in actions.Chunk(5))
+ {
+ var row = new ActionRowProperties();
+ foreach (var action in chunk)
+ {
+ var style = action.Style switch
+ {
+ WizardActionStyle.Primary => ButtonStyle.Primary,
+ WizardActionStyle.Success => ButtonStyle.Success,
+ WizardActionStyle.Danger => ButtonStyle.Danger,
+ _ => ButtonStyle.Secondary,
+ };
+ var cid = action.Payload;
+ if (cid.Length > DiscordWizardStep.MaxCustomIdLength)
+ {
+ // Truncate-by-omission isn't safe (customId must be
+ // unique). The wizard's callback format is already
+ // bounded — if we hit this, it's a bug.
+ throw new InvalidOperationException(
+ $"Wizard action custom id '{cid}' exceeds Discord's 100-char limit.");
+ }
+ row.Add(new ButtonProperties(cid, action.Label, style));
+ }
+ rows.Add(row);
+ }
+ return rows;
+ }
+
+ private static bool IsExpiredOrUnknownMessage(RestException ex) =>
+ ex.StatusCode == System.Net.HttpStatusCode.NotFound
+ || ex.StatusCode == System.Net.HttpStatusCode.Unauthorized
+ || ex.StatusCode == System.Net.HttpStatusCode.Forbidden;
+}
diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs
new file mode 100644
index 0000000..330f1c7
--- /dev/null
+++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs
@@ -0,0 +1,575 @@
+using System.Collections.Generic;
+using System.Linq;
+using GmRelay.DiscordBot.Rendering;
+using GmRelay.Shared.Domain;
+using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
+using NetCord;
+using NetCord.Rest;
+using NetCord.Services.ComponentInteractions;
+
+namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
+
+///
+/// Renders a wizard step into a Discord embed + components row + an
+/// optional modal popup. Lives in the DiscordBot project because the
+/// platform-neutral doesn't know
+/// about embeds, action rows, or modals.
+///
+/// The renderer also exposes so the slash
+/// command can return a "popup" response to the user (Telegram's
+/// "ForceReply" equivalent). Discord modals are limited to 5 components
+/// (typically labels wrapping text inputs) and a 45-character title.
+///
+public static class DiscordWizardStep
+{
+ ///
+ /// Discord custom-id budget is 100 characters. We pad our own
+ /// identifiers to stay under it.
+ ///
+ public const int MaxCustomIdLength = 100;
+
+ ///
+ /// Sentinel returned in
+ /// when the renderer wants the interaction module to open a modal
+ /// for the given step. The step name is the
+ /// value (e.g. Title, DateTime).
+ ///
+ public sealed record DiscordWizardRender(
+ string EmbedTitle,
+ string EmbedDescription,
+ IReadOnlyList Components,
+ string? OpenModalStep);
+
+ ///
+ /// Build the embed + components for a wizard step. The caller is
+ /// responsible for sending the message via
+ /// .
+ ///
+ public static DiscordWizardRender Render(
+ WizardDraft draft,
+ WizardPayload payload,
+ IReadOnlyList? clubs = null)
+ {
+ return draft.Step switch
+ {
+ WizardStepNames.Type => RenderType(),
+ WizardStepNames.Title => RenderTitle(),
+ WizardStepNames.Description => RenderDescription(),
+ WizardStepNames.Cover => RenderCover(),
+ WizardStepNames.System => RenderSystem(),
+ WizardStepNames.Duration => RenderDuration(),
+ WizardStepNames.DateTime => RenderDateTime(),
+ WizardStepNames.Capacity => RenderCapacity(),
+ WizardStepNames.Visibility => RenderVisibility(),
+ WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty()),
+ WizardStepNames.Publish => RenderPublish(),
+ WizardStepNames.Confirm => RenderConfirm(payload),
+
+ WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
+ WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
+ WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
+ WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
+ WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
+
+ _ => throw new System.InvalidOperationException($"Unknown wizard step: {draft.Step}"),
+ };
+ }
+
+ // ── Custom-id helpers ─────────────────────────────────────────────
+ public static string ButtonCustomId(string step, string value) =>
+ $"wizard:btn:{step}:{value}";
+
+ public static string SelectCustomId(string step) => $"wizard:select:{step}";
+
+ public static string ModalCustomId(string step) => $"wizard:modal:{step}";
+
+ public static bool TryParseButtonCustomId(string customId, out string step, out string value)
+ {
+ step = value = string.Empty;
+ var parts = customId.Split(':', 4);
+ if (parts.Length < 4 || parts[0] != "wizard" || parts[1] != "btn")
+ {
+ return false;
+ }
+ step = parts[2];
+ value = parts[3];
+ return true;
+ }
+
+ public static bool TryParseSelectCustomId(string customId, out string step)
+ {
+ step = string.Empty;
+ var parts = customId.Split(':', 3);
+ return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "select" && (step = parts[2]) is not null;
+ }
+
+ public static bool TryParseModalCustomId(string customId, out string step)
+ {
+ step = string.Empty;
+ var parts = customId.Split(':', 3);
+ return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "modal" && (step = parts[2]) is not null;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────
+ private static ButtonProperties Btn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary)
+ {
+ var cid = ButtonCustomId(step, value);
+ EnsureCustomIdFits(cid);
+ return new ButtonProperties(cid, label, style);
+ }
+
+ private static ActionRowProperties Row(params IActionRowComponentProperties[] components)
+ {
+ var row = new ActionRowProperties();
+ foreach (var c in components)
+ {
+ row.Add(c);
+ }
+ return row;
+ }
+
+ ///
+ /// Wrap a list of top-level message components (action rows and
+ /// select menus) into a single
+ /// for MessageProperties.WithComponents.
+ ///
+ private static IReadOnlyList Comps(params IMessageComponentProperties[] items) =>
+ items;
+
+ private static void EnsureCustomIdFits(string customId)
+ {
+ if (customId.Length > MaxCustomIdLength)
+ {
+ throw new System.InvalidOperationException(
+ $"Custom id '{customId}' is {customId.Length} chars; Discord limit is {MaxCustomIdLength}.");
+ }
+ }
+
+ // ── Single-game steps ─────────────────────────────────────────────
+ private static DiscordWizardRender RenderType() => new(
+ "🎲 Создание игровой сессии",
+ "Выберите тип: одна игра или пул.",
+ new IMessageComponentProperties[] { Row(Btn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary),
+ Btn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
+ OpenModalStep: null);
+
+ private static DiscordWizardRender RenderTitle() => new(
+ "📝 Название",
+ "Введите название игры в модальном окне.",
+ new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
+ OpenModalStep: WizardStepNames.Title);
+
+ private static DiscordWizardRender RenderDescription() => new(
+ "📄 Описание",
+ "Введите описание (или «-», чтобы пропустить).",
+ new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"),
+ Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
+ OpenModalStep: WizardStepNames.Description);
+
+ private static DiscordWizardRender RenderCover() => new(
+ "🖼 Обложка",
+ "Введите URL картинки (или «-», чтобы пропустить).",
+ new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"),
+ Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
+ OpenModalStep: WizardStepNames.Cover);
+
+ private static DiscordWizardRender RenderSystem() => new(
+ "🎲 Система",
+ "Выберите систему.",
+ new[]
+ {
+ Row(Btn("D&D 5e", WizardStepNames.System, "Dnd5e"),
+ Btn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"),
+ Btn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"),
+ Btn("GURPS", WizardStepNames.System, "GURPS"),
+ Btn("Fate", WizardStepNames.System, "Fate")),
+ Row(Btn("Другое… ✏️", "modal", "SystemFreeText", ButtonStyle.Primary),
+ Btn("⏭ Пропустить", WizardStepNames.System, "_skip"),
+ Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ private static DiscordWizardRender RenderDuration() => new(
+ "⏱ Длительность",
+ "Выберите длительность (или «Другое…»).",
+ new[]
+ {
+ Row(Btn("3 часа", WizardStepNames.Duration, "180"),
+ Btn("4 часа", WizardStepNames.Duration, "240"),
+ Btn("5 часов", WizardStepNames.Duration, "300"),
+ Btn("6 часов", WizardStepNames.Duration, "360")),
+ Row(Btn("Другое… ✏️", "modal", "DurationFreeText", ButtonStyle.Primary),
+ Btn("⏭ Пропустить", WizardStepNames.Duration, "_skip"),
+ Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ private static DiscordWizardRender RenderDateTime() => new(
+ "📅 Дата и время",
+ "Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
+ new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
+ OpenModalStep: WizardStepNames.DateTime);
+
+ private static DiscordWizardRender RenderCapacity() => new(
+ "👥 Лимит мест",
+ "Введите лимит (1..50) и выберите waitlist.",
+ new[]
+ {
+ Row(Btn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
+ Btn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
+ Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: WizardStepNames.Capacity);
+
+ private static DiscordWizardRender RenderVisibility() => new(
+ "🔒 Видимость",
+ "Выберите, кто увидит сессию.",
+ new IMessageComponentProperties[]
+ {
+ BuildSelectMenu(
+ SelectCustomId(WizardStepNames.Visibility),
+ "Выберите видимость…",
+ new[] { new StringMenuSelectOptionProperties("🌐 Публичная в общем showcase", "public"),
+ new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"),
+ new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"),
+ }),
+ Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ private static DiscordWizardRender RenderPickClub(IReadOnlyList clubs)
+ {
+ if (clubs.Count == 0)
+ {
+ return new DiscordWizardRender(
+ "🏷 Выбор клуба",
+ "У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
+ new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
+ OpenModalStep: null);
+ }
+ var options = new List(clubs.Count);
+ foreach (var c in clubs.Take(25))
+ {
+ options.Add(new StringMenuSelectOptionProperties(c.Name, c.ClubId.ToString()));
+ }
+ return new DiscordWizardRender(
+ "🏷 Выбор клуба",
+ "Выберите клуб из списка.",
+ new IMessageComponentProperties[]
+ {
+ BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options),
+ Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+ }
+
+ private static DiscordWizardRender RenderPublish() => new(
+ "✨ Публикация",
+ "Опубликовать в витрине сейчас?",
+ new[]
+ {
+ Row(Btn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success),
+ Btn("📝 Только в чате", WizardStepNames.Publish, "no")),
+ Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ private static DiscordWizardRender RenderConfirm(WizardPayload p) => new(
+ "👀 Проверьте перед созданием",
+ BuildConfirmDescription(p),
+ new[]
+ {
+ Row(Btn("✅ Создать", "create", "1", ButtonStyle.Success),
+ Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ // ── Pool steps ────────────────────────────────────────────────────
+ private static DiscordWizardRender RenderPoolSystemDuration() => new(
+ "🎲 Система и длительность пула",
+ "Выберите пресет или «Другое…».",
+ new IMessageComponentProperties[]
+ {
+ BuildSelectMenu(
+ SelectCustomId(WizardStepNames.PoolSystemDuration),
+ "Выберите пресет…",
+ new[] { new StringMenuSelectOptionProperties("D&D 5e · 4 ч", "Dnd5e:240"),
+ new StringMenuSelectOptionProperties("Pathfinder 2e · 4 ч", "Pathfinder2e:240"),
+ new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"),
+ new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"),
+ }),
+ Row(Btn("Другое… ✏️", "modal", "PoolSystemDurationFreeText", ButtonStyle.Primary),
+ Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ private static DiscordWizardRender RenderPoolAddSlots(WizardPayload p) => new(
+ $"📅 Слоты пула «{p.Title}»",
+ $"Добавлено: {p.Pool?.Slots.Count ?? 0}.",
+ new[]
+ {
+ Row(Btn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary),
+ Btn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)),
+ Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ private static DiscordWizardRender RenderPoolSlotDateTime() => new(
+ "📅 Дата/время слота",
+ "Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
+ new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
+ OpenModalStep: WizardStepNames.PoolSlotDateTime);
+
+ private static DiscordWizardRender RenderPoolSlotCapacity() => new(
+ "👥 Лимит слотов",
+ "Введите лимит (1..50) и выберите waitlist.",
+ new[]
+ {
+ Row(Btn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
+ Btn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
+ Row(Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: WizardStepNames.PoolSlotCapacity);
+
+ private static DiscordWizardRender RenderPoolConfirm(WizardPayload p) => new(
+ "👀 Проверьте пул перед созданием",
+ BuildPoolConfirmDescription(p),
+ new[]
+ {
+ Row(Btn("✅ Создать пул", "create", "1", ButtonStyle.Success),
+ Btn("⬅️ Назад", "back", "1"),
+ Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
+ },
+ OpenModalStep: null);
+
+ // ── Builders for embed descriptions and selects ───────────────────
+ private static StringMenuProperties BuildSelectMenu(
+ string customId,
+ string placeholder,
+ IReadOnlyList options)
+ {
+ EnsureCustomIdFits(customId);
+ return new StringMenuProperties(customId, options)
+ {
+ Placeholder = placeholder,
+ };
+ }
+
+ private static string BuildConfirmDescription(WizardPayload p)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.Append("🎲 ").AppendLine(p.Title);
+ if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
+ if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
+ if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
+ if (p.Single?.ScheduledAt is { } at) sb.Append("📅 ").AppendLine(at.FormatMoscow());
+ if (p.Single?.MaxPlayers is { } mp)
+ {
+ sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
+ }
+ sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
+ return sb.ToString();
+ }
+
+ private static string BuildPoolConfirmDescription(WizardPayload p)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.Append("📝 ").AppendLine(p.Title);
+ if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
+ if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
+ if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
+ sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
+ sb.Append("Слоты (").Append(p.Pool?.Slots.Count ?? 0).AppendLine("):");
+ if (p.Pool is not null)
+ {
+ foreach (var s in p.Pool.Slots)
+ {
+ sb.Append(" • ").Append(s.ScheduledAt.FormatMoscow())
+ .Append(" — мест ").Append(s.MaxPlayers)
+ .Append(", waitlist ").Append(s.Waitlist ? "вкл" : "выкл")
+ .AppendLine();
+ }
+ }
+ return sb.ToString();
+ }
+
+ private static string RenderVisibilityText(WizardVisibility? v) => v switch
+ {
+ WizardVisibility.Public => "публичная в общем showcase",
+ WizardVisibility.Club => "публичная в витрине клуба",
+ WizardVisibility.Members => "только для членов клуба",
+ _ => "не задана",
+ };
+
+ // ── Modal builders ────────────────────────────────────────────────
+ ///
+ /// Build a for the given wizard step.
+ /// The wizard step's openModal value drives which modal we
+ /// emit. Returns null if no modal is required for the step.
+ ///
+ public static ModalProperties? BuildModal(string step, string? draftTitle)
+ {
+ return step switch
+ {
+ WizardStepNames.Title => new ModalProperties(
+ ModalCustomId(WizardStepNames.Title),
+ "📝 Название игры",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Название",
+ new TextInputProperties(ModalCustomId(WizardStepNames.Title), TextInputStyle.Short)
+ {
+ Placeholder = "Например: D&D 5e, Проклятие Страда",
+ MinLength = 1,
+ MaxLength = WizardStepLimits.MaxTitleLength,
+ Required = true,
+ }),
+ }),
+ WizardStepNames.Description => new ModalProperties(
+ ModalCustomId(WizardStepNames.Description),
+ "📄 Описание",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Описание",
+ new TextInputProperties(ModalCustomId(WizardStepNames.Description), TextInputStyle.Paragraph)
+ {
+ Placeholder = "Опишите сценарий / сеттинг. «-» чтобы пропустить.",
+ MaxLength = WizardStepLimits.MaxDescriptionLength,
+ Required = true,
+ }),
+ }),
+ WizardStepNames.Cover => new ModalProperties(
+ ModalCustomId(WizardStepNames.Cover),
+ "🖼 Обложка (URL)",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "URL картинки",
+ new TextInputProperties(ModalCustomId(WizardStepNames.Cover), TextInputStyle.Short)
+ {
+ Placeholder = "https://… или «-» чтобы пропустить",
+ MaxLength = 500,
+ Required = true,
+ }),
+ }),
+ "SystemFreeText" => new ModalProperties(
+ ModalCustomId("SystemFreeText"),
+ "🎲 Другая система",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Система",
+ new TextInputProperties(ModalCustomId("SystemFreeText"), TextInputStyle.Short)
+ {
+ Placeholder = "Свободное название системы",
+ MaxLength = WizardStepLimits.MaxSystemLength,
+ Required = true,
+ }),
+ }),
+ "DurationFreeText" => new ModalProperties(
+ ModalCustomId("DurationFreeText"),
+ "⏱ Длительность (часы)",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Часы",
+ new TextInputProperties(ModalCustomId("DurationFreeText"), TextInputStyle.Short)
+ {
+ Placeholder = "1..12",
+ MaxLength = 4,
+ Required = true,
+ }),
+ }),
+ WizardStepNames.DateTime => new ModalProperties(
+ ModalCustomId(WizardStepNames.DateTime),
+ "📅 Дата и время",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Когда",
+ new TextInputProperties(ModalCustomId(WizardStepNames.DateTime), TextInputStyle.Short)
+ {
+ Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
+ MaxLength = 32,
+ Required = true,
+ }),
+ }),
+ WizardStepNames.Capacity => new ModalProperties(
+ ModalCustomId(WizardStepNames.Capacity),
+ "👥 Лимит мест",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Max players",
+ new TextInputProperties(ModalCustomId(WizardStepNames.Capacity), TextInputStyle.Short)
+ {
+ Placeholder = "1..50",
+ MaxLength = 3,
+ Required = true,
+ }),
+ }),
+ WizardStepNames.PoolSlotDateTime => new ModalProperties(
+ ModalCustomId(WizardStepNames.PoolSlotDateTime),
+ "📅 Дата/время слота",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Когда",
+ new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotDateTime), TextInputStyle.Short)
+ {
+ Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
+ MaxLength = 32,
+ Required = true,
+ }),
+ }),
+ WizardStepNames.PoolSlotCapacity => new ModalProperties(
+ ModalCustomId(WizardStepNames.PoolSlotCapacity),
+ "👥 Лимит слотов",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Max players",
+ new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotCapacity), TextInputStyle.Short)
+ {
+ Placeholder = "1..50",
+ MaxLength = 3,
+ Required = true,
+ }),
+ }),
+ "PoolSystemDurationFreeText" => new ModalProperties(
+ ModalCustomId("PoolSystemDurationFreeText"),
+ "🎲 Другая система пула",
+ new IModalComponentProperties[]
+ {
+ new LabelProperties(
+ "Система и длительность",
+ new TextInputProperties(ModalCustomId("PoolSystemDurationFreeText"), TextInputStyle.Short)
+ {
+ Placeholder = "Dnd5e:240 или «Pathfinder 2e:180»",
+ MaxLength = 32,
+ Required = true,
+ }),
+ }),
+ _ => null,
+ };
+ }
+}
diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs
new file mode 100644
index 0000000..632cf08
--- /dev/null
+++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs
@@ -0,0 +1,286 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GmRelay.DiscordBot.Infrastructure.Discord;
+using GmRelay.Shared.Domain;
+using GmRelay.Shared.Features.Sessions.CreateSession;
+using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
+using GmRelay.Shared.Platform;
+using Microsoft.Extensions.Logging;
+using NetCord;
+using NetCord.Rest;
+
+namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
+
+///
+/// Finalises a wizard draft by calling the shared
+/// . On success the original draft
+/// message is overwritten with a "✅ Создано" confirmation; on failure
+/// the user is offered Retry / Cancel buttons so the same draft can be
+/// re-submitted without re-entering the wizard from scratch.
+///
+public sealed class DiscordWizardSubmitter
+{
+ private const int MaxRetries = 3;
+
+ private readonly CreateSessionHandler _shared;
+ private readonly RestClient _rest;
+ private readonly IWizardDraftRepository _drafts;
+ private readonly IWizardContextStore _contextStore;
+ private readonly ILogger _log;
+
+ public DiscordWizardSubmitter(
+ CreateSessionHandler shared,
+ RestClient rest,
+ IWizardDraftRepository drafts,
+ IWizardContextStore contextStore,
+ ILogger log)
+ {
+ _shared = shared;
+ _rest = rest;
+ _drafts = drafts;
+ _contextStore = contextStore;
+ _log = log;
+ }
+
+ ///
+ /// Submit the draft to the shared handler. On a 1-shot failure we
+ /// edit the draft message to show "retry / cancel" affordances and
+ /// bump the in-payload retry counter; after
+ /// consecutive failures the draft is deleted.
+ ///
+ public async Task SubmitAsync(WizardDraft draft, CancellationToken ct)
+ {
+ var payload = LoadPayload(draft);
+ if (!IsComplete(payload, out var missing))
+ {
+ await EditDraftMessageAsync(
+ draft,
+ $"❌ Не заполнены поля: {missing}",
+ RetryCancelActions(),
+ ct);
+ return;
+ }
+
+ try
+ {
+ var commands = BuildCommands(draft, payload);
+ foreach (var cmd in commands)
+ {
+ await _shared.HandleAsync(cmd, ct);
+ }
+ 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)
+ {
+ _log.LogError(ex, "Submit failed for draft {DraftId}", draft.Id);
+ payload.RetryCount += 1;
+ SavePayload(draft, payload);
+ if (payload.RetryCount >= MaxRetries)
+ {
+ await EditDraftMessageAsync(
+ draft,
+ "💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
+ Array.Empty(),
+ ct);
+ await _drafts.DeleteAsync(draft.Id, ct);
+ _contextStore.Remove(draft.Id);
+ return;
+ }
+ draft.UpdatedAt = DateTimeOffset.UtcNow;
+ await _drafts.UpsertAsync(draft, ct);
+ await EditDraftMessageAsync(
+ draft,
+ $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
+ RetryCancelActions(),
+ ct);
+ }
+ }
+
+ // ── Build shared commands ────────────────────────────────────────
+ // Same shape as the Telegram submitter: pool → one command with N
+ // times, single → one command with one time.
+ private static List BuildCommands(WizardDraft draft, WizardPayload p)
+ {
+ if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
+ {
+ return new List
+ {
+ BuildCommand(
+ draft,
+ p,
+ pool.Slots.Select(s => s.ScheduledAt).ToList(),
+ MaxPlayersForPool(pool),
+ isOneShot: false),
+ };
+ }
+ 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 user = new PlatformUser(
+ PlatformKind.Discord,
+ draft.OwnerId,
+ DisplayName: string.Empty,
+ ExternalUsername: null);
+ var group = new PlatformGroup(
+ PlatformKind.Discord,
+ draft.ChatId,
+ DisplayName: string.Empty,
+ ExternalChannelId: null,
+ ExternalThreadId: draft.MessageThreadId);
+ 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 System.Text.Json.JsonSerializer.Deserialize(
+ draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
+ }
+
+ private static void SavePayload(WizardDraft draft, WizardPayload p)
+ {
+ draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
+ p, WizardPayloadJsonContext.Default.WizardPayload);
+ }
+
+ // ── Embed editing ────────────────────────────────────────────────
+ private async Task EditDraftMessageAsync(
+ WizardDraft draft, string text, IReadOnlyList actions, CancellationToken ct)
+ {
+ if (!_contextStore.TryGet(draft.Id, out var ctx))
+ {
+ return;
+ }
+ if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
+ !ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
+ {
+ return;
+ }
+ try
+ {
+ var embed = new EmbedProperties()
+ .WithTitle("Мастер создания сессии")
+ .WithDescription(Truncate(text, 3900))
+ .WithColor(new Color(0x5865F2));
+ var rows = BuildActionRowsFromActions(actions);
+ await _rest.ModifyMessageAsync(
+ channelId,
+ messageId,
+ options =>
+ {
+ options.Embeds = new[] { embed };
+ options.Components = rows;
+ });
+ }
+ catch (RestException ex)
+ {
+ _log.LogWarning(ex, "Failed to edit wizard message for draft {DraftId}", draft.Id);
+ }
+ }
+
+ private static IReadOnlyList BuildActionRowsFromActions(
+ IReadOnlyList actions)
+ {
+ if (actions.Count == 0)
+ {
+ return Array.Empty();
+ }
+ var rows = new List();
+ foreach (var chunk in actions.Chunk(5))
+ {
+ var row = new ActionRowProperties();
+ foreach (var action in chunk)
+ {
+ var style = action.Style switch
+ {
+ WizardActionStyle.Primary => ButtonStyle.Primary,
+ WizardActionStyle.Success => ButtonStyle.Success,
+ WizardActionStyle.Danger => ButtonStyle.Danger,
+ _ => ButtonStyle.Secondary,
+ };
+ row.Add(new ButtonProperties(action.Payload, action.Label, style));
+ }
+ rows.Add(row);
+ }
+ return rows;
+ }
+
+ private static string Truncate(string text, int max) =>
+ text.Length <= max ? text : text[..max];
+
+ private static IReadOnlyList RetryCancelActions() => new[]
+ {
+ new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
+ new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
+ };
+}
diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs
index 28a00f4..e239f92 100644
--- a/src/GmRelay.DiscordBot/Program.cs
+++ b/src/GmRelay.DiscordBot/Program.cs
@@ -1,5 +1,6 @@
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions;
+using GmRelay.DiscordBot.Features.Sessions.Wizard;
using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Health;
@@ -10,6 +11,7 @@ using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
+using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
@@ -82,6 +84,19 @@ builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
+// ── Wizard services (issue #112) ──────────────────────────────────────
+// The Discord wizard reuses the platform-neutral state machine in
+// GmRelay.Shared (GameCreationWizard, IWizardMessenger,
+// IWizardDraftRepository) and only adds a Discord-specific messenger,
+// step renderer, slash command, and submitter on top. The wizard's
+// cleanup service is shared with the Telegram bot and is not
+// registered here — it would compete on the same drafts table.
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
builder.Services
.AddDiscordGateway(options =>
{