Compare commits

...

5 Commits

Author SHA1 Message Date
Toutsu 99a58d7835 ci: install Trivy from official Docker image; normalize .gitignore to UTF-8
Deploy Telegram Bot / build-and-push (push) Successful in 52s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 43s
Trivy install keeps failing on the deploy workflow:

  - empty TAG: install.sh falls back to 'latest', but the
    'latest' GitHub release tag is no longer published.
  - pin v0.71.0: pin alone is not durable. The release got
    unpublished and install.sh now dies with
    'unable to find v0.71.0 - use latest'.

Switch to the official aquasec/trivy Docker image:

  - Docker Hub tags are content-addressed and rarely removed,
    so the pin is durable.
  - The image manifest ships linux/amd64, linux/arm64, linux/ppc64le
    and linux/s390x, so the same tag works on the GitHub-hosted
    runner and on the ARM64 Pi runner.
  - We just need docker pull + docker cp /usr/local/bin/trivy.
  - Pinned to 0.70.0 (April 2026) for reasonably current CVE data.

Also normalize .gitignore to plain UTF-8. The working copy had
been re-saved as UTF-16 LE by a Set-Content call without
-Encoding UTF8 (PowerShell 5.1 default on the local Windows box),
so git kept reporting 'Binary files differ' even though the rules
themselves were fine. Re-wrote through the editor to drop the
UTF-16 encoding and added rules for showcase-*.png, *.png.local,
and the test scratch dirs that keep creeping in.
2026-06-08 12:09:19 +03:00
Toutsu f491727cec chore: stop tracking AI scratch dirs and local screenshots
Deploy Telegram Bot / build-and-push (push) Successful in 5m32s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
The previous commit accidentally pulled in .opencode/tmp/, .playwright-mcp/,
.superpowers/, and a handful of local screenshots/logs because
'git add -A' was used during the 3.9.2 fix. None of these affect the
build or the deploy; the deploy workflow was triggered by the version
bump and ran cleanly. Add them to .gitignore and untrack so the next
contributor doesn't commit them again.
2026-06-08 10:49:41 +03:00
Toutsu 2c9016a383 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.
2026-06-08 10:48:24 +03:00
Toutsu 065e8011ee ci: pin Trivy v0.71.0 in install step
Deploy Telegram Bot / build-and-push (push) Successful in 42s
Deploy Telegram Bot / scan-images (push) Successful in 2m59s
Deploy Telegram Bot / deploy (push) Successful in 46s
The previous 'curl ... | sh -s -- -b /usr/local/bin' call passed no
positional tag, so the install script fell back to the GitHub 'latest'
tag. aquasecurity/trivy no longer publishes a 'latest' release tag, so
the CI failed at 'Install Trivy' with:
  aquasecurity/trivy crit unable to find '' - use 'latest' or see ...

This blocked the entire 3.9.1 hotfix deploy: build-and-push succeeded
(3 fresh 3.9.1 images pushed to git.codeanddice.ru), but scan-images
never ran and deploy was skipped. Production still runs 3.9.0 with the
broken wizard.

