Compare commits

..

2 Commits

Author SHA1 Message Date
Toutsu f796b7d1e4 fix(shared): make WizardDraftRepository AOT-safe (v3.9.1 hotfix)
Deploy Telegram Bot / build-and-push (push) Successful in 7m24s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
Production regression in 3.9.0: Telegram bot silently dropped every update.
WizardDraftRepository.GetActiveAsync was called on every Telegram update
(via UpdateRouter -> TryGetWizardContext) and threw
System.PlatformNotSupportedException in NativeAOT, because Dapper.AOT 1.0.48
only generates interceptors for the (sql, object?) extension overloads and
NOT for the (CommandDefinition) overload. The runtime then fell back to
Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
fails on AOT. TelegramBotService swallowed the exception, so /newsession
appeared to start but no button press reached the wizard and no session was
created.

Two related changes in WizardDraft:

1. Switched WizardDraftRepository.* from 'new CommandDefinition(sql, params,
   cancellationToken: ct)' to the direct 'connection.Query*(sql, params)'
   overload, matching the working pattern in JoinSessionHandler. Dapper.AOT
   now generates CommandFactory30<WizardDraft> + RowFactory17<WizardDraft> +
   QuerySingleOrDefaultAsync37<WizardDraft> for all four methods.

2. WizardDraft.CreatedAt/UpdatedAt/ExpiresAt are now DateTime (UTC) instead
   of DateTimeOffset. AOT RowFactory calls reader.GetDateTime() directly and
   does not perform DateTime -> DateTimeOffset conversion; the previous type
   raised InvalidCastException on the very first wizard_drafts query.

All 588/590 tests pass (2 pre-existing skipped, +5 new AOT regression tests
in WizardDraftRepositoryAotShapeTests). dotnet format clean.

Bumps: 3.9.0 -> 3.9.1.

