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:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||
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
|
||||
.AddDiscordGateway(options =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user