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<IMessageComponentProperties> 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<SlashCommandInteraction, SlashCommandContext>()
+ 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.
This commit is contained in:
Coder
2026-06-05 17:52:29 +03:00
parent 8f0f2ef7e7
commit b81d865832
7 changed files with 1414 additions and 0 deletions
@@ -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;
/// <summary>
/// Small lookup helper for Discord permission checks. The
/// <see cref="DiscordNewSessionHandler"/> 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.
/// </summary>
internal static class DiscordPermissionLookup
{
public static async Task<IReadOnlyList<ulong>> 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<ulong>(
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
return rows.ToList();
}
}
@@ -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;
/// <summary>
/// Slash entry point for the Discord wizard. Mirrors the Telegram
/// <c>/newsession-wizard</c> 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 <see cref="DiscordPermissionChecker"/> is
/// applied before any draft is created.
/// </summary>
public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordWizardMessenger _messenger;
private readonly DiscordPermissionChecker _permissions;
private readonly IWizardDraftRepository _drafts;
private readonly IWizardContextStore _contextStore;
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<DiscordWizardCommand> _log;
public DiscordWizardCommand(
DiscordWizardMessenger messenger,
DiscordPermissionChecker permissions,
IWizardDraftRepository drafts,
IWizardContextStore contextStore,
NpgsqlDataSource dataSource,
ILogger<DiscordWizardCommand> 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<IMessageComponentProperties> 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 };
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Concurrent;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// 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.
/// </summary>
/// <param name="GuildId">Discord guild (server) id as a decimal string.</param>
/// <param name="ChannelId">Channel id where the draft message was posted.</param>
/// <param name="MessageId">Id of the currently active draft message.</param>
/// <param name="ThreadId">Optional thread id; <c>null</c> for top-level channel posts.</param>
public sealed record DiscordWizardContext(
string GuildId,
string ChannelId,
string MessageId,
string? ThreadId);
/// <summary>
/// In-memory store of draft → context lookups. Lives for the lifetime of
/// the process; the wizard's 24-hour expiry is enforced by
/// <c>WizardDraftCleanupService</c>, so this cache is allowed to hold
/// entries until the draft is finalized or explicitly removed.
/// </summary>
public interface IWizardContextStore
{
void Set(Guid draftId, DiscordWizardContext context);
bool TryGet(Guid draftId, out DiscordWizardContext context);
void Remove(Guid draftId);
}
/// <summary>
/// Thread-safe in-memory implementation. Concurrent dictionary keyed by
/// <see cref="Guid"/> is sufficient for a single-process Discord bot.
/// </summary>
public sealed class DiscordWizardContextStore : IWizardContextStore
{
private readonly ConcurrentDictionary<Guid, DiscordWizardContext> 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 _);
}
@@ -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;
/// <summary>
/// Discord-side implementation of <see cref="IWizardMessenger"/>.
/// 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
/// <see cref="AnswerInteractionAsync"/> stashes the toast text in
/// <see cref="DiscordInteractionReplyCache"/>; the inbound module
/// drains the cache and ships the actual <c>SendResponseAsync</c> call
/// via the existing helper used by <c>DiscordPlatformMessenger</c>.
/// </summary>
public sealed class DiscordWizardMessenger : IWizardMessenger
{
private readonly RestClient _rest;
private readonly NpgsqlDataSource _dataSource;
private readonly DiscordInteractionReplyCache _replies;
private readonly IWizardContextStore _contextStore;
private readonly ILogger<DiscordWizardMessenger>? _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<DiscordWizardMessenger>? logger)
{
_rest = rest;
_dataSource = dataSource;
_replies = replies;
_contextStore = contextStore;
_log = logger;
}
public async Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<IReadOnlyList<WizardClubOption>> 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<WizardClubOption>(
new CommandDefinition(
sql,
new { Platform = "Discord", ExternalId = ownerId },
cancellationToken: ct));
return rows.AsList();
}
// ── Embed + component construction ────────────────────────────────
private (EmbedProperties? embed, IReadOnlyList<IMessageComponentProperties> rows) BuildEmbedAndRows(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> 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<IMessageComponentProperties> BuildActionRowsFromActions(
IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return Array.Empty<IMessageComponentProperties>();
}
var rows = new List<IMessageComponentProperties>();
// 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;
}
@@ -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;
/// <summary>
/// Renders a wizard step into a Discord embed + components row + an
/// optional modal popup. Lives in the DiscordBot project because the
/// platform-neutral <see cref="WizardStepViewBuilder"/> doesn't know
/// about embeds, action rows, or modals.
///
/// The renderer also exposes <see cref="OpenModal"/> 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.
/// </summary>
public static class DiscordWizardStep
{
/// <summary>
/// Discord custom-id budget is 100 characters. We pad our own
/// identifiers to stay under it.
/// </summary>
public const int MaxCustomIdLength = 100;
/// <summary>
/// Sentinel returned in <see cref="DiscordWizardRender.OpenModalStep"/>
/// when the renderer wants the interaction module to open a modal
/// for the given step. The step name is the <see cref="WizardStepNames"/>
/// value (e.g. <c>Title</c>, <c>DateTime</c>).
/// </summary>
public sealed record DiscordWizardRender(
string EmbedTitle,
string EmbedDescription,
IReadOnlyList<IMessageComponentProperties> Components,
string? OpenModalStep);
/// <summary>
/// Build the embed + components for a wizard step. The caller is
/// responsible for sending the message via
/// <see cref="DiscordWizardMessenger"/>.
/// </summary>
public static DiscordWizardRender Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? 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<WizardClubOption>()),
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;
}
/// <summary>
/// Wrap a list of top-level message components (action rows and
/// select menus) into a single <see cref="IReadOnlyList{IMessageComponentProperties}"/>
/// for <c>MessageProperties.WithComponents</c>.
/// </summary>
private static IReadOnlyList<IMessageComponentProperties> 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<WizardClubOption> 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<StringMenuSelectOptionProperties>(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<StringMenuSelectOptionProperties> 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 ────────────────────────────────────────────────
/// <summary>
/// Build a <see cref="ModalProperties"/> for the given wizard step.
/// The wizard step's <c>openModal</c> value drives which modal we
/// emit. Returns <c>null</c> if no modal is required for the step.
/// </summary>
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,
};
}
}
@@ -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;
/// <summary>
/// Finalises a wizard draft by calling the shared
/// <see cref="CreateSessionHandler"/>. 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.
/// </summary>
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<DiscordWizardSubmitter> _log;
public DiscordWizardSubmitter(
CreateSessionHandler shared,
RestClient rest,
IWizardDraftRepository drafts,
IWizardContextStore contextStore,
ILogger<DiscordWizardSubmitter> log)
{
_shared = shared;
_rest = rest;
_drafts = drafts;
_contextStore = contextStore;
_log = log;
}
/// <summary>
/// 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 <see cref="MaxRetries"/>
/// consecutive failures the draft is deleted.
/// </summary>
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<WizardAction>(),
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<WizardAction>(),
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<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
{
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false),
};
}
return new List<CreateSessionCommand>
{
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<DateTimeOffset> 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<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
}
// ── Validation ───────────────────────────────────────────────────
private static bool IsComplete(WizardPayload p, out string missing)
{
var missingFields = new List<string>();
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<WizardAction> 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<IMessageComponentProperties> BuildActionRowsFromActions(
IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return Array.Empty<IMessageComponentProperties>();
}
var rows = new List<IMessageComponentProperties>();
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<WizardAction> RetryCancelActions() => new[]
{
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}
+15
View File
@@ -1,5 +1,6 @@
using GmRelay.DiscordBot; using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions; using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using GmRelay.DiscordBot.Infrastructure; using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Health; 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.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder; using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Infrastructure.Scheduling; using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
@@ -82,6 +84,19 @@ builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>(); builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services.AddHostedService<DiscordHealthCheckHostedService>(); builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
// ── 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<IWizardDraftRepository, GmRelay.Shared.Features.Sessions.CreateSession.Wizard.WizardDraftRepository>();
builder.Services.AddSingleton<IWizardContextStore, DiscordWizardContextStore>();
builder.Services.AddSingleton<IWizardMessenger, DiscordWizardMessenger>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
builder.Services.AddSingleton<DiscordWizardSubmitter>();
builder.Services builder.Services
.AddDiscordGateway(options => .AddDiscordGateway(options =>
{ {