Note: GetOwnerClubsAsync (Telegram/Discord), DiscordPermissionLookup, and
DiscordWizardInteractionModule.GetOwnerClubsAsync still use CommandDefinition
and will hit the same Reflection.Emit AOT failure when the user reaches the
PickClub visibility step. Follow-up in 3.9.2.
2026-06-08 10:02:59 +03:00
Toutsu 415c13bf00 Merge pull request 'feat(discord): step-by-step game/pool creation wizard (issue #112)' (#122) from feat/issue-112-wizard-refactor into main
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 2m32s
Deploy Telegram Bot / deploy (push) Successful in 41s
Reviewed-on: #122
2026-06-06 08:04:16 +03:00
15 changed files with 199 additions and 43 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.9.0
VERSION: 3.9.1
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.9.0</Version>
<Version>3.9.1</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+18 -1
View File
@@ -1,4 +1,21 @@
## 🎯 Minor 3.9.0Discord-визард создания игры/пула (issue #112)
## 🐞 Patch 3.9.1Hotfix: Telegram-визард мёртв после 3.9.0
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator``PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
### 🩹 Что починено
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs``CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs``DateTimeOffset.UtcNow``DateTime.UtcNow` в новых драфтах.
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`).
### ⚠️ Известные ограничения
- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады.
### 🧪 Тесты
- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17<WizardDraft>` + `CommandFactory30<WizardDraft>`.
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.1
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.1
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.1
restart: always
depends_on:
db:
@@ -66,16 +66,16 @@ public sealed class CreateSessionHandler
OwnerId = ownerId,
Platform = PlatformName,
Step = WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
await _drafts.UpsertAsync(draft, ct);
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
return draft;
}
@@ -135,7 +135,7 @@ public sealed class CreateSessionHandler
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditDraftMessageAsync(
draft,
@@ -115,9 +115,9 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
? WizardStepNames.Title
: WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.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.
@@ -147,7 +147,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await Context.Interaction.ModifyResponseAsync(msg =>
@@ -97,7 +97,7 @@ public sealed class DiscordWizardSubmitter
_contextStore.Remove(draft.Id);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
// The full exception (with stack trace, Postgres constraint
// name, sometimes partial SQL) is already logged server-side
@@ -169,7 +169,7 @@ public sealed class GameCreationWizard
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
{
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? clubs = null;
@@ -43,9 +43,9 @@ public sealed class WizardDraft
/// </summary>
public string? DraftMessageId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
@@ -8,6 +8,13 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
{
// NOTE: NativeAOT — Dapper.AOT 1.0.48 only generates interceptors for the
// (sql, param) extension overloads, NOT for the (CommandDefinition) overload.
// Passing a CommandDefinition here would skip the interceptor and fall back to
// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
// throws PlatformNotSupportedException on AOT (issue: wizard silently dropped
// every Telegram update in v3.9.0). All four methods therefore use the plain
// (sql, param) overload, matching the pattern in JoinSessionHandler.
public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
{
const string sql = """
@@ -29,13 +36,10 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
ORDER BY updated_at DESC
LIMIT 1
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
new CommandDefinition(
sql,
new { Platform = platform, OwnerId = ownerId },
cancellationToken: ct));
sql,
new { Platform = platform, OwnerId = ownerId });
}
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
@@ -52,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
await connection.ExecuteAsync(sql, draft);
}
public async Task DeleteAsync(Guid id, CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
await connection.ExecuteAsync(sql, new { Id = id });
}
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
return await connection.ExecuteAsync(sql);
}
}
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.9.0</div>
<div class="nav-version">v3.9.1</div>
</div>
</Authorized>
<NotAuthorized>
@@ -0,0 +1,136 @@
using System;
using System.IO;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Regression guard: WizardDraftRepository must not use the (CommandDefinition)
/// overload of Dapper. Dapper.AOT 1.0.48 only generates interceptors for the
/// (sql, object?) extension overloads; using CommandDefinition falls back to
/// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
/// throws PlatformNotSupportedException on NativeAOT (v3.9.0 wizard regression).
/// The v3.9.1 hotfix switched all four methods to the direct overload.
/// </summary>
public sealed class WizardDraftRepositoryAotShapeTests
{
[Fact]
public void WizardDraftRepository_GetActiveAsync_ShouldNotUseCommandDefinition()
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"Wizard",
"WizardDraftRepository.cs"));
var getActive = ExtractMethodBody(source, "GetActiveAsync");
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
}
[Theory]
[InlineData("UpsertAsync")]
[InlineData("DeleteAsync")]
[InlineData("DeleteExpiredAsync")]
public void WizardDraftRepository_MutatingMethods_ShouldNotUseCommandDefinition(string methodName)
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"Wizard",
"WizardDraftRepository.cs"));
var body = ExtractMethodBody(source, methodName);
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
}
/// <summary>
/// AOT RowFactory for WizardDraft expects to read timestamps as
/// <see cref="DateTime"/> (UTC). If a future refactor switches the
/// properties back to <see cref="DateTimeOffset"/>, the AOT row factory
/// will throw <c>InvalidCastException: DateTime → DateTimeOffset</c>
/// on the first query against wizard_drafts.
/// </summary>
[Fact]
public void WizardDraft_TimestampsMustBeDateTime_NotDateTimeOffset()
{
var repoRoot = FindRepositoryRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"Wizard",
"WizardDraft.cs"));
Assert.Contains("public DateTime CreatedAt", source, StringComparison.Ordinal);
Assert.Contains("public DateTime UpdatedAt", source, StringComparison.Ordinal);
Assert.Contains("public DateTime ExpiresAt", source, StringComparison.Ordinal);
Assert.DoesNotContain("public DateTimeOffset CreatedAt", source, StringComparison.Ordinal);
Assert.DoesNotContain("public DateTimeOffset UpdatedAt", source, StringComparison.Ordinal);
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
}
private static string ExtractMethodBody(string source, string methodName)
{
var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal);
if (searchFrom < 0)
{
throw new InvalidOperationException($"Could not locate {methodName} in source.");
}
// 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))
{
throw new InvalidOperationException($"Could not locate {methodName} declaration in source.");
}
var braceStart = source.IndexOf('{', idx);
if (braceStart < 0)
{
throw new InvalidOperationException($"Could not locate body opening brace for {methodName}.");
}
var depth = 1;
var pos = braceStart + 1;
while (pos < source.Length && depth > 0)
{
switch (source[pos])
{
case '{': depth++; break;
case '}': depth--; break;
}
pos++;
}
return source.Substring(braceStart, pos - braceStart);
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Directory.Build.props")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Could not locate repository root.");
}
}
@@ -16,11 +16,11 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
draft.Step = "Title";
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
draft.UpdatedAt = DateTime.UtcNow.AddSeconds(1);
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
@@ -35,7 +35,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
var draft = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
@@ -49,7 +49,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1)
@@ -65,8 +65,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
var fresh = NewDraft("Type", DateTime.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
stale.Id = Guid.NewGuid();
await sut.UpsertAsync(fresh, CancellationToken.None);
await sut.UpsertAsync(stale, CancellationToken.None);
@@ -78,7 +78,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
Assert.NotNull(loadedFresh);
}
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
private static WizardDraft NewDraft(string step, DateTime expiresAt) => new()
{
Id = Guid.NewGuid(),
ChatId = "42",
@@ -87,8 +87,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
Platform = "Telegram",
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
};
}
@@ -40,9 +40,9 @@ internal static class WizardTestFakes
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
/// <summary>
@@ -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.0", navMenu, StringComparison.Ordinal);
Assert.Contains("v3.9.1", navMenu, StringComparison.Ordinal);
}
[Fact]