fix(shared,bot,discordbot): make club-picker Dapper calls AOT-safe (v3.9.2)
Deploy Telegram Bot / build-and-push (push) Successful in 7m18s
Deploy Telegram Bot / scan-images (push) Failing after 17s
Deploy Telegram Bot / deploy (push) Has been skipped

The 3.9.1 hotfix only repaired WizardDraftRepository, the most common
Dapper call in the wizard. The same AOT-unsafe CommandDefinition pattern
remained in 4 other places that the user hit immediately after the
deploy: the 'Choose visibility' wizard step triggers GetOwnerClubsAsync
when the user picks 'Публичная в витрине клуба' or 'Только для членов
клуба'. The wizard swallowed PlatformNotSupportedException, the
callback ack replied with '⚠️ Ошибка', and the next step never rendered.
Privacy 'didn't stick' from the user's perspective.

Two changes to fix the Discord side as well:

1. Switched GetOwnerClubsAsync / LoadClubsAsync / LoadManagerUserIdsAsync
   to the direct (sql, params) overload across TelegramWizardMessenger,
   DiscordWizardMessenger, DiscordWizardInteractionModule, and
   DiscordPermissionLookup — same pattern as the 3.9.1 fix.

2. Added Dapper.AOT module attribute ([module: Dapper.DapperAot]) and
   InterceptorsPreviewNamespaces to the DiscordBot project. The
   DiscordBot assembly was previously skipped by the AOT source
   generator, so even the direct-overload fix wouldn't have produced
   interceptors for the Discord-specific Dapper call sites. With this
   addition, the generator emits 3 DiscordBot-specific interceptors
   (DiscordWizardMessenger, DiscordWizardInteractionModule,
   DiscordPermissionLookup) and the AssemblyLoad ships with the right
   GmRelay.DiscordBot.generated.cs.

Also expanded the AOT shape regression tests to cover all 4
CommandDefinition sites + added a 'containingClass' parameter to
ExtractMethodBody to disambiguate the duplicated LoadClubsAsync names
in DiscordWizardInteractionModule.

Bumps: 3.9.1 -> 3.9.2.
This commit is contained in:
2026-06-08 10:48:24 +03:00
parent 065e8011ee
commit 2c9016a383
200 changed files with 5856 additions and 27 deletions
@@ -28,7 +28,7 @@ public sealed class WizardDraftRepositoryAotShapeTests
"Wizard",
"WizardDraftRepository.cs"));
var getActive = ExtractMethodBody(source, "GetActiveAsync");
var getActive = ExtractMethodBody(source, "GetActiveAsync", "");
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
}
@@ -49,7 +49,29 @@ public sealed class WizardDraftRepositoryAotShapeTests
"Wizard",
"WizardDraftRepository.cs"));
var body = ExtractMethodBody(source, methodName);
var body = ExtractMethodBody(source, methodName, "");
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
}
/// <summary>
/// WizardDraftRepository was the only AOT-fatal site in v3.9.0, but the
/// same pattern (CommandDefinition on a Dapper extension that the AOT
/// generator cannot reach) is repeated in 4 club-picker / permission
/// lookups across Telegram and Discord messengers. v3.9.2 hotfix
/// converted them all to the direct (sql, params) overload. Lock the
/// regression so the next refactor doesn't reintroduce it.
/// </summary>
[Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs", "GetOwnerClubsAsync", "")]
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs", "GetOwnerClubsAsync", "")]
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs", "LoadClubsAsync", "internal static class WizardClubLookup")]
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs", "LoadManagerUserIdsAsync", "")]
public void ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition(string relativePath, string methodName, string containingClass)
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
var body = ExtractMethodBody(source, methodName, containingClass);
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
}
@@ -82,9 +104,24 @@ public sealed class WizardDraftRepositoryAotShapeTests
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
}
private static string ExtractMethodBody(string source, string methodName)
private static string ExtractMethodBody(string source, string methodName, string containingClass)
{
var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal);
// If a containing class is given (non-empty), narrow the search
// to the first occurrence AFTER the class declaration. This is
// needed when the same method name is used as a call site
// elsewhere in the file.
if (!string.IsNullOrEmpty(containingClass))
{
var classIdx = source.IndexOf(containingClass, StringComparison.Ordinal);
if (classIdx < 0)
{
throw new InvalidOperationException($"Could not locate class {containingClass} in source.");
}
searchFrom = source.IndexOf(methodName, classIdx, StringComparison.Ordinal);
}
if (searchFrom < 0)
{
throw new InvalidOperationException($"Could not locate {methodName} in source.");
@@ -92,9 +129,11 @@ public sealed class WizardDraftRepositoryAotShapeTests
// Accept any return type: `public async Task` (no result) or
// `public async Task<int>` (with result). Search for the keyword
// "Task" right before the method name.
var idx = source.IndexOf("Task", searchFrom - 16, StringComparison.Ordinal);
if (idx < 0 || !source.Substring(idx, 4).StartsWith("Task", StringComparison.Ordinal))
// "Task" in a 60-char window before the method name so we also
// pick up `public static async Task<IReadOnlyList<ulong>>`.
var windowStart = Math.Max(0, searchFrom - 60);
var idx = source.IndexOf("Task", windowStart, StringComparison.Ordinal);
if (idx < 0 || idx >= searchFrom)
{
throw new InvalidOperationException($"Could not locate {methodName} declaration in source.");
}
@@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
Assert.Contains("v3.9.1", navMenu, StringComparison.Ordinal);
Assert.Contains("v3.9.2", navMenu, StringComparison.Ordinal);
}
[Fact]