From b81d865832584741b99d2aa6f2983979e3727ffa Mon Sep 17 00:00:00 2001 From: Coder Date: Fri, 5 Jun 2026 17:52:29 +0300 Subject: [PATCH] feat(discord): step-by-step game/pool creation wizard (issue #112) Add Discord adapter for the platform-neutral wizard moved to Shared in the previous commit. Six new files in src/GmRelay.DiscordBot/Features/ Sessions/Wizard/: - DiscordWizardContextStore: IWizardContextStore abstraction + thread-safe in-memory impl keyed by draft id. Holds the (guild, channel, message) coordinates the messenger needs to re-send the draft after a 15-minute interaction token expires. - DiscordWizardStep: renderer for all 15 wizard steps. Returns an embed plus an IReadOnlyList that mixes ActionRow buttons with StringMenu select menus. Also exposes BuildModal for the 8 modal-collecting steps. - DiscordWizardMessenger: IWizardMessenger impl backed by NetCord's RestClient + NpgsqlDataSource. Edit falls back to re-send on 401/403/404. Toast replies are stashed in the existing DiscordInteractionReplyCache. - DiscordWizardSubmitter: 3-retry finalize loop. Builds the shared CreateSessionCommand and calls CreateSessionHandler; on success edits the message to "ok Created: N sessions", on failure shows retry/cancel buttons. - DiscordWizardCommand: /newsession-wizard slash command with an optional mode param (single|pool). Owner/co-GM check via the shared group_managers table. - DiscordPermissionLookup: small helper that loads DB manager ids for a guild. Program.cs gets 5 new singleton registrations (IWizardDraftRepository, IWizardContextStore, IWizardMessenger, GameCreationWizard, DiscordWizardSubmitter). The slash command is auto-discovered by AddApplicationCommands() + AddModules(typeof(Program).Assembly). Build green. All 85 wizard tests + 95 Discord tests pass. dotnet format clean. Open: DiscordWizardInteractionModule (button/modal handlers) is not yet implemented; the bot starts and /newsession-wizard works to the point of posting the first embed, but subsequent button clicks won't be handled. A follow-up commit will add the component-interaction module. --- .../Wizard/DiscordPermissionLookup.cs | 38 ++ .../Sessions/Wizard/DiscordWizardCommand.cs | 210 +++++++ .../Wizard/DiscordWizardContextStore.cs | 51 ++ .../Sessions/Wizard/DiscordWizardMessenger.cs | 239 ++++++++ .../Sessions/Wizard/DiscordWizardStep.cs | 575 ++++++++++++++++++ .../Sessions/Wizard/DiscordWizardSubmitter.cs | 286 +++++++++ src/GmRelay.DiscordBot/Program.cs | 15 + 7 files changed, 1414 insertions(+) create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardContextStore.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs 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 => {