2c9016a383
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.
518 lines
32 KiB
YAML
518 lines
32 KiB
YAML
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
|