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
File diff suppressed because one or more lines are too long
+517
View File
@@ -0,0 +1,517 @@
version: 1
plan:
name: 'Discord-визард создания игры/пула (issue #112)'
max_concurrency: 1
max_consecutive_failures: 2
max_cycles: 6
verifier_config:
default_verifiers: [verifier]
audit_sample_rate: 0.0
tasks:
- id: wizard-platform-refactor
title: 'Рефакторинг визарда под платформо-нейтральный IWizardMessenger'
prompt: |
Задача: подготовить визард создания игр (issue #112) к переиспользованию
из Discord-бота. Текущая стейт-машина Telegram-визарда завязана на
`ITelegramWizardMessenger` и `Telegram.Bot.Types`. Нужно вынести ядро в
`GmRelay.Shared`, оставив существующее Telegram-поведение работоспособным.
## Контекст репозитория (D:\Projects\Game)
Ключевые файлы (прочитай их перед началом работы):
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
(504 строки) — стейт-машина визарда, сейчас принимает `Update` от
`Telegram.Bot`.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs`
— 4 метода с `long chatId`/`int? messageThreadId`/`long ownerTelegramId`.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs`
— реализация.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs` —
рендер шагов в `InlineKeyboardMarkup` (Telegram-only).
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
— имена шагов.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs`
— сериализация callback data.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs` —
`ChatId long`, `MessageThreadId int?`, `OwnerTelegramId long`,
`DraftMessageId long?`, `Step string`, `PayloadJson string`.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs`
— платформо-нейтральный (уже в Shared).
- `src/GmRelay.Bot/Migrations/V031__add_wizard_drafts.sql` — таблица создана.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/` —
7 тестов: WizardStepRender, GameCreationWizardStepTransitions,
GameCreationWizardValidation, GameCreationWizardPoolSlot,
UpdateRouterDelegation, UpdateRouterResetsDraftOnStaleCommand,
WizardTestFakes (хелпер). ВСЕ должны продолжать проходить.
- `src/GmRelay.Bot/Program.cs` — DI-регистрация `IWizardDraftRepository`,
`ITelegramWizardMessenger`, `GameCreationWizard`.
## Что сделать
### 1. Платформо-нейтральный интерфейс в Shared
Создай `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs`
с контрактом, достаточным для обеих платформ. Минимум:
```csharp
public interface IWizardMessenger
{
// Возвращает обновлённый draft message id (string, чтобы покрыть
// и Telegram long, и Discord ulong).
Task<string> EditDraftMessageAsync(WizardDraft draft, string text, WizardKeyboard keyboard, CancellationToken ct);
Task<string> SendDraftMessageAsync(WizardDraft draft, string text, WizardKeyboard keyboard, CancellationToken ct);
Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct);
Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct);
}
```
`WizardKeyboard` — record/класс, описывающий набор рядов и кнопок
(линейный список `WizardAction` сгодится, ряды формирует адаптер при
рендере шага — на твой выбор, лишь бы был согласованно).
`WizardAction` — `(string Label, string Payload, WizardActionStyle Style)`
со стилем `Primary|Secondary|Success|Danger` (если решишь делать ряды —
добавь `Width` или подобное).
`WizardClubOption` перенеси из `GmRelay.Bot` в `GmRelay.Shared` (record с
`Guid ClubId`, `string Name`).
### 2. Перенос ядра в Shared
Перенеси в `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/`:
- `GameCreationWizard.cs` — перепиши с `IWizardMessenger` вместо
`ITelegramWizardMessenger`. `Update`/`Message`/`CallbackQuery` замени
на платформо-нейтральный `WizardInteraction` (record с полями
`OwnerId string`, `Text string?`, `CallbackPayload string?`,
`PhotoFileId string?`, `PhotoUrl string?`, `InteractionId string`).
- `WizardStepNames.cs`, `WizardCallbackData.cs`, `WizardStorageException.cs`.
- Константы длин и лимитов (MaxTitleLength, MaxDescriptionLength и т.п.)
— перенеси в `WizardStepLimits` (статический класс) в Shared.
Удали старые файлы из `GmRelay.Bot/Features/Sessions/CreateSession/Wizard/`,
перенеси их `using`-и. Если в Shared нужны дополнительные зависимости
(например, `Microsoft.Extensions.Logging.Abstractions`) — добавь в
`GmRelay.Shared.csproj`, проверь, что Shared по-прежнему НЕ зависит от
Telegram.Bot.
`WizardStep.cs` (рендер в `InlineKeyboardMarkup`) ОСТАВЬ в `GmRelay.Bot`
— он Telegram-only. Аналогично создашь `DiscordWizardStep` в
`GmRelay.DiscordBot` в следующей задаче.
### 3. Поле platform в wizard_drafts
Добавь миграцию `src/GmRelay.Bot/Migrations/V032__add_wizard_drafts_platform.sql`:
```sql
ALTER TABLE wizard_drafts ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram';
```
`WizardDraft` POCO: добавь `string Platform { get; set; } = "Telegram";`.
Обнови `WizardDraftRepository` (Dapper.AOT insert/select) — должны
включать `platform`. Обнови `FakeWizardDraftRepository` в тестах.
### 4. Telegram-адаптер под новый контракт
`TelegramWizardMessenger` в Bot пусть реализует `IWizardMessenger`,
конвертируя `(WizardDraft, text, keyboard)` в Telegram-вызовы и обратно.
Рендер рядов в `InlineKeyboardMarkup` — внутри адаптера или отдельным
хелпером в Bot. Сохрани существующее поведение (BackCancel, SkipBackCancel,
AppendBackCancel и т.п.).
`CreateSessionHandler` в Bot — обнови `StartWizardAsync`, `SubmitDraftAsync`
под новый messenger-контракт. `BuildCommand` должен ставить
`Platform = "Telegram"`.
`WizardDraftCleanupService` — без изменений, только подключи
обновлённый `IWizardDraftRepository`.
### 5. DI
В `src/GmRelay.Bot/Program.cs`:
- Убери регистрацию `ITelegramWizardMessenger`, оставь
`IWizardMessenger` → `TelegramWizardMessenger`.
- Убери регистрацию `GameCreationWizard` из Bot, если он теперь в Shared
(Shared не имеет Program.cs — зарегистрируй его в Bot, конструктор
принимает `IWizardMessenger` и `IWizardDraftRepository`).
### 6. Тесты
Обнови `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs`:
- `FakeWizardMessenger` теперь реализует `IWizardMessenger`. Сигнатуры
`EditDraftMessageAsync`/`SendDraftMessageAsync` принимают `WizardKeyboard`
(или `IReadOnlyList<WizardAction>` — что выберешь).
- `NewDraft` ставит `Platform = "Telegram"`.
- `CallbackUpdate`/`TextUpdate` — оберни в `WizardInteraction` (helper-метод).
Цель: ВСЕ 7 существующих wizard-тестов продолжают проходить без
изменения бизнес-смысла. Если тесты завязаны на конкретные тексты
кнопок или callback-data — обнови assertions под новые форматы (но
callback-data формат `wizard:cancel`, `wizard:back`, `wizard:choice:step:value`
должен сохраниться для обратной совместимости с Telegram-флоу).
Добавь `WizardInteractionMapperTests` (минимум 3 кейса): конвертация
`Update` → `WizardInteraction` для callback/text/photo.
## Acceptance
- `dotnet build` всего решения успешен.
- `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
— все тесты зелёные.
- `dotnet format --verify-no-changes` — без замечаний.
- `git grep "Telegram.Bot" src/GmRelay.Shared/` — пусто.
- `git grep "NetCord" src/GmRelay.Bot/` — пусто.
- `GameCreationWizard` существует ровно в одном месте — в Shared.
- `IWizardMessenger` живёт в Shared, `ITelegramWizardMessenger` удалён
(или стал internal алиасом, если проще мигрировать).
## Не делай
- Не реализуй Discord-адаптер в этой задаче — это Task 2.
- Не дублируй логику стейт-машины в Bot и Shared.
- Не используй reflection.
- Не ломай callback-data формат без migration-плана.
## Куда коммитить
Создай ветку `feat/issue-112-wizard-refactor`. Запиши в
`deliverable.md` (корень репо) сводку: какие файлы созданы/изменены,
какие тесты прошли, ссылка на ветку/PR.
Читай `D:\Projects\Game\CLAUDE.md` для команд сборки и тестов.
assigned_to: coder
verified_by: verifier
verify_prompt: |
Adversarial verification рефакторинга визарда под платформо-нейтральный
контракт. НЕ перечитывай описание исполнителя — перезапускай команды
и атакуй поведение.
## Шаги
1. `dotnet build D:\Projects\Game\GM-Relay.slnx` — должно собраться.
2. `dotnet test D:\Projects\Game\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --verbosity normal`
— ВСЕ тесты зелёные, особенно 7 wizard-тестов:
`GameCreationWizardStepTransitions`,
`GameCreationWizardValidation`,
`GameCreationWizardPoolSlot`,
`UpdateRouterDelegation`,
`UpdateRouterResetsDraftOnStaleCommand`,
`WizardStepRender`.
3. `dotnet format --verify-no-changes --verbosity diagnostic` в
`D:\Projects\Game` — без замечаний.
4. `dotnet list package --vulnerable --include-transitive` в
`D:\Projects\Game\src\GmRelay.Bot` и
`D:\Projects\Game\src\GmRelay.DiscordBot` — нет HIGH/CRITICAL.
5. `git grep -n "Telegram.Bot" D:\Projects\Game\src\GmRelay.Shared/`
— должно быть пусто.
6. `git grep -n "NetCord" D:\Projects\Game\src\GmRelay.Bot/`
— должно быть пусто.
7. `git grep -n "GameCreationWizard" D:\Projects\Game\src\`
— `GameCreationWizard.cs` должен существовать РОВНО в одном месте
(в `GmRelay.Shared`).
8. `git grep -n "IWizardMessenger" D:\Projects\Game\src\`
— интерфейс должен быть в `GmRelay.Shared`, а НЕ в Bot.
9. `git grep -n "ITelegramWizardMessenger" D:\Projects\Game\src\`
— должен быть удалён или заменён на алиас; НЕ должен быть
активным контрактом, на который завязан `GameCreationWizard`.
10. Проверь наличие миграции
`D:\Projects\Game\src\GmRelay.Bot\Migrations\V032__add_wizard_drafts_platform.sql`.
11. `git grep -n "Reflection" D:\Projects\Game\src\GmRelay.Shared/`
— не должно быть reflection-based логики.
12. Прочти `deliverable.md` в корне репо — сводка должна быть.
## Критерии FAIL
- Любой тест красный.
- `dotnet build` падает.
- `dotnet format` ругается.
- Есть vulnerable HIGH/CRITICAL пакеты.
- Shared зависит от Telegram.Bot.
- Bot зависит от NetCord.
- `GameCreationWizard` дублируется в Bot и Shared.
- `IWizardMessenger` не в Shared.
- Существующая text-only `/newsession` Telegram-команда сломана
(запусти бот хотя бы на синтаксис — собери, посмотри, что
`CreateSessionHandler.StartWizardAsync`/`SubmitDraftAsync`
компилируются).
- Reflection в AOT-критичном коде.
- deliverable.md отсутствует.
- Ветка/PR не созданы.
На FAIL укажи конкретный файл/строку/команду. НЕ просто «кажется,
сломалось» — точные доказательства.
timeout_ms: 1800000
- id: discord-wizard-impl
title: 'Discord-адаптер визарда: slash-команда, кнопки, модалы, select-меню'
prompt: |
Задача: реализовать Discord-визард создания игры/пула игр (issue #112)
в проекте `src/GmRelay.DiscordBot/`. Переиспользовать общую стейт-машину
`GameCreationWizard` и `IWizardMessenger` из Shared (Task 1 уже подготовил
контракт). Discord-бот использует NetCord; AOT-friendly код (без
reflection, без динамической загрузки).
## Контекст
Что уже готово (Task 1):
- `GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs`
- `GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
- `GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
- `GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardKeyboard.cs` /
`WizardAction.cs` — как ты их назвал в Task 1.
- Миграция V032 с колонкой `platform`.
- Существующие wizard-тесты зелёные.
Discord-проект (`src/GmRelay.DiscordBot/`):
- Использует NetCord, в Program.cs:
`AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()`
и `AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()`.
- Содержит `DiscordNewSessionCommand` (text-only `/newsession`),
`DiscordNewSessionHandler`, `DiscordSessionInteractionModule`
с кнопками `[ComponentInteraction("join_session:...")]`.
- Содержит `DiscordPermissionChecker` (owner/co-GM) и
`DiscordPlatformMessenger` (для IPlatformMessenger).
- Использует `NpgsqlDataSource` (БД общая с Bot).
## Что сделать
### 1. DiscordWizardMessenger (реализация IWizardMessenger)
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs`:
- `EditDraftMessageAsync` — редактирует embed, в котором `draft_id`
зашит в footer или в customId кнопок. Если 15-минутный interaction
token истёк — пересоздаёт сообщение в исходном канале, возвращает
новый `messageId`.
- `SendDraftMessageAsync` — отправляет embed с кнопками.
- `AnswerInteractionAsync` — отвечает на interaction (ephemeral при
необходимости). Для длительных операций — defer+followup.
- `GetOwnerClubsAsync` — SQL-запрос через Dapper.AOT:
```sql
SELECT cm.club_id, c.name
FROM club_memberships cm
JOIN clubs c ON c.id = cm.club_id
JOIN players p ON p.id = cm.player_id
WHERE p.platform = 'Discord'
AND p.external_user_id = @OwnerId
AND cm.role IN ('Owner', 'CoGm')
```
Поля классы — в существующей схеме (посмотри V030 миграцию).
Owner/co-GM права — в `club_memberships.role`.
Контекст для отправки/редактирования нужно где-то хранить между
вызовами. Сделай in-memory кэш `DiscordWizardContextStore`:
- Ключ — `draft.Id` (Guid).
- Значение — `DiscordWizardContext(guildId, channelId, messageId,
threadId?, lastInteractionToken)`.
- При `SendDraftMessageAsync` / `EditDraftMessageAsync` обновляется.
- На cancel / confirm — удаляется.
### 2. DiscordWizardStep (рендер шагов)
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs`:
- Метод `Render(WizardDraft draft, WizardPayload payload, IReadOnlyList<WizardClubOption> clubs) → (string text, WizardKeyboard keyboard, string? openModal)`.
- Поле `openModal` — имя шага, для которого нужно открыть модал
(`Title`, `Description`, `SystemFreeText`, `DurationFreeText`,
`DateTime`, `Capacity`, `PoolSlotDateTime`, `PoolSlotCapacity`,
`PoolSystemDurationFreeText`). Если null — просто рендерим embed
+ кнопки.
- Каждый шаг использует emoji-метки из Telegram-версии (🎲 📅 ⏱ и т.п.),
но текст короче (Discord embed description 4096 символов).
- CustomId формат: `wizard:btn:<step>:<value>` (кнопки),
`wizard:select:<step>` (select menu, value в values[]),
`wizard:modal:<step>` (модалы). Длина customId <= 100 символов.
Конкретные шаги:
- `Type` — 2 кнопки single/pool + Cancel.
- `Title` / `Description` / `Cover` — modal (Text Input, Short/Paragraph).
`Cover` дополнительно принимает URL (Text Input, Short).
- `System` — buttons (Dnd5e/Pathfinder2e/CallOfCthulhu7e/GURPS/Fate) +
"Другое…" (modal) + "Пропустить" (button).
- `Duration` — buttons (3ч/4ч/5ч/6ч) + "Другое…" (modal) + "Пропустить".
- `DateTime` — modal с placeholder "ДД.ММ.ГГГГ ЧЧ:ММ".
- `Capacity` — modal "Max players" + 2 кнопки waitlist on/off.
- `Visibility` — StringSelectMenu (Public/Club/Members) + Back/Cancel.
- `PickClub` — StringSelectMenu со списком клубов (max 25 опций).
- `Publish` — 2 кнопки yes/no.
- `Confirm` — preview-embed + 3 кнопки Create/Back/Cancel.
- `PoolSystemDuration` — StringSelectMenu (Dnd5e:240, Pathfinder2e:240,
CallOfCthulhu7e:180, GURPS:240) + "Custom" (modal) + Back/Cancel.
- `PoolAddSlots` — 2 кнопки add/done + счётчик слотов в embed.
- `PoolSlotDateTime` — modal.
- `PoolSlotCapacity` — modal "Max players" + 2 кнопки waitlist.
- `PoolConfirm` — preview-embed пула + 3 кнопки.
### 3. Slash-команда
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs`:
- `[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]`
- Опциональный параметр `mode` (`single` / `pool`) для пропуска первого шага.
- Проверка owner/co-GM прав через `DiscordPermissionChecker`.
- Проверка существующего активного draft'а
(`IWizardDraftRepository.GetActiveAsync`); если есть — показать
ephemeral с кнопками Continue/Start over/Cancel.
- Если нет — создать draft (`Platform = "Discord"`, `Step = "Type"`),
отправить первое сообщение через `DiscordWizardMessenger`,
сохранить `DraftMessageId` (строкой).
### 4. Interaction handlers
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs`:
- `[ComponentInteraction("wizard:btn:choice:*:*")]`
— кнопки-выборы (Type, System, Duration, Capacity waitlist,
Visibility, PickClub, Publish, PoolAddSlots, PoolSlotCapacity,
Confirm, Back, Cancel). Парсит `step` и `value` из customId.
- `[ComponentInteraction("wizard:btn:cancel")]`,
`[ComponentInteraction("wizard:btn:back")]`,
`[ComponentInteraction("wizard:btn:create")]` — отдельные (если
customId не разбит на части, или разбит по другому).
- `[ComponentInteraction("wizard:select:*:*")]`
— StringSelectMenu (Visibility, PickClub, PoolSystemDuration).
Парсит `step` и `value` из values[].
- `[ModalInteraction("wizard:modal:*")]`
— модалы. Парсит `step` из customId, поля из ModalInteractionData.
Преобразует в `WizardInteraction` (Text = value) и вызывает
`GameCreationWizard.HandleUpdateAsync`.
Каждый обработчик:
1. Достаёт `draft_id` из customId (или из data store).
2. Загружает draft из `IWizardDraftRepository`.
3. Формирует `WizardInteraction` (OwnerId, Text/CallbackPayload,
InteractionId).
4. Вызывает `GameCreationWizard.HandleUpdateAsync(draft, interaction, ct)`.
5. На ошибке валидации — перерендерить шаг с ⚠️ префиксом.
6. На cancel — отредактировать сообщение на "❌ Мастер отменён",
удалить draft из кэша.
7. На confirm (create) — вызвать финализацию (см. п. 5).
### 5. Финализация (Submit)
Сделай `DiscordWizardSubmitter` (или метод в `DiscordWizardInteractionModule`):
- Загружает `WizardPayload` из `draft.PayloadJson`.
- Валидирует полноту (заголовок, система, длительность, видимость;
для single — дата/лимит; для pool — >=1 слот).
- Строит `CreateSessionCommand` (платформо-нейтральный) и вызывает
`GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler.HandleAsync`.
- На успехе — отредактировать wizard-сообщение на "✅ Создано: N сессий",
удалить draft.
- На ошибке — показать retry/cancel кнопки (retry = повтор Submit).
### 6. DI
В `src/GmRelay.DiscordBot/Program.cs`:
- Зарегистрируй `IWizardDraftRepository` (общий; если реализация в
Bot — продублируй в DiscordBot, чтобы не таскать сборку Bot).
Dapper.AOT-атрибуты уже работают в обоих проектах.
- Зарегистрируй `IWizardMessenger` → `DiscordWizardMessenger`
(Singleton, конструктор принимает Discord client и кэш).
- Зарегистрируй `GameCreationWizard` (Singleton, берёт
`IWizardDraftRepository` + `IWizardMessenger`).
- Зарегистрируй `DiscordWizardContextStore`, `DiscordWizardSubmitter`.
- Не сломай существующие регистрации (DiscordNewSessionCommand,
DiscordSessionInteractionModule, DiscordPlatformMessenger и т.д.).
### 7. Тесты
Добавь в `tests/GmRelay.Bot.Tests/Discord/`:
- `DiscordWizardMessengerTests` — fake `INetCordClient` / context;
проверка edit/send/answer. Используй ручные fakes (без Moq) —
проект convention.
- `DiscordWizardStepTests` — для каждого шага: правильный набор
actions (label/payload/style), правильный customId, корректный
`openModal` флаг.
- `DiscordWizardSubmitterTests` — happy path (single + pool),
missing fields, DB error → retry flow.
Если не получается замокать NetCord (он registration-based) —
вынеси `DiscordWizardContextStore` в отдельный класс с
интерфейсом `IWizardContextStore`, тестируй через fake store,
а сам submitter / messenger — через интеграционный тест с реальным
контекстом (минимум: проверка формата customId, генерации embed).
## Acceptance
- `dotnet build` решения успешен.
- `dotnet test` — все wizard-тесты + Discord-тесты зелёные.
- `dotnet format --verify-no-changes` — без замечаний.
- `git grep "Telegram.Bot" src/GmRelay.DiscordBot/` — пусто.
- `git grep "NetCord" src/GmRelay.Shared/` — пусто.
- Существующая команда `/newsession` (text-only) работает.
- Новая команда `/newsession-wizard` доступна в Discord.
- Стейт-машина одна — в Shared.
## Не делай
- Не дублируй логику `GameCreationWizard`.
- Не используй reflection.
- Не ломай callback-data формат, если он уже согласован с Telegram.
- Не пиши большие markdown в embed (лимит 4096, плюс нужен запас).
## Куда коммитить
Допиши в ту же ветку `feat/issue-112-wizard-refactor` (или создай
`feat/issue-112-discord-wizard` поверх, если refactor ещё не в main).
Запиши в `deliverable.md` сводку: какие файлы, какие тесты добавлены,
PR-ссылка. Пометь отдельно «открытые вопросы» (если есть).
Читай `D:\Projects\Game\CLAUDE.md` для команд.
assigned_to: coder
depends_on: [wizard-platform-refactor]
verified_by: verifier
verify_prompt: |
Adversarial verification Discord-визарда (issue #112). НЕ перечитывай
описание исполнителя — перезапускай команды и атакуй поведение.
## Шаги
1. `dotnet build D:\Projects\Game\GM-Relay.slnx` — успех.
2. `dotnet test D:\Projects\Game\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --verbosity normal`
— все wizard-тесты + новые Discord-тесты зелёные.
3. `dotnet format --verify-no-changes --verbosity diagnostic` —
без замечаний.
4. `dotnet list package --vulnerable --include-transitive` в
`src/GmRelay.Bot` и `src/GmRelay.DiscordBot` — нет HIGH/CRITICAL.
5. `git grep "Telegram.Bot" D:\Projects\Game\src\GmRelay.DiscordBot/`
— пусто.
6. `git grep "NetCord" D:\Projects\Game\src\GmRelay.Shared/`
— пусто.
7. `git grep "Reflection" D:\Projects\Game\src\GmRelay.DiscordBot/Features/Sessions/Wizard/`
— пусто (AOT-safe).
8. `git grep "GameCreationWizard" D:\Projects\Game\src\` —
существует ровно в `GmRelay.Shared/Features/Sessions/CreateSession/Wizard/`,
адаптеры ссылаются через `using`.
9. `git grep "DiscordWizardMessenger"` — реализует `IWizardMessenger`.
10. CustomId формат: `git grep "wizard:btn:" D:\Projects\Game\src\GmRelay.DiscordBot/`
и `git grep "wizard:select:"`, `wizard:modal:` — должны быть
использованы последовательно; длина customId не превышает
100 символов нигде.
11. `DiscordWizardCommand` — slash-команда `/newsession-wizard`
(или эквивалент) с проверкой owner/co-GM.
12. `DiscordWizardInteractionModule` — обработчики кнопок,
select-меню, модалов.
13. `DiscordNewSessionCommand` (text-only `/newsession`) — компилируется
и зарегистрирована (не сломана).
14. `deliverable.md` в корне репо — сводка + ссылка на PR/ветку.
## Adversarial прогоны (опционально, но желательно)
- Запусти `dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore`
и проверь warning list.
- Прочти 1-2 самых больших новых файла (DiscordWizardMessenger,
DiscordWizardInteractionModule) и найди потенциальные баги:
* Возможен NRE при `Context.User as GuildInteractionUser is null`?
* CustomId overflow (>100)?
* Modal interaction с истёкшим token — корректно ли?
* `GetOwnerClubsAsync` фильтрует по роли Owner/CoGm?
* Кэш контекста чистится на cancel?
## Критерии FAIL
- Любой тест красный.
- `dotnet build` падает.
- `dotnet format` ругается.
- Есть vulnerable HIGH/CRITICAL.
- Telegram.Bot в DiscordBot или NetCord в Shared.
- Reflection в AOT-критичном коде.
- `GameCreationWizard` дублируется.
- CustomId > 100 символов где-либо.
- `/newsession` text-only сломан.
- deliverable.md отсутствует или PR не открыт.
- В одном из файлов найдены серьёзные архитектурные косяки
(например, `Context.User as GuildInteractionUser` без null-проверки).
На FAIL укажи конкретный файл/строку/команду и обоснование.
timeout_ms: 1800000