fix(discord): fix select/modal parser off-by-one + wire real club lookup

The dispatcher rejected every valid select menu and modal submit
because of an off-by-one in the customId parts-length check
(NetCord strips the matching 'wizard' prefix and passes the
remainder, so 'wizard:select:Visibility' arrives as
'select:Visibility' = 2 parts, not 3).

Also fixed: WizardClubLookup.LoadClubsAsync returned an empty
list, making the PickClub step always show 'no clubs'. Now
queries the DB via NpgsqlDataSource with the same Owner|CoGm
role filter the messenger uses.

Build green, 190/192 wizard+Discord tests pass (2 pre-existing
skips), format clean.
This commit is contained in:
2026-06-05 19:10:20 +03:00
parent b1bd47f6c1
commit 7cfb1968c0
@@ -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();
}
}