From 7cfb1968c0e00f79a15a7dbd55ed53bec0ae7a94 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Fri, 5 Jun 2026 19:10:20 +0300 Subject: [PATCH] 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. --- .../Wizard/DiscordWizardInteractionModule.cs | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs index e9e3689..293b9ee 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs @@ -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 _log; public WizardInteractionDispatcher( @@ -101,6 +104,7 @@ public sealed class WizardInteractionDispatcher DiscordWizardSubmitter submitter, DiscordWizardMessenger messenger, DiscordInteractionReplyCache replies, + NpgsqlDataSource dataSource, ILogger 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?> 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 /// internal static class WizardClubLookup { - public static async Task> LoadClubsAsync(string ownerId, CancellationToken ct) + public static async Task> 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(); + // 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( + new CommandDefinition( + sql, + new { Platform = "Discord", OwnerId = ownerId }, + cancellationToken: ct)); + return rows.AsList(); } }