Pass 'v0.71.0' as the positional tag; v0.71.0 has Linux-ARM64 and
Linux-AMD64 builds so both the deploy runner (RPi 5) and pr-checks
runner pick the right tarball.
2026-06-08 10:23:31 +03:00
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
23 changed files with 315 additions and 58 deletions
+22 -2
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.9.0 VERSION: 3.9.2
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -72,7 +72,27 @@ jobs:
steps: steps:
- name: Install Trivy - name: Install Trivy
run: | run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale:
# 1. install.sh resolves the positional tag against the
# GitHub releases API; when a release is unpublished or
# yanked, the script fails with
# `unable to find '<tag>' - use 'latest' or see ...`
# even when the release once existed. We hit this with
# v0.71.0.
# 2. Docker Hub tags are content-addressed and rarely
# removed, so a pinned image tag is much more stable.
# 3. The image is multi-arch (linux/amd64, linux/arm64,
# linux/ppc64le, linux/s390x) so the same tag works on
# the GitHub-hosted runner and on the ARM64 Pi runner.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Scan Bot image - name: Scan Bot image
run: | run: |
+13 -1
View File
@@ -47,7 +47,19 @@ jobs:
- name: Install Trivy - name: Install Trivy
run: | run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale (see deploy.yml for the long
# version): the GitHub release tag we pinned (v0.71.0) was
# unpublished, and install.sh fails hard on missing tags.
# Docker Hub images are content-addressed and rarely removed,
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version trivy --version
- name: Trivy filesystem security scan - name: Trivy filesystem security scan
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.9.0</Version> <Version>3.9.2</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+38 -1
View File
@@ -1,4 +1,41 @@
## 🎯 Minor 3.9.0Discord-визард создания игры/пула (issue #112) ## 🐞 Patch 3.9.2Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync``PlatformNotSupportedException``GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
### 🩹 Что починено
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync``new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
### ⚠️ Известные ограничения
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
### 🧪 Тесты
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
## 🐞 Patch 3.9.1 — Hotfix: 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) использовали один и тот же движок визарда. Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.2
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.2
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -86,7 +86,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0 image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.2
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -66,16 +66,16 @@ public sealed class CreateSessionHandler
OwnerId = ownerId, OwnerId = ownerId,
Platform = PlatformName, Platform = PlatformName,
Step = WizardStepNames.Type, Step = WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), ExpiresAt = DateTime.UtcNow.AddHours(24),
}; };
await _drafts.UpsertAsync(draft, ct); await _drafts.UpsertAsync(draft, ct);
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload()); var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct); var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId; draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow; draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct); await _drafts.UpsertAsync(draft, ct);
return draft; return draft;
} }
@@ -135,7 +135,7 @@ public sealed class CreateSessionHandler
await _drafts.DeleteAsync(draft.Id, ct); await _drafts.DeleteAsync(draft.Id, ct);
return; return;
} }
draft.UpdatedAt = DateTimeOffset.UtcNow; draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct); await _drafts.UpsertAsync(draft, ct);
await _messenger.EditDraftMessageAsync( await _messenger.EditDraftMessageAsync(
draft, draft,
@@ -82,6 +82,14 @@ public sealed class TelegramWizardMessenger(
// and game_groups has no `club_id` FK). The picker therefore returns the // and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching // game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream. // the WizardClubOption contract (UUID id, name) used downstream.
//
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
// (sql, object?) extension overload — not the (CommandDefinition) overload.
// The wizard reaches this method on the PickClub visibility step
// (issue #112 follow-up); using CommandDefinition here would fall back
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
// and throws PlatformNotSupportedException on AOT. Same root cause as
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
const string sql = """ const string sql = """
SELECT g.id AS ClubId, SELECT g.id AS ClubId,
g.name AS Name g.name AS Name
@@ -95,10 +103,8 @@ public sealed class TelegramWizardMessenger(
"""; """;
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<WizardClubOption>( var rows = await connection.QueryAsync<WizardClubOption>(
new CommandDefinition( sql,
sql, new { Platform = "Telegram", ExternalId = ownerId });
new { Platform = "Telegram", ExternalId = ownerId },
cancellationToken: ct));
return rows.AsList(); return rows.AsList();
} }
@@ -31,8 +31,10 @@ internal static class DiscordPermissionLookup
"""; """;
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await connection.QueryAsync<ulong>( var rows = await connection.QueryAsync<ulong>(
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken)); sql,
new { GuildId = guildId.ToString() });
return rows.ToList(); return rows.ToList();
} }
} }
@@ -115,9 +115,9 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
? WizardStepNames.Title ? WizardStepNames.Title
: WizardStepNames.Type, : WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), ExpiresAt = DateTime.UtcNow.AddHours(24),
}; };
// If the user passed `mode=pool` we pre-seed the payload so the // If the user passed `mode=pool` we pre-seed the payload so the
// wizard's own branching lands on the pool flow. // 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 (text, actions) = WizardStepViewBuilder.Build(draft, payload);
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct); var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId; draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow; draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct); await _drafts.UpsertAsync(draft, ct);
await Context.Interaction.ModifyResponseAsync(msg => await Context.Interaction.ModifyResponseAsync(msg =>
@@ -537,11 +537,10 @@ internal static class WizardClubLookup
ORDER BY g.name ORDER BY g.name
"""; """;
await using var conn = await dataSource.OpenConnectionAsync(ct); await using var conn = await dataSource.OpenConnectionAsync(ct);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await conn.QueryAsync<WizardClubOption>( var rows = await conn.QueryAsync<WizardClubOption>(
new CommandDefinition( sql,
sql, new { Platform = "Discord", OwnerId = ownerId });
new { Platform = "Discord", OwnerId = ownerId },
cancellationToken: ct));
return rows.AsList(); return rows.AsList();
} }
} }
@@ -164,11 +164,11 @@ public sealed class DiscordWizardMessenger : IWizardMessenger
ORDER BY g.name ORDER BY g.name
"""; """;
await using var conn = await _dataSource.OpenConnectionAsync(ct); await using var conn = await _dataSource.OpenConnectionAsync(ct);
// NativeAOT: direct (sql, params) overload — see
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
var rows = await conn.QueryAsync<WizardClubOption>( var rows = await conn.QueryAsync<WizardClubOption>(
new CommandDefinition( sql,
sql, new { Platform = "Discord", ExternalId = ownerId });
new { Platform = "Discord", ExternalId = ownerId },
cancellationToken: ct));
return rows.AsList(); return rows.AsList();
} }
@@ -97,7 +97,7 @@ public sealed class DiscordWizardSubmitter
_contextStore.Remove(draft.Id); _contextStore.Remove(draft.Id);
return; return;
} }
draft.UpdatedAt = DateTimeOffset.UtcNow; draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct); await _drafts.UpsertAsync(draft, ct);
// The full exception (with stack trace, Postgres constraint // The full exception (with stack trace, Postgres constraint
// name, sometimes partial SQL) is already logged server-side // name, sometimes partial SQL) is already logged server-side
@@ -8,6 +8,7 @@
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId> <UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper --> <!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
<NoWarn>$(NoWarn);DAP005</NoWarn> <NoWarn>$(NoWarn);DAP005</NoWarn>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+2
View File
@@ -27,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
using NetCord.Services.ComponentInteractions; using NetCord.Services.ComponentInteractions;
using Npgsql; using Npgsql;
[module: Dapper.DapperAot]
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults(); builder.AddServiceDefaults();
@@ -169,7 +169,7 @@ public sealed class GameCreationWizard
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct) private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
{ {
draft.UpdatedAt = DateTimeOffset.UtcNow; draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct); await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft); var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? clubs = null; IReadOnlyList<WizardClubOption>? clubs = null;
@@ -43,9 +43,9 @@ public sealed class WizardDraft
/// </summary> /// </summary>
public string? DraftMessageId { get; set; } 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 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) public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
{ {
const string sql = """ const string sql = """
@@ -29,13 +36,10 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 1 LIMIT 1
"""; """;
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.QuerySingleOrDefaultAsync<WizardDraft>( return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
new CommandDefinition( sql,
sql, new { Platform = platform, OwnerId = ownerId });
new { Platform = platform, OwnerId = ownerId },
cancellationToken: ct));
} }
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct) public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
@@ -52,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
updated_at = EXCLUDED.updated_at, updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at; expires_at = EXCLUDED.expires_at;
"""; """;
await using var connection = await dataSource.OpenConnectionAsync(ct); 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) public async Task DeleteAsync(Guid id, CancellationToken ct)
{ {
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id"; const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
await using var connection = await dataSource.OpenConnectionAsync(ct); 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) public async Task<int> DeleteExpiredAsync(CancellationToken ct)
{ {
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()"; const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
await using var connection = await dataSource.OpenConnectionAsync(ct); 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> </button>
</form> </form>
<div class="nav-version">v3.9.0</div> <div class="nav-version">v3.9.2</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -0,0 +1,175 @@
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>
/// 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);
}
/// <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, 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.");
}
// Accept any return type: `public async Task` (no result) or
// `public async Task<int>` (with result). Search for the keyword
// "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.");
}
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); await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource); 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); await sut.UpsertAsync(draft, CancellationToken.None);
draft.Step = "Title"; draft.Step = "Title";
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1); draft.UpdatedAt = DateTime.UtcNow.AddSeconds(1);
await sut.UpsertAsync(draft, CancellationToken.None); await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, 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); await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource); 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); await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, 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); await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource); 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); await sut.UpsertAsync(draft, CancellationToken.None);
var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1) 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); await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource); var sut = new WizardDraftRepository(dataSource);
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1)); var fresh = NewDraft("Type", DateTime.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1)); var stale = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
stale.Id = Guid.NewGuid(); stale.Id = Guid.NewGuid();
await sut.UpsertAsync(fresh, CancellationToken.None); await sut.UpsertAsync(fresh, CancellationToken.None);
await sut.UpsertAsync(stale, CancellationToken.None); await sut.UpsertAsync(stale, CancellationToken.None);
@@ -78,7 +78,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
Assert.NotNull(loadedFresh); Assert.NotNull(loadedFresh);
} }
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new() private static WizardDraft NewDraft(string step, DateTime expiresAt) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ChatId = "42", ChatId = "42",
@@ -87,8 +87,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
Platform = "Telegram", Platform = "Telegram",
Step = step, Step = step,
PayloadJson = "{}", PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt, ExpiresAt = expiresAt,
}; };
} }
@@ -40,9 +40,9 @@ internal static class WizardTestFakes
PayloadJson = System.Text.Json.JsonSerializer.Serialize( PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(), payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload), WizardPayloadJsonContext.Default.WizardPayload),
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), ExpiresAt = DateTime.UtcNow.AddHours(24),
}; };
/// <summary> /// <summary>
@@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
public async Task NavMenu_ShouldExposeCurrentProjectVersion() public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{ {
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); 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.2", navMenu, StringComparison.Ordinal);
} }
[Fact] [Fact]