feat(discord): step-by-step game/pool creation wizard (issue #112) #122
@@ -3,12 +3,14 @@ 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 Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
@@ -92,6 +94,7 @@ public sealed class WizardInteractionDispatcher
|
||||
private readonly DiscordWizardSubmitter _submitter;
|
||||
private readonly DiscordWizardMessenger _messenger;
|
||||
private readonly DiscordInteractionReplyCache _replies;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<WizardInteractionDispatcher> _log;
|
||||
|
||||
public WizardInteractionDispatcher(
|
||||
@@ -101,6 +104,7 @@ public sealed class WizardInteractionDispatcher
|
||||
DiscordWizardSubmitter submitter,
|
||||
DiscordWizardMessenger messenger,
|
||||
DiscordInteractionReplyCache replies,
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<WizardInteractionDispatcher> log)
|
||||
{
|
||||
_drafts = drafts;
|
||||
@@ -109,6 +113,7 @@ public sealed class WizardInteractionDispatcher
|
||||
_submitter = submitter;
|
||||
_messenger = messenger;
|
||||
_replies = replies;
|
||||
_dataSource = dataSource;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -295,13 +300,18 @@ public sealed class WizardInteractionDispatcher
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = args.Split(':', 3);
|
||||
if (parts.Length < 3 || parts[0] != "select" || context.Interaction.Data.SelectedValues.Count == 0)
|
||||
// NetCord strips the matching ComponentInteraction "wizard"
|
||||
// prefix from the custom id and passes the remainder as `args`.
|
||||
// For `wizard:select:Visibility` the args arrive as
|
||||
// `select:Visibility` (2 parts when split on `:`), so the
|
||||
// length check uses `< 2` and the step lives at parts[1].
|
||||
var parts = args.Split(':', 2);
|
||||
if (parts.Length < 2 || parts[0] != "select" || context.Interaction.Data.SelectedValues.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестный выбор");
|
||||
return;
|
||||
}
|
||||
var step = parts[2];
|
||||
var step = parts[1];
|
||||
var value = context.Interaction.Data.SelectedValues[0];
|
||||
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredModifyMessage);
|
||||
@@ -341,13 +351,16 @@ public sealed class WizardInteractionDispatcher
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = args.Split(':', 3);
|
||||
if (parts.Length < 3 || parts[0] != "modal" || context.Interaction.Data.Components.Count == 0)
|
||||
// Same NetCord prefix-stripping as the select handler:
|
||||
// for `wizard:modal:Title` the args arrive as `modal:Title`
|
||||
// (2 parts when split on `:`).
|
||||
var parts = args.Split(':', 2);
|
||||
if (parts.Length < 2 || parts[0] != "modal" || context.Interaction.Data.Components.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Некорректный модал");
|
||||
return;
|
||||
}
|
||||
var step = parts[2];
|
||||
var step = parts[1];
|
||||
|
||||
var text = ExtractModalText(context);
|
||||
if (text is null)
|
||||
@@ -450,11 +463,10 @@ public sealed class WizardInteractionDispatcher
|
||||
|
||||
private async Task<IReadOnlyList<WizardClubOption>?> LoadClubsAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
// The wizard's GetOwnerClubsAsync would do this for us, but the
|
||||
// dispatcher doesn't have a direct reference to the messenger.
|
||||
// We re-query via the messenger's DB connection by going
|
||||
// through a small helper exposed below.
|
||||
return await WizardClubLookup.LoadClubsAsync(draft.OwnerId, ct);
|
||||
// Inline the same query the messenger would run, so the
|
||||
// dispatcher's PickClub step sees the owner's real club list
|
||||
// instead of an empty array.
|
||||
return await WizardClubLookup.LoadClubsAsync(_dataSource, draft.OwnerId, ct);
|
||||
}
|
||||
|
||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||
@@ -488,14 +500,29 @@ public sealed class WizardInteractionDispatcher
|
||||
/// </summary>
|
||||
internal static class WizardClubLookup
|
||||
{
|
||||
public static async Task<IReadOnlyList<WizardClubOption>> LoadClubsAsync(string ownerId, CancellationToken ct)
|
||||
public static async Task<IReadOnlyList<WizardClubOption>> LoadClubsAsync(
|
||||
NpgsqlDataSource dataSource, string ownerId, CancellationToken ct)
|
||||
{
|
||||
// Resolve the same NpgsqlDataSource the messenger uses. The
|
||||
// dispatcher doesn't hold it directly, so it goes through DI.
|
||||
// For now we return an empty list — the inline messenger's
|
||||
// GetOwnerClubsAsync is the source of truth and is invoked by
|
||||
// the wizard's render path.
|
||||
await Task.CompletedTask;
|
||||
return Array.Empty<WizardClubOption>();
|
||||
// Same SQL the messenger runs for the wizard's render path.
|
||||
// Filter by Owner|CoGm role and group by club to dedupe.
|
||||
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 = @OwnerId
|
||||
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", OwnerId = ownerId },
|
||||
cancellationToken: ct));
|
||||
return rows.AsList();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user