refactor(wizard): move core to Shared, add IWizardMessenger contract (issue #112)
Moves the game-creation wizard state machine, view builder, and platform-neutral contracts (callback data, step names, storage exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared. Telegram continues to work through a new TelegramWizardMessenger implementing IWizardMessenger and a WizardInteractionMapper that converts Update → WizardInteraction. Wires the new platform column on wizard_drafts (V032 migration) and switches chat/owner/thread/message ids to TEXT so the same table can hold Discord snowflakes later. - GameCreationWizard: now in Shared, takes IWizardMessenger + IWizardDraftRepository, dispatches on WizardInteraction. - New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs (returns string ids so Telegram longs and Discord snowflakes both fit). - New WizardStepViewBuilder in Shared returns (text, IReadOnlyList<WizardAction>); TelegramWizardMessenger renders actions into InlineKeyboardMarkup via a new Bot-side ToInlineKeyboard helper. - New WizardInteractionMapper in Bot (5-case test) converts Telegram Update to WizardInteraction. - WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/ DraftMessageId switched to string. V032 migrates existing rows and rebuilds the owner lookup index on (platform, owner_id). - All existing wizard / create-session tests updated to the new contract (HandleInteractionAsync + WizardInteraction). Wizard callback-data format preserved. - dotnet build clean, dotnet format --verify-no-changes clean, all 101 wizard tests pass.
This commit is contained in:
+157
@@ -0,0 +1,157 @@
|
|||||||
|
# Issue #112 — Wizard platform-neutral refactor
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Moved the game-creation wizard's state machine and view builder from `GmRelay.Bot`
|
||||||
|
to `GmRelay.Shared`, replacing the Telegram-typed `ITelegramWizardMessenger` /
|
||||||
|
`Update` / `Message` surface with a platform-neutral `IWizardMessenger` /
|
||||||
|
`WizardInteraction` contract. Telegram continues to work unchanged through a
|
||||||
|
new `TelegramWizardMessenger` adapter and `WizardInteractionMapper`. The wizard
|
||||||
|
core is now ready for a future Discord adapter without touching the state
|
||||||
|
machine, and a `platform` column on `wizard_drafts` discriminates drafts from
|
||||||
|
different messengers.
|
||||||
|
|
||||||
|
## Branch
|
||||||
|
`feat/issue-112-wizard-refactor` (off `main`).
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
### New (Shared — platform-neutral wizard core)
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs`
|
||||||
|
— `IWizardMessenger` (Edit/Send/Answer/GetOwnerClubs), `WizardAction` +
|
||||||
|
`WizardActionStyle`, `WizardKeyboard`, `WizardClubOption`, `WizardInteraction`,
|
||||||
|
`IWizardDraftRepository` (now takes `platform, ownerId`).
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs`
|
||||||
|
— `MaxTitleLength`, `MaxDescriptionLength`, `MaxSystemLength`, capacity and
|
||||||
|
duration bounds (moved out of the Bot-only `WizardStep`).
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
|
||||||
|
— moved from Bot (unchanged content).
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs`
|
||||||
|
— moved from Bot (unchanged content; `wizard:cancel` / `wizard:back` /
|
||||||
|
`wizard:choice:step:value` format preserved for back-compat).
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs`
|
||||||
|
— moved from Bot (unchanged content).
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs`
|
||||||
|
— new platform-neutral view builder; produces
|
||||||
|
`(string Text, IReadOnlyList<WizardAction> Actions)` for each step.
|
||||||
|
Replaces the Telegram-only `WizardStep.Render(text, InlineKeyboardMarkup)`.
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
|
||||||
|
— moved from Bot; rewritten to take `IWizardMessenger` +
|
||||||
|
`IWizardDraftRepository` and a `WizardInteraction` (no more
|
||||||
|
`Update`/`Message`/`CallbackQuery`).
|
||||||
|
|
||||||
|
### Updated (Shared)
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs`
|
||||||
|
— `ChatId`, `MessageThreadId`, `OwnerId`, `DraftMessageId` switched to
|
||||||
|
`string?` to fit both Telegram and Discord ids; new `Platform` field
|
||||||
|
(defaults to `"Telegram"` for backward compatibility with pre-V032 rows).
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs`
|
||||||
|
— `GetActiveAsync(platform, ownerId)`; selects/inserts the new `platform`
|
||||||
|
column and the renamed `owner_id`.
|
||||||
|
- (Deleted) `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs`
|
||||||
|
— merged into `IWizardMessenger.cs` to keep wizard contracts colocated.
|
||||||
|
|
||||||
|
### New (Bot — Telegram adapter)
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs`
|
||||||
|
— converts `Telegram.Bot.Types.Update` → `WizardInteraction`. The single
|
||||||
|
bridge between Telegram's update type and the platform-neutral wizard.
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs`
|
||||||
|
— rewritten to implement `IWizardMessenger`; serialises
|
||||||
|
`(WizardDraft, text, IReadOnlyList<WizardAction>)` to Telegram
|
||||||
|
`EditMessageText` / `SendMessage` / `AnswerCallbackQuery`. Club lookup
|
||||||
|
SQL unchanged.
|
||||||
|
|
||||||
|
### Updated (Bot)
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs`
|
||||||
|
— kept as the Telegram-side keyboard renderer. Re-exports the
|
||||||
|
`WizardStepLimits` constants (so legacy call sites still work) and now
|
||||||
|
delegates to `WizardStepViewBuilder.Build(...)` for the (text, actions)
|
||||||
|
pair, then converts actions to `InlineKeyboardMarkup`.
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
|
||||||
|
— depends on `IWizardMessenger`; `StartWizardAsync` / `SubmitDraftAsync`
|
||||||
|
use the new messenger contract; sets `Platform = "Telegram"` on the
|
||||||
|
draft; uses string ids for `ChatId` / `MessageThreadId` / `OwnerId`.
|
||||||
|
- `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs` — takes the
|
||||||
|
Shared `GameCreationWizard`; uses `WizardInteractionMapper.TryMap` to
|
||||||
|
feed the wizard with a platform-neutral `WizardInteraction`. Draft
|
||||||
|
lookup uses `(platform, ownerId)`.
|
||||||
|
- `src/GmRelay.Bot/Program.cs` — DI: `IWizardMessenger` →
|
||||||
|
`TelegramWizardMessenger`; `GameCreationWizard` resolved from Shared.
|
||||||
|
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs`
|
||||||
|
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs`
|
||||||
|
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs`
|
||||||
|
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs`
|
||||||
|
- (Deleted) `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs`
|
||||||
|
|
||||||
|
### New (Bot migrations)
|
||||||
|
- `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
|
||||||
|
— adds `platform TEXT NOT NULL DEFAULT 'Telegram'`; converts `chat_id`,
|
||||||
|
`message_thread_id`, `draft_message_id` from numeric types to `TEXT`;
|
||||||
|
renames `owner_telegram_id` → `owner_id` and converts to `TEXT`; rebuilds
|
||||||
|
the owner lookup index to use `(platform, owner_id)`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs`
|
||||||
|
— `FakeWizardMessenger` now implements `IWizardMessenger`; `NewDraft`
|
||||||
|
sets `Platform = "Telegram"` and uses string ids; new helper
|
||||||
|
factories `CallbackInteraction`, `TextInteraction`, `PhotoInteraction`
|
||||||
|
build the platform-neutral `WizardInteraction`; legacy
|
||||||
|
`CallbackUpdate` / `TextUpdate` helpers preserved for router-level
|
||||||
|
tests that still consume `Update`.
|
||||||
|
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs`
|
||||||
|
— schema updated to TEXT columns + `platform` column.
|
||||||
|
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs`
|
||||||
|
— uses the new `GetActiveAsync(platform, ownerId)` signature and string
|
||||||
|
ids.
|
||||||
|
- All wizard / create-session test files updated to call
|
||||||
|
`wizard.HandleInteractionAsync(...)` with a `WizardInteraction` instead
|
||||||
|
of `wizard.HandleUpdateAsync(Update, …)`.
|
||||||
|
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs` (new)
|
||||||
|
— 5 cases covering callback / text / photo / captioned-photo / empty
|
||||||
|
updates.
|
||||||
|
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs`
|
||||||
|
— `NewDraft` updated to use string `ChatId`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `dotnet build` — full solution builds with 0 warnings, 0 errors.
|
||||||
|
- `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` —
|
||||||
|
568/571 pass (2 pre-existing `[Fact(Skip = …)]` happy-path tests
|
||||||
|
skipped, 1 pre-existing test failure in
|
||||||
|
`CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion`
|
||||||
|
which expects the old `v3.7.1` string in `NavMenu.razor` after the
|
||||||
|
3.8.0 release bump in commit `71080ae` — unrelated to this refactor).
|
||||||
|
All 101 wizard / create-session tests pass.
|
||||||
|
- `dotnet format --verify-no-changes` — clean.
|
||||||
|
- `git grep "Telegram.Bot" src/GmRelay.Shared/` — empty.
|
||||||
|
- `git grep "NetCord" src/GmRelay.Bot/` — empty.
|
||||||
|
- `GameCreationWizard.cs` exists exactly once, in
|
||||||
|
`src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/`.
|
||||||
|
- `IWizardMessenger` lives in `src/GmRelay.Shared/...`;
|
||||||
|
`ITelegramWizardMessenger` is gone.
|
||||||
|
- `dotnet list package --vulnerable` — clean.
|
||||||
|
|
||||||
|
## Notes for the verifier
|
||||||
|
|
||||||
|
- The callback-data wire format (`wizard:cancel`, `wizard:back`,
|
||||||
|
`wizard:create`, `wizard:choice:{step}:{value}`) is unchanged; the
|
||||||
|
router-level `wizard:resume` / `wizard:reset` controls are still
|
||||||
|
produced by `UpdateRouter`.
|
||||||
|
- `WizardDraft.DraftMessageId` switched from `long?` to `string?` so the
|
||||||
|
same column can hold Discord's 64-bit snowflakes. V032 converts
|
||||||
|
`BIGINT → TEXT`; existing rows in the V031 schema (if any are still
|
||||||
|
live) survive the cast.
|
||||||
|
- The `FakeWizardMessenger` keeps the long-typed recording shape
|
||||||
|
(`Edits` / `Sends` tuples) so the existing assertions in
|
||||||
|
`CreateSessionHandlerSubmitMissingFieldsTests` etc. keep working
|
||||||
|
without rewrites. The fake converts `draft.ChatId` /
|
||||||
|
`draft.MessageThreadId` to long on the way out, matching the old test
|
||||||
|
contract.
|
||||||
|
- The Telegram-renderer Bot-side `WizardStep` keeps the same public
|
||||||
|
surface (`WizardStep.Render(draft, payload, clubs)` returning
|
||||||
|
`(string, InlineKeyboardMarkup)`) so call sites in
|
||||||
|
`UpdateRouter.TryHandleDraftControlCallbackAsync` and the
|
||||||
|
`CreateSessionHandler` continue to work. The view layer behind it is
|
||||||
|
now `WizardStepViewBuilder`.
|
||||||
|
- A future `DiscordWizardMessenger` and `DiscordWizardInteractionMapper`
|
||||||
|
can be added without any change to the Shared wizard; this is
|
||||||
|
deliberately not part of this task (the plan calls it out as Task 2).
|
||||||
@@ -1,40 +1,41 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wizard-driven entry point for game-session creation. Replaces the legacy
|
/// Telegram-side entry point for the wizard-driven session creation
|
||||||
/// text-template parser. Exposes <see cref="StartWizardAsync"/> (called from
|
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
|
||||||
/// <c>/newsession</c>), <see cref="TryResumeAsync"/> (continue a draft), and
|
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
|
||||||
/// <see cref="SubmitDraftAsync"/> (finalize on "✅ Создать" callback).
|
/// platform glue (mapping <c>Message</c> to draft fields, rendering
|
||||||
|
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CreateSessionHandler
|
public sealed class CreateSessionHandler
|
||||||
{
|
{
|
||||||
private const int MaxRetries = 3;
|
private const int MaxRetries = 3;
|
||||||
|
private const string PlatformName = "Telegram";
|
||||||
|
|
||||||
private readonly IWizardDraftRepository _drafts;
|
private readonly IWizardDraftRepository _drafts;
|
||||||
private readonly SharedCreateSessionHandler _shared;
|
private readonly SharedCreateSessionHandler _shared;
|
||||||
private readonly ITelegramWizardMessenger _messenger;
|
private readonly IWizardMessenger _messenger;
|
||||||
private readonly ILogger<CreateSessionHandler> _log;
|
private readonly ILogger<CreateSessionHandler> _log;
|
||||||
|
|
||||||
public CreateSessionHandler(
|
public CreateSessionHandler(
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
SharedCreateSessionHandler shared,
|
SharedCreateSessionHandler shared,
|
||||||
ITelegramWizardMessenger messenger,
|
IWizardMessenger messenger,
|
||||||
ILogger<CreateSessionHandler> log)
|
ILogger<CreateSessionHandler> log)
|
||||||
{
|
{
|
||||||
_drafts = drafts;
|
_drafts = drafts;
|
||||||
@@ -44,14 +45,14 @@ public sealed class CreateSessionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for <c>/newsession</c>. If a non-expired draft already exists for
|
/// Entry point for <c>/newsession</c>. If a non-expired draft
|
||||||
/// this (chat, thread, owner), returns <c>null</c> so the caller can render a
|
/// already exists for this owner, returns <c>null</c> so the caller
|
||||||
/// "Continue / Start over / Cancel" menu.
|
/// can render a "Continue / Start over / Cancel" menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
|
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existing = await _drafts.GetActiveAsync(
|
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||||
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
|
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -60,9 +61,10 @@ public sealed class CreateSessionHandler
|
|||||||
var draft = new WizardDraft
|
var draft = new WizardDraft
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = message.Chat.Id,
|
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
MessageThreadId = message.MessageThreadId,
|
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
|
||||||
OwnerTelegramId = message.From?.Id ?? 0,
|
OwnerId = ownerId,
|
||||||
|
Platform = PlatformName,
|
||||||
Step = WizardStepNames.Type,
|
Step = WizardStepNames.Type,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
@@ -70,9 +72,8 @@ public sealed class CreateSessionHandler
|
|||||||
};
|
};
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
|
|
||||||
var (text, kb) = WizardStep.Render(draft, new WizardPayload());
|
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||||||
var msgId = await _messenger.SendGroupMessageAsync(
|
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||||
draft.ChatId, draft.MessageThreadId, text, kb, ct);
|
|
||||||
draft.DraftMessageId = msgId;
|
draft.DraftMessageId = msgId;
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
@@ -80,24 +81,27 @@ public sealed class CreateSessionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resume an existing draft — returns the draft row so the caller can re-render.
|
/// Resume an existing draft — returns the draft row so the caller
|
||||||
|
/// can re-render the resume/reset menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct) =>
|
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
|
||||||
_drafts.GetActiveAsync(
|
{
|
||||||
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
|
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finalize: build shared command(s), call the shared handler, edit the wizard message.
|
/// Finalize: build shared command(s), call the shared handler, edit
|
||||||
/// On failure, retry up to <see cref="MaxRetries"/> times before deleting the draft.
|
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
|
||||||
|
/// times before deleting the draft.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
|
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var payload = LoadPayload(draft);
|
var payload = LoadPayload(draft);
|
||||||
if (!IsComplete(payload, out var missing))
|
if (!IsComplete(payload, out var missing))
|
||||||
{
|
{
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
|
||||||
$"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +113,11 @@ public sealed class CreateSessionHandler
|
|||||||
await _shared.HandleAsync(cmd, ct);
|
await _shared.HandleAsync(cmd, ct);
|
||||||
}
|
}
|
||||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft,
|
||||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||||
EmptyKeyboard(), ct);
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -122,26 +127,29 @@ public sealed class CreateSessionHandler
|
|||||||
SavePayload(draft, payload);
|
SavePayload(draft, payload);
|
||||||
if (payload.RetryCount >= MaxRetries)
|
if (payload.RetryCount >= MaxRetries)
|
||||||
{
|
{
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft,
|
||||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||||||
EmptyKeyboard(), ct);
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft,
|
||||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||||
RetryCancelKeyboard(), ct);
|
RetryCancelActions(),
|
||||||
|
ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build shared commands ────────────────────────────────────────
|
// ── Build shared commands ────────────────────────────────────────
|
||||||
// The shared handler creates one session per scheduled time in a single transaction
|
// The shared handler creates one session per scheduled time in a
|
||||||
// and assigns the same batch_id to all of them. A wizard pool therefore produces ONE
|
// single transaction and assigns the same batch_id to all of them.
|
||||||
// command with N times; a single-game wizard produces ONE command with one time.
|
// A wizard pool therefore produces ONE command with N times; a
|
||||||
|
// single-game wizard produces ONE command with one time.
|
||||||
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
||||||
{
|
{
|
||||||
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||||||
@@ -153,7 +161,7 @@ public sealed class CreateSessionHandler
|
|||||||
p,
|
p,
|
||||||
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||||||
MaxPlayersForPool(pool),
|
MaxPlayersForPool(pool),
|
||||||
isOneShot: false)
|
isOneShot: false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return new List<CreateSessionCommand>
|
return new List<CreateSessionCommand>
|
||||||
@@ -163,7 +171,7 @@ public sealed class CreateSessionHandler
|
|||||||
p,
|
p,
|
||||||
new[] { p.Single?.ScheduledAt ?? default },
|
new[] { p.Single?.ScheduledAt ?? default },
|
||||||
p.Single?.MaxPlayers ?? 0,
|
p.Single?.MaxPlayers ?? 0,
|
||||||
isOneShot: true)
|
isOneShot: true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,18 +185,17 @@ public sealed class CreateSessionHandler
|
|||||||
int maxPlayers,
|
int maxPlayers,
|
||||||
bool isOneShot)
|
bool isOneShot)
|
||||||
{
|
{
|
||||||
var gmId = draft.OwnerTelegramId;
|
|
||||||
var user = new PlatformUser(
|
var user = new PlatformUser(
|
||||||
PlatformKind.Telegram,
|
PlatformKind.Telegram,
|
||||||
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
draft.OwnerId,
|
||||||
DisplayName: string.Empty,
|
DisplayName: string.Empty,
|
||||||
ExternalUsername: null);
|
ExternalUsername: null);
|
||||||
var group = new PlatformGroup(
|
var group = new PlatformGroup(
|
||||||
PlatformKind.Telegram,
|
PlatformKind.Telegram,
|
||||||
draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
draft.ChatId,
|
||||||
DisplayName: string.Empty,
|
DisplayName: string.Empty,
|
||||||
ExternalChannelId: null,
|
ExternalChannelId: null,
|
||||||
ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
ExternalThreadId: draft.MessageThreadId);
|
||||||
return new CreateSessionCommand(
|
return new CreateSessionCommand(
|
||||||
User: user,
|
User: user,
|
||||||
Group: group,
|
Group: group,
|
||||||
@@ -245,10 +252,9 @@ public sealed class CreateSessionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboards ────────────────────────────────────────────────────
|
// ── Keyboards ────────────────────────────────────────────────────
|
||||||
private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty<InlineKeyboardButton[]>());
|
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||||||
private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[]
|
|
||||||
{
|
{
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) },
|
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
|
|
||||||
public sealed record WizardClubOption(Guid ClubId, string Name);
|
|
||||||
|
|
||||||
public interface ITelegramWizardMessenger
|
|
||||||
{
|
|
||||||
Task<long> EditMessageTextAsync(long chatId, int? messageThreadId, long messageId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
|
|
||||||
Task<long> SendGroupMessageAsync(long chatId, int? messageThreadId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
|
|
||||||
Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct);
|
|
||||||
Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -3,48 +3,79 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
|
||||||
|
/// Translates the platform-neutral wizard contracts into the
|
||||||
|
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
|
||||||
|
/// (message editing, callback ack, group lookup) lives behind the
|
||||||
|
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
|
||||||
|
/// </summary>
|
||||||
public sealed class TelegramWizardMessenger(
|
public sealed class TelegramWizardMessenger(
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
NpgsqlDataSource dataSource) : ITelegramWizardMessenger
|
NpgsqlDataSource dataSource) : IWizardMessenger
|
||||||
{
|
{
|
||||||
public async Task<long> EditMessageTextAsync(
|
public async Task<string> EditDraftMessageAsync(
|
||||||
long chatId, int? messageThreadId, long messageId, string text,
|
WizardDraft draft,
|
||||||
InlineKeyboardMarkup keyboard, CancellationToken ct)
|
string text,
|
||||||
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||||
|
}
|
||||||
|
if (!TryParseMessageId(draft.DraftMessageId, out var messageId))
|
||||||
|
{
|
||||||
|
// No draft message recorded yet — fall back to sending a new one.
|
||||||
|
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||||
|
}
|
||||||
var msg = await bot.EditMessageText(
|
var msg = await bot.EditMessageText(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageId: (int)messageId,
|
messageId: messageId,
|
||||||
text: text,
|
text: text,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
return msg.MessageId;
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<long> SendGroupMessageAsync(
|
public async Task<string> SendDraftMessageAsync(
|
||||||
long chatId, int? messageThreadId, string text,
|
WizardDraft draft,
|
||||||
InlineKeyboardMarkup keyboard, CancellationToken ct)
|
string text,
|
||||||
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||||
|
}
|
||||||
|
int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread)
|
||||||
|
? parsedThread
|
||||||
|
: null;
|
||||||
|
|
||||||
var msg = await bot.SendMessage(
|
var msg = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
text: text,
|
text: text,
|
||||||
messageThreadId: messageThreadId,
|
messageThreadId: threadId,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
return msg.MessageId;
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
|
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(callbackId, text: text, cancellationToken: ct);
|
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
|
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Adjusted from the plan: this codebase models "clubs" as game_groups
|
// Adjusted from the plan: this codebase models "clubs" as game_groups
|
||||||
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
|
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
|
||||||
@@ -57,14 +88,49 @@ public sealed class TelegramWizardMessenger(
|
|||||||
FROM game_groups g
|
FROM game_groups g
|
||||||
JOIN group_managers gm ON gm.group_id = g.id
|
JOIN group_managers gm ON gm.group_id = g.id
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE p.platform = 'Telegram'
|
WHERE p.platform = @Platform
|
||||||
AND p.external_user_id = @ExternalId
|
AND p.external_user_id = @ExternalId
|
||||||
GROUP BY g.id, g.name
|
GROUP BY g.id, g.name
|
||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""";
|
""";
|
||||||
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, new { ExternalId = ownerTelegramId.ToString() }, cancellationToken: ct));
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new { Platform = "Telegram", ExternalId = ownerId },
|
||||||
|
cancellationToken: ct));
|
||||||
return rows.AsList();
|
return rows.AsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryParseChatId(string raw, out long chatId)
|
||||||
|
{
|
||||||
|
if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
chatId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseMessageId(string? raw, out int messageId)
|
||||||
|
{
|
||||||
|
if (raw is not null &&
|
||||||
|
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
messageId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseThreadId(string? raw, out int threadId)
|
||||||
|
{
|
||||||
|
if (raw is not null &&
|
||||||
|
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
threadId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Telegram <see cref="Update"/> into the
|
||||||
|
/// platform-neutral <see cref="WizardInteraction"/> consumed by
|
||||||
|
/// <see cref="GameCreationWizard"/>. The mapping is the only place in
|
||||||
|
/// the bot that knows about both <c>Telegram.Bot.Types</c> and the
|
||||||
|
/// shared wizard contract, so a future Discord adapter can do the same
|
||||||
|
/// for its native event without changing the wizard core.
|
||||||
|
/// </summary>
|
||||||
|
public static class WizardInteractionMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> if <paramref name="update"/> carries a
|
||||||
|
/// wizard-relevant interaction (text message, photo, or
|
||||||
|
/// callback). Side-effect-free: the wizard state is not touched.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryMap(Update update, out WizardInteraction interaction)
|
||||||
|
{
|
||||||
|
interaction = default!;
|
||||||
|
if (update.CallbackQuery is { } cb && cb.From is not null)
|
||||||
|
{
|
||||||
|
interaction = new WizardInteraction(
|
||||||
|
OwnerId: cb.From.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Text: null,
|
||||||
|
CallbackPayload: cb.Data,
|
||||||
|
PhotoFileId: null,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: cb.Id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.Message is { From: not null } msg)
|
||||||
|
{
|
||||||
|
// The original Telegram wizard dispatched on
|
||||||
|
// `msg.Text is null` to identify a non-text update (photo,
|
||||||
|
// document, sticker, …) and only ran the text pipeline
|
||||||
|
// otherwise. We preserve that semantic: a message that
|
||||||
|
// carries a photo is a photo interaction even if it has a
|
||||||
|
// caption. Text is null for photos; the wizard checks
|
||||||
|
// PhotoFileId separately when Text is null.
|
||||||
|
//
|
||||||
|
// Note: `Message.MessageId` is exposed as a read-only
|
||||||
|
// property in Telegram.Bot, so the mapper cannot embed the
|
||||||
|
// numeric id in the interaction. Text interactions never
|
||||||
|
// need an ack, so the InteractionId is unused for them —
|
||||||
|
// we just emit a stable sentinel.
|
||||||
|
var hasPhoto = msg.Photo is { Length: > 0 };
|
||||||
|
var text = hasPhoto ? null : msg.Text;
|
||||||
|
var photoFileId = hasPhoto ? msg.Photo![^1].FileId : null;
|
||||||
|
interaction = new WizardInteraction(
|
||||||
|
OwnerId: msg.From!.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Text: text,
|
||||||
|
CallbackPayload: null,
|
||||||
|
PhotoFileId: photoFileId,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: "msg");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,253 +1,65 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text;
|
using System.Linq;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram-side renderer for wizard keyboards. Acts as the adapter
|
||||||
|
/// between the platform-neutral <see cref="WizardAction"/> list
|
||||||
|
/// produced by <see cref="WizardStepViewBuilder"/> and Telegram's
|
||||||
|
/// <see cref="InlineKeyboardMarkup"/>. Each <see cref="WizardAction"/>
|
||||||
|
/// becomes its own row (matching the pre-refactor Telegram layout).
|
||||||
|
/// <see cref="WizardActionStyle"/> is currently ignored by Telegram
|
||||||
|
/// because the platform has no native primary/danger/success button
|
||||||
|
/// colours.
|
||||||
|
/// </summary>
|
||||||
public static class WizardStep
|
public static class WizardStep
|
||||||
{
|
{
|
||||||
public const int MaxTitleLength = 200;
|
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
|
||||||
public const int MaxDescriptionLength = 4000;
|
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
|
||||||
public const int MaxSystemLength = 100;
|
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
|
||||||
public const int MaxCapacity = 50;
|
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
|
||||||
public const int MinCapacity = 1;
|
public const int MinCapacity = WizardStepLimits.MinCapacity;
|
||||||
public const int MinDurationHours = 1;
|
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
|
||||||
public const int MaxDurationHours = 12;
|
public const int MaxDurationHours = WizardStepLimits.MaxDurationHours;
|
||||||
|
|
||||||
public static (string text, InlineKeyboardMarkup keyboard) Render(
|
/// <summary>
|
||||||
|
/// Render the platform-neutral view into a (text, Telegram keyboard)
|
||||||
|
/// pair. Used by the wizard's surrounding code (router, create
|
||||||
|
/// handler) when it needs to send a fresh draft message or render
|
||||||
|
/// the resume/reset menu.
|
||||||
|
/// </summary>
|
||||||
|
public static (string Text, InlineKeyboardMarkup Keyboard) Render(
|
||||||
WizardDraft draft,
|
WizardDraft draft,
|
||||||
WizardPayload payload,
|
WizardPayload payload,
|
||||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||||
{
|
{
|
||||||
return draft.Step switch
|
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||||
{
|
return (text, ToInlineKeyboard(actions));
|
||||||
WizardStepNames.Type => RenderType(),
|
|
||||||
WizardStepNames.Title => RenderTitle(),
|
|
||||||
WizardStepNames.Description => RenderDescription(),
|
|
||||||
WizardStepNames.Cover => RenderCover(),
|
|
||||||
WizardStepNames.System => RenderSystem(),
|
|
||||||
WizardStepNames.Duration => RenderDuration(),
|
|
||||||
WizardStepNames.DateTime => RenderDateTime(),
|
|
||||||
WizardStepNames.Capacity => RenderCapacity(),
|
|
||||||
WizardStepNames.Visibility => RenderVisibility(),
|
|
||||||
WizardStepNames.PickClub => RenderPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
|
||||||
WizardStepNames.Publish => RenderPublish(),
|
|
||||||
WizardStepNames.Confirm => RenderSingleConfirm(payload),
|
|
||||||
|
|
||||||
WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
|
|
||||||
WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
|
|
||||||
WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
|
|
||||||
WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
|
|
||||||
WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
|
|
||||||
|
|
||||||
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single-game renderers ──────────────────────────────────────────
|
/// <summary>
|
||||||
private static (string, InlineKeyboardMarkup) RenderType() => (
|
/// Convert a flat list of <see cref="WizardAction"/>s into a
|
||||||
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
|
/// Telegram keyboard. Each action is placed in its own row to
|
||||||
new InlineKeyboardMarkup(new[]
|
/// preserve the pre-refactor visual layout.
|
||||||
{
|
/// </summary>
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single")) },
|
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
}));
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderTitle() => (
|
|
||||||
"📝 Введите название игры одним сообщением.",
|
|
||||||
BackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderDescription() => (
|
|
||||||
"📄 Введите описание (или «-», чтобы пропустить).",
|
|
||||||
SkipBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderCover() => (
|
|
||||||
"🖼 Пришлите картинку как вложение или URL (или «-»).",
|
|
||||||
SkipBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderSystem()
|
|
||||||
{
|
{
|
||||||
var buttons = new List<InlineKeyboardButton[]>
|
if (actions.Count == 0)
|
||||||
{
|
{
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")) },
|
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")) },
|
|
||||||
};
|
|
||||||
return ("🎲 Выберите систему.", new InlineKeyboardMarkup(buttons).AppendBackCancel());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderDuration() => (
|
|
||||||
"⏱ Выберите длительность.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderDateTime() => (
|
|
||||||
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
|
||||||
BackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderCapacity() => (
|
|
||||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderVisibility() => (
|
|
||||||
"🔒 Выберите видимость.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPickClub(IReadOnlyList<WizardClubOption> clubs)
|
|
||||||
{
|
|
||||||
if (clubs.Count == 0)
|
|
||||||
{
|
|
||||||
return (
|
|
||||||
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
|
||||||
BackCancel());
|
|
||||||
}
|
}
|
||||||
var rows = new List<InlineKeyboardButton[]>();
|
var rows = new InlineKeyboardButton[actions.Count][];
|
||||||
foreach (var club in clubs)
|
for (var i = 0; i < actions.Count; i++)
|
||||||
{
|
{
|
||||||
rows.Add(new[]
|
rows[i] = new[]
|
||||||
{
|
{
|
||||||
InlineKeyboardButton.WithCallbackData(club.Name, WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString()))
|
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
return ("🏷 Выберите клуб:", new InlineKeyboardMarkup(rows).AppendBackCancel());
|
return new InlineKeyboardMarkup(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPublish() => (
|
|
||||||
"✨ Опубликовать в витрине сейчас?",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderSingleConfirm(WizardPayload p)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("👀 Проверьте перед созданием:");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"🎲 {p.Title}");
|
|
||||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
|
||||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
|
||||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
|
||||||
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
|
|
||||||
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
|
|
||||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
|
||||||
return (sb.ToString(), new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать", WizardCallbackData.Create()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pool renderers ─────────────────────────────────────────────────
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolSystemDuration() => (
|
|
||||||
"🎲 Выберите систему и длительность пула.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolAddSlots(WizardPayload p) => (
|
|
||||||
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolSlotDateTime() => (
|
|
||||||
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
|
||||||
BackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolSlotCapacity() => (
|
|
||||||
"👥 Введите лимит мест (1..50) и выберите waitlist.",
|
|
||||||
new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off")) },
|
|
||||||
}).AppendBackCancel());
|
|
||||||
|
|
||||||
private static (string, InlineKeyboardMarkup) RenderPoolConfirm(WizardPayload p)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("👀 Проверьте пул перед созданием:");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"📝 {p.Title}");
|
|
||||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
|
||||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
|
||||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
|
||||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
|
||||||
if (p.Pool is not null)
|
|
||||||
{
|
|
||||||
foreach (var s in p.Pool.Slots)
|
|
||||||
{
|
|
||||||
sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (sb.ToString(), new InlineKeyboardMarkup(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать пул", WizardCallbackData.Create()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
|
||||||
private static InlineKeyboardMarkup BackCancel() => new(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
});
|
|
||||||
|
|
||||||
private static InlineKeyboardMarkup SkipBackCancel() => new(new[]
|
|
||||||
{
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
|
|
||||||
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
|
||||||
});
|
|
||||||
|
|
||||||
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
|
||||||
{
|
|
||||||
WizardVisibility.Public => "публичная в общем showcase",
|
|
||||||
WizardVisibility.Club => "публичная в витрине клуба",
|
|
||||||
WizardVisibility.Members => "только для членов клуба",
|
|
||||||
_ => "не задана",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class InlineKeyboardMarkupExtensions
|
|
||||||
{
|
|
||||||
public static InlineKeyboardMarkup AppendBackCancel(this InlineKeyboardMarkup kb) => kb;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
|
|
||||||
public sealed class WizardStorageException : Exception
|
|
||||||
{
|
|
||||||
public WizardStorageException(string message, Exception inner) : base(message, inner) { }
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||||
|
using System.Globalization;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
@@ -16,6 +17,7 @@ using Telegram.Bot;
|
|||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.Enums;
|
using Telegram.Bot.Types.Enums;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ public sealed class UpdateRouter(
|
|||||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||||
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
BotRescheduleVoteHandler rescheduleVoteHandler,
|
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
GameCreationWizard wizard,
|
SharedWizard wizard,
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
@@ -47,9 +49,9 @@ public sealed class UpdateRouter(
|
|||||||
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
|
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
|
||||||
// (chat, thread, owner), every update routes to the wizard. The wizard is
|
// (chat, thread, owner), every update routes to the wizard. The wizard is
|
||||||
// responsible for both text input and callback handling.
|
// responsible for both text input and callback handling.
|
||||||
if (TryGetWizardContext(update, out var chatId, out var threadId, out var ownerId))
|
if (TryGetWizardContext(update, out _, out _, out var ownerId))
|
||||||
{
|
{
|
||||||
var draft = await drafts.GetActiveAsync(chatId, threadId, ownerId, ct);
|
var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct);
|
||||||
if (draft is not null)
|
if (draft is not null)
|
||||||
{
|
{
|
||||||
// Resume / Reset / Cancel menu callbacks live in the router because
|
// Resume / Reset / Cancel menu callbacks live in the router because
|
||||||
@@ -60,7 +62,10 @@ public sealed class UpdateRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(update, draft, ct);
|
if (WizardInteractionMapper.TryMap(update, out var interaction))
|
||||||
|
{
|
||||||
|
await wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||||
|
}
|
||||||
|
|
||||||
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
|
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
|
||||||
// acknowledges the callback; the actual session creation lives in
|
// acknowledges the callback; the actual session creation lives in
|
||||||
@@ -157,7 +162,7 @@ public sealed class UpdateRouter(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||||
GameCreationWizard.LoadPayload(draft);
|
SharedWizard.LoadPayload(draft);
|
||||||
|
|
||||||
internal static string GetCommandText(Message message)
|
internal static string GetCommandText(Message message)
|
||||||
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
||||||
@@ -166,30 +171,30 @@ public sealed class UpdateRouter(
|
|||||||
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
|
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
|
||||||
/// Returns false for updates that carry no usable origin (e.g. inline queries).
|
/// Returns false for updates that carry no usable origin (e.g. inline queries).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out long ownerId)
|
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId)
|
||||||
{
|
{
|
||||||
chatId = 0;
|
chatId = 0;
|
||||||
messageThreadId = null;
|
messageThreadId = null;
|
||||||
ownerId = 0;
|
ownerId = string.Empty;
|
||||||
|
|
||||||
switch (update)
|
switch (update)
|
||||||
{
|
{
|
||||||
case { Message: { From: not null, Chat: { } chat } msg }:
|
case { Message: { From: not null, Chat: { } chat } msg }:
|
||||||
chatId = chat.Id;
|
chatId = chat.Id;
|
||||||
messageThreadId = msg.MessageThreadId;
|
messageThreadId = msg.MessageThreadId;
|
||||||
ownerId = msg.From!.Id;
|
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
||||||
chatId = cbmChat.Id;
|
chatId = cbmChat.Id;
|
||||||
messageThreadId = cb.Message?.MessageThreadId;
|
messageThreadId = cb.Message?.MessageThreadId;
|
||||||
ownerId = cb.From!.Id;
|
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case { CallbackQuery: { From: not null } cb2 }:
|
case { CallbackQuery: { From: not null } cb2 }:
|
||||||
// Callback arrived without a message (e.g. from a Mini App). No chat
|
// Callback arrived without a message (e.g. from a Mini App). No chat
|
||||||
// context → wizard cannot run on this update.
|
// context → wizard cannot run on this update.
|
||||||
ownerId = cb2.From!.Id;
|
ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- V032: Platform-neutral wizard drafts (issue #112).
|
||||||
|
-- Adds the platform discriminator and switches owner/chat/thread/message
|
||||||
|
-- columns from numeric to TEXT so the same table can hold both Telegram
|
||||||
|
-- ids (long) and Discord snowflakes (ulong). All conversions are safe:
|
||||||
|
-- the affected columns are nullable except chat_id/owner_telegram_id
|
||||||
|
-- which we cast via TEXT.
|
||||||
|
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram';
|
||||||
|
|
||||||
|
-- Convert chat_id: BIGINT → TEXT. Existing rows hold Telegram chat ids
|
||||||
|
-- which convert losslessly to their decimal string form.
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN chat_id TYPE TEXT USING chat_id::TEXT;
|
||||||
|
|
||||||
|
-- Convert message_thread_id: INT (nullable) → TEXT (nullable).
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN message_thread_id TYPE TEXT USING message_thread_id::TEXT;
|
||||||
|
|
||||||
|
-- Convert draft_message_id: BIGINT (nullable) → TEXT (nullable).
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN draft_message_id TYPE TEXT USING draft_message_id::TEXT;
|
||||||
|
|
||||||
|
-- Rename owner_telegram_id → owner_id (now platform-agnostic) and
|
||||||
|
-- convert from BIGINT to TEXT.
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
RENAME COLUMN owner_telegram_id TO owner_id;
|
||||||
|
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN owner_id TYPE TEXT USING owner_id::TEXT;
|
||||||
|
|
||||||
|
-- Replace the old owner lookup index with one that uses the new column
|
||||||
|
-- names and the platform discriminator.
|
||||||
|
DROP INDEX IF EXISTS idx_wizard_drafts_owner;
|
||||||
|
|
||||||
|
CREATE INDEX idx_wizard_drafts_owner
|
||||||
|
ON wizard_drafts(platform, owner_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wizard_drafts_platform
|
||||||
|
ON wizard_drafts(platform);
|
||||||
@@ -73,8 +73,8 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.Create
|
|||||||
|
|
||||||
// Wizard services (issue #111)
|
// Wizard services (issue #111)
|
||||||
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
|
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
|
||||||
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
|
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
|
||||||
builder.Services.AddSingleton<GameCreationWizard>();
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
|
|||||||
+78
-61
@@ -3,25 +3,27 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Telegram.Bot.Types;
|
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central state machine for the game/pool creation wizard.
|
/// Central state machine for the game/pool creation wizard. Lives in
|
||||||
|
/// <c>GmRelay.Shared</c> so it can be driven from any platform
|
||||||
|
/// messenger. Platform-specific code (<c>Telegram.Bot</c>,
|
||||||
|
/// <c>NetCord</c>, …) lives in the corresponding adapter and converts
|
||||||
|
/// its native update type into a <see cref="WizardInteraction"/> before
|
||||||
|
/// calling <see cref="HandleInteractionAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GameCreationWizard
|
public sealed class GameCreationWizard
|
||||||
{
|
{
|
||||||
private readonly IWizardDraftRepository _drafts;
|
private readonly IWizardDraftRepository _drafts;
|
||||||
private readonly ITelegramWizardMessenger _messenger;
|
private readonly IWizardMessenger _messenger;
|
||||||
private readonly ILogger<GameCreationWizard> _log;
|
private readonly ILogger<GameCreationWizard> _log;
|
||||||
|
|
||||||
public GameCreationWizard(
|
public GameCreationWizard(
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
ITelegramWizardMessenger messenger,
|
IWizardMessenger messenger,
|
||||||
ILogger<GameCreationWizard> log)
|
ILogger<GameCreationWizard> log)
|
||||||
{
|
{
|
||||||
_drafts = drafts;
|
_drafts = drafts;
|
||||||
@@ -29,44 +31,61 @@ public sealed class GameCreationWizard
|
|||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Handle a text or callback update from the owning GM.</summary>
|
/// <summary>
|
||||||
public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct)
|
/// Handle a single user interaction with the wizard. Adapters should
|
||||||
|
/// map their native event (Telegram <c>Update</c>, Discord
|
||||||
|
/// interaction, …) into a <see cref="WizardInteraction"/> first.
|
||||||
|
/// </summary>
|
||||||
|
public async Task HandleInteractionAsync(
|
||||||
|
WizardInteraction interaction,
|
||||||
|
WizardDraft draft,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (update.CallbackQuery is { } cb)
|
if (interaction.CallbackPayload is not null)
|
||||||
{
|
{
|
||||||
await HandleCallbackAsync(draft, cb, ct);
|
await HandleCallbackAsync(draft, interaction, ct);
|
||||||
}
|
}
|
||||||
else if (update.Message is { } msg)
|
else
|
||||||
{
|
{
|
||||||
await HandleTextAsync(draft, msg, ct);
|
await HandleTextAsync(draft, interaction, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (WizardStorageException)
|
catch (WizardStorageException)
|
||||||
{
|
{
|
||||||
// Surface storage failure; do not crash the update loop.
|
if (interaction.CallbackPayload is not null)
|
||||||
if (update.CallbackQuery is { } cb2)
|
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct);
|
await _messenger.AnswerInteractionAsync(
|
||||||
|
interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id);
|
_log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id);
|
||||||
if (update.CallbackQuery is { } cb3)
|
if (interaction.CallbackPayload is not null)
|
||||||
{
|
{
|
||||||
try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); }
|
try
|
||||||
catch { /* swallow — we're already in error path */ }
|
{
|
||||||
|
await _messenger.AnswerInteractionAsync(
|
||||||
|
interaction.InteractionId, "⚠️ Ошибка", ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* swallow — we're already in error path */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCallbackAsync(WizardDraft draft, CallbackQuery cb, CancellationToken ct)
|
private async Task HandleCallbackAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
WizardInteraction interaction,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!WizardCallbackData.TryParse(cb.Data, out var action, out var step, out var choice))
|
if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice))
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(cb.Id, "Неизвестная команда", ct);
|
await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,37 +93,39 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
case "cancel":
|
case "cancel":
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
draft, "❌ Мастер отменён.", Array.Empty<WizardAction>(), ct);
|
||||||
"❌ Мастер отменён.", EmptyKeyboard, ct);
|
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||||
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "back":
|
case "back":
|
||||||
ApplyBack(draft, step);
|
ApplyBack(draft, step);
|
||||||
await PersistAndRenderAsync(draft, cb.Id, ct);
|
await PersistAndRenderAsync(draft, interaction.InteractionId, ct);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "create":
|
case "create":
|
||||||
// Routed by CreateSessionHandler, not here.
|
// Routed by the platform's CreateSessionHandler, not here.
|
||||||
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
|
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// For "Choice" callbacks, action == step.
|
// For "Choice" callbacks, action == step.
|
||||||
await ApplyChoiceAsync(draft, step, choice, cb.Id, ct);
|
await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleTextAsync(WizardDraft draft, Message msg, CancellationToken ct)
|
private async Task HandleTextAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
WizardInteraction interaction,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (msg.Text is not { } text)
|
if (interaction.Text is not { } text)
|
||||||
{
|
{
|
||||||
// Photo or other non-text — handle cover step only.
|
// Photo or other non-text — handle cover step only.
|
||||||
if (msg.Photo is { Length: > 0 } && draft.Step == WizardStepNames.Cover)
|
if (interaction.PhotoFileId is { } fileId &&
|
||||||
|
draft.Step == WizardStepNames.Cover)
|
||||||
{
|
{
|
||||||
var fileId = msg.Photo[^1].FileId;
|
|
||||||
ApplyCoverPhoto(draft, fileId);
|
ApplyCoverPhoto(draft, fileId);
|
||||||
await PersistAndRenderAsync(draft, null, ct);
|
await PersistAndRenderAsync(draft, null, ct);
|
||||||
}
|
}
|
||||||
@@ -113,13 +134,12 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
var (nextStep, error, payload) = ApplyText(draft, text);
|
var (nextStep, error, payload) = ApplyText(draft, text);
|
||||||
if (payload is { } p) SavePayload(draft, p);
|
if (payload is { } p) SavePayload(draft, p);
|
||||||
if (error is { } errMsg && draft.DraftMessageId is { } mid)
|
if (error is { } errMsg)
|
||||||
{
|
{
|
||||||
// Re-render the same step with ⚠️ prefix.
|
// Re-render the same step with ⚠️ prefix.
|
||||||
var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null);
|
var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft));
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft.ChatId, draft.MessageThreadId, mid,
|
draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct);
|
||||||
"⚠️ " + errMsg + "\n\n" + rendered, kb, ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +150,13 @@ public sealed class GameCreationWizard
|
|||||||
await PersistAndRenderAsync(draft, null, ct);
|
await PersistAndRenderAsync(draft, null, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct)
|
private async Task ApplyChoiceAsync(
|
||||||
|
WizardDraft draft, string step, string choice, string interactionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
|
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
|
||||||
if (error is { } err)
|
if (error is { } err)
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
|
await _messenger.AnswerInteractionAsync(interactionId, err, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload is { } p) SavePayload(draft, p);
|
if (payload is { } p) SavePayload(draft, p);
|
||||||
@@ -143,10 +164,10 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
draft.Step = s;
|
draft.Step = s;
|
||||||
}
|
}
|
||||||
await PersistAndRenderAsync(draft, callbackId, ct);
|
await PersistAndRenderAsync(draft, interactionId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PersistAndRenderAsync(WizardDraft draft, string? callbackId, CancellationToken ct)
|
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
@@ -154,15 +175,13 @@ public sealed class GameCreationWizard
|
|||||||
IReadOnlyList<WizardClubOption>? clubs = null;
|
IReadOnlyList<WizardClubOption>? clubs = null;
|
||||||
if (draft.Step == WizardStepNames.PickClub)
|
if (draft.Step == WizardStepNames.PickClub)
|
||||||
{
|
{
|
||||||
clubs = await _messenger.GetGmClubsAsync(draft.OwnerTelegramId, ct);
|
clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct);
|
||||||
}
|
}
|
||||||
var (text, kb) = WizardStep.Render(draft, payload, clubs);
|
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||||
await _messenger.EditMessageTextAsync(
|
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
|
||||||
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
|
if (interactionId is { } id)
|
||||||
text, kb, ct);
|
|
||||||
if (callbackId is { } id)
|
|
||||||
{
|
{
|
||||||
await _messenger.AnswerCallbackAsync(id, null, ct);
|
await _messenger.AnswerInteractionAsync(id, null, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,13 +192,13 @@ public sealed class GameCreationWizard
|
|||||||
switch (draft.Step)
|
switch (draft.Step)
|
||||||
{
|
{
|
||||||
case WizardStepNames.Title:
|
case WizardStepNames.Title:
|
||||||
return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
|
return ValidateText(input, WizardStepLimits.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
|
||||||
? (WizardStepNames.Description, SetTitle(payload, title), payload)
|
? (WizardStepNames.Description, SetTitle(payload, title), payload)
|
||||||
: (null, title, payload);
|
: (null, title, payload);
|
||||||
|
|
||||||
case WizardStepNames.Description:
|
case WizardStepNames.Description:
|
||||||
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
|
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
|
||||||
return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
|
return ValidateText(input, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
|
||||||
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
|
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
|
||||||
: (null, desc, payload);
|
: (null, desc, payload);
|
||||||
|
|
||||||
@@ -191,7 +210,7 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
case WizardStepNames.System when payload.System is null:
|
case WizardStepNames.System when payload.System is null:
|
||||||
// "Other" branch — only active if free-text was offered.
|
// "Other" branch — only active if free-text was offered.
|
||||||
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
|
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
|
||||||
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
|
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
|
||||||
: (null, sys, payload);
|
: (null, sys, payload);
|
||||||
|
|
||||||
@@ -206,12 +225,12 @@ public sealed class GameCreationWizard
|
|||||||
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||||
|
|
||||||
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
||||||
return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity
|
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
|
||||||
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
|
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
|
||||||
: (null, "Лимит должен быть 1..50", payload);
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
||||||
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
||||||
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
||||||
: (null, psys, payload);
|
: (null, psys, payload);
|
||||||
|
|
||||||
@@ -226,7 +245,7 @@ public sealed class GameCreationWizard
|
|||||||
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSlotCapacity:
|
case WizardStepNames.PoolSlotCapacity:
|
||||||
return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity
|
return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity
|
||||||
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
|
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
|
||||||
: (null, "Лимит должен быть 1..50", payload);
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
|
|
||||||
@@ -367,7 +386,7 @@ public sealed class GameCreationWizard
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Payload I/O ───────────────────────────────────────────────────
|
// ── Payload I/O ───────────────────────────────────────────────────
|
||||||
internal static WizardPayload LoadPayload(WizardDraft draft)
|
public static WizardPayload LoadPayload(WizardDraft draft)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
||||||
return System.Text.Json.JsonSerializer.Deserialize(
|
return System.Text.Json.JsonSerializer.Deserialize(
|
||||||
@@ -495,10 +514,8 @@ public sealed class GameCreationWizard
|
|||||||
if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
|
if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
|
||||||
if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
|
if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
|
||||||
if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false;
|
if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false;
|
||||||
if (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false;
|
if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false;
|
||||||
minutes = (int)Math.Round(hours * 60);
|
minutes = (int)Math.Round(hours * 60);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly InlineKeyboardMarkup EmptyKeyboard = new(Array.Empty<InlineKeyboardButton[]>());
|
|
||||||
}
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Storage contract for wizard drafts. Exists so the wizard can be unit-tested
|
|
||||||
/// against a hand-rolled fake (the concrete repository hits PostgreSQL via
|
|
||||||
/// Dapper.AOT and is therefore unsuitable for fast in-process tests).
|
|
||||||
/// </summary>
|
|
||||||
public interface IWizardDraftRepository
|
|
||||||
{
|
|
||||||
Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct);
|
|
||||||
|
|
||||||
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
|
|
||||||
|
|
||||||
Task DeleteAsync(Guid id, CancellationToken ct);
|
|
||||||
|
|
||||||
Task<int> DeleteExpiredAsync(CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visual style for a wizard button. The platform adapter maps this to its
|
||||||
|
/// own native styling (Telegram currently ignores it; Discord uses it for
|
||||||
|
/// primary/danger/success button colors).
|
||||||
|
/// </summary>
|
||||||
|
public enum WizardActionStyle
|
||||||
|
{
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
Success,
|
||||||
|
Danger,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single button on a wizard keyboard. <see cref="Payload"/> is the
|
||||||
|
/// platform-neutral callback token — usually produced by
|
||||||
|
/// <see cref="WizardCallbackData"/> but adapters are free to interpret
|
||||||
|
/// any string.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record WizardAction(
|
||||||
|
string Label,
|
||||||
|
string Payload,
|
||||||
|
WizardActionStyle Style = WizardActionStyle.Secondary);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row of buttons on a wizard keyboard. The platform adapter is
|
||||||
|
/// responsible for laying out rows; the wizard core returns a flat list
|
||||||
|
/// of actions and trusts the adapter to split them into rows.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record WizardKeyboard(IReadOnlyList<WizardAction> Actions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A user-owned group/club selectable from the visibility step. Moved
|
||||||
|
/// from <c>GmRelay.Bot</c> so the wizard can ask for the list without
|
||||||
|
/// taking a dependency on Telegram.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record WizardClubOption(Guid ClubId, string Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-neutral user interaction with the wizard. Adapters convert
|
||||||
|
/// their native event (Telegram <c>Update</c>, Discord interaction, …)
|
||||||
|
/// into one of these before handing it to <see cref="GameCreationWizard"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record WizardInteraction(
|
||||||
|
string OwnerId,
|
||||||
|
string? Text,
|
||||||
|
string? CallbackPayload,
|
||||||
|
string? PhotoFileId,
|
||||||
|
string? PhotoUrl,
|
||||||
|
string InteractionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage contract for wizard drafts. Exists so the wizard can be
|
||||||
|
/// unit-tested against a hand-rolled fake (the concrete repository hits
|
||||||
|
/// PostgreSQL via Dapper.AOT and is therefore unsuitable for fast
|
||||||
|
/// in-process tests).
|
||||||
|
/// </summary>
|
||||||
|
public interface IWizardDraftRepository
|
||||||
|
{
|
||||||
|
Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct);
|
||||||
|
|
||||||
|
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
|
||||||
|
|
||||||
|
Task DeleteAsync(Guid id, CancellationToken ct);
|
||||||
|
|
||||||
|
Task<int> DeleteExpiredAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contract the wizard core uses to talk to the chat platform. Each
|
||||||
|
/// platform supplies its own implementation (Telegram today, Discord in
|
||||||
|
/// a follow-up task).
|
||||||
|
/// </summary>
|
||||||
|
public interface IWizardMessenger
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Edit the message that currently represents the wizard draft.
|
||||||
|
/// Returns the new message id as a string — Telegram exposes
|
||||||
|
/// <c>int32</c>, Discord uses 64-bit snowflakes, both fit in
|
||||||
|
/// <see cref="string"/> for cross-platform uniformity.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> EditDraftMessageAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Post a fresh wizard draft message and return its id.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> SendDraftMessageAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledge a callback / interaction. <paramref name="text"/>
|
||||||
|
/// is an optional toast the user sees briefly.
|
||||||
|
/// </summary>
|
||||||
|
Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List the clubs/groups the owner manages. The platform
|
||||||
|
/// implementation decides how to query the database — the wizard
|
||||||
|
/// core only needs a list of (id, name) pairs.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct);
|
||||||
|
}
|
||||||
+11
-1
@@ -1,14 +1,24 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire format for wizard callback data. The format is shared by all
|
||||||
|
/// platforms (Telegram today, Discord in a follow-up task) and must
|
||||||
|
/// stay stable because it is persisted in chat histories and slash-command
|
||||||
|
/// autocomplete. Token is <c>wizard</c> to keep the namespace separate
|
||||||
|
/// from the rest of the bot's command callbacks.
|
||||||
|
/// </summary>
|
||||||
public static class WizardCallbackData
|
public static class WizardCallbackData
|
||||||
{
|
{
|
||||||
public const string Prefix = "wizard";
|
public const string Prefix = "wizard";
|
||||||
|
|
||||||
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
|
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
|
||||||
|
|
||||||
public static string Back() => $"{Prefix}:back";
|
public static string Back() => $"{Prefix}:back";
|
||||||
|
|
||||||
public static string Cancel() => $"{Prefix}:cancel";
|
public static string Cancel() => $"{Prefix}:cancel";
|
||||||
|
|
||||||
public static string Create() => $"{Prefix}:create";
|
public static string Create() => $"{Prefix}:create";
|
||||||
|
|
||||||
public static bool TryParse(string? data, out string action, out string step, out string choice)
|
public static bool TryParse(string? data, out string action, out string step, out string choice)
|
||||||
@@ -5,13 +5,47 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|||||||
public sealed class WizardDraft
|
public sealed class WizardDraft
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public long ChatId { get; set; }
|
|
||||||
public int? MessageThreadId { get; set; }
|
/// <summary>
|
||||||
public long OwnerTelegramId { get; set; }
|
/// Stable string id of the chat/guild/channel this draft lives in.
|
||||||
|
/// Stored as <c>TEXT</c> to fit both Telegram's <c>long</c> chat ids
|
||||||
|
/// and Discord's snowflakes.
|
||||||
|
/// </summary>
|
||||||
|
public string ChatId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional thread/topic id within the chat. Telegram's
|
||||||
|
/// <c>message_thread_id</c>, Discord's thread snowflake, <c>null</c>
|
||||||
|
/// when the chat has no sub-thread concept.
|
||||||
|
/// </summary>
|
||||||
|
public string? MessageThreadId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-specific user id of the wizard owner. Telegram uses
|
||||||
|
/// <c>long</c>, Discord uses snowflakes — both fit in a string.
|
||||||
|
/// </summary>
|
||||||
|
public string OwnerId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which messenger platform owns this draft. Defaults to
|
||||||
|
/// <c>"Telegram"</c> for backward compatibility with pre-V032 rows.
|
||||||
|
/// </summary>
|
||||||
|
public string Platform { get; set; } = "Telegram";
|
||||||
|
|
||||||
public string Step { get; set; } = string.Empty;
|
public string Step { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string PayloadJson { get; set; } = "{}";
|
public string PayloadJson { get; set; } = "{}";
|
||||||
public long? DraftMessageId { get; set; }
|
|
||||||
|
/// <summary>
|
||||||
|
/// Id of the message that the wizard last edited. Stored as
|
||||||
|
/// <c>TEXT</c> to fit both Telegram's <c>int32</c> ids and Discord's
|
||||||
|
/// 64-bit snowflakes.
|
||||||
|
/// </summary>
|
||||||
|
public string? DraftMessageId { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset UpdatedAt { get; set; }
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -8,14 +8,14 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|||||||
|
|
||||||
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
||||||
{
|
{
|
||||||
public async Task<WizardDraft?> GetActiveAsync(
|
public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||||
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT id AS Id,
|
SELECT id AS Id,
|
||||||
chat_id AS ChatId,
|
chat_id AS ChatId,
|
||||||
message_thread_id AS MessageThreadId,
|
message_thread_id AS MessageThreadId,
|
||||||
owner_telegram_id AS OwnerTelegramId,
|
owner_id AS OwnerId,
|
||||||
|
platform AS Platform,
|
||||||
step AS Step,
|
step AS Step,
|
||||||
payload::text AS PayloadJson,
|
payload::text AS PayloadJson,
|
||||||
draft_message_id AS DraftMessageId,
|
draft_message_id AS DraftMessageId,
|
||||||
@@ -23,17 +23,18 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
|||||||
updated_at AS UpdatedAt,
|
updated_at AS UpdatedAt,
|
||||||
expires_at AS ExpiresAt
|
expires_at AS ExpiresAt
|
||||||
FROM wizard_drafts
|
FROM wizard_drafts
|
||||||
WHERE chat_id = @ChatId
|
WHERE platform = @Platform
|
||||||
AND (message_thread_id = @ThreadId OR (@ThreadId IS NULL AND message_thread_id IS NULL))
|
AND owner_id = @OwnerId
|
||||||
AND owner_telegram_id = @OwnerId
|
|
||||||
AND expires_at > NOW()
|
AND expires_at > NOW()
|
||||||
|
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,
|
new CommandDefinition(
|
||||||
new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId },
|
sql,
|
||||||
|
new { Platform = platform, OwnerId = ownerId },
|
||||||
cancellationToken: ct));
|
cancellationToken: ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +42,9 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
|||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
INSERT INTO wizard_drafts
|
INSERT INTO wizard_drafts
|
||||||
(id, chat_id, message_thread_id, owner_telegram_id, step, payload, draft_message_id, created_at, updated_at, expires_at)
|
(id, chat_id, message_thread_id, owner_id, platform, step, payload, draft_message_id, created_at, updated_at, expires_at)
|
||||||
VALUES
|
VALUES
|
||||||
(@Id, @ChatId, @MessageThreadId, @OwnerTelegramId, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
|
(@Id, @ChatId, @MessageThreadId, @OwnerId, @Platform, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
|
||||||
ON CONFLICT (id) DO UPDATE
|
ON CONFLICT (id) DO UPDATE
|
||||||
SET step = EXCLUDED.step,
|
SET step = EXCLUDED.step,
|
||||||
payload = EXCLUDED.payload,
|
payload = EXCLUDED.payload,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Limits and bounds used by the wizard's input validation. Kept here
|
||||||
|
/// (rather than on the Telegram-only <c>WizardStep</c>) so the state
|
||||||
|
/// machine can reference them without pulling in a platform dependency.
|
||||||
|
/// </summary>
|
||||||
|
public static class WizardStepLimits
|
||||||
|
{
|
||||||
|
public const int MaxTitleLength = 200;
|
||||||
|
public const int MaxDescriptionLength = 4000;
|
||||||
|
public const int MaxSystemLength = 100;
|
||||||
|
public const int MaxCapacity = 50;
|
||||||
|
public const int MinCapacity = 1;
|
||||||
|
public const int MinDurationHours = 1;
|
||||||
|
public const int MaxDurationHours = 12;
|
||||||
|
}
|
||||||
+7
-1
@@ -1,5 +1,11 @@
|
|||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Symbolic step identifiers used by <see cref="WizardDraft.Step"/> and
|
||||||
|
/// the <see cref="WizardCallbackData"/> payload. Strings (rather than an
|
||||||
|
/// enum) so that future platforms can extend the set without breaking
|
||||||
|
/// the wire format stored in PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
public static class WizardStepNames
|
public static class WizardStepNames
|
||||||
{
|
{
|
||||||
public const string Type = "Type";
|
public const string Type = "Type";
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produces a (text, list of <see cref="WizardAction"/>s) pair for each
|
||||||
|
/// wizard step. This is the "view builder" half of ADR-002: the same
|
||||||
|
/// builder is used by every platform messenger, and each messenger is
|
||||||
|
/// responsible for converting the action list into its native UI
|
||||||
|
/// (Telegram's <c>InlineKeyboardMarkup</c> today, Discord components
|
||||||
|
/// later).
|
||||||
|
/// </summary>
|
||||||
|
public static class WizardStepViewBuilder
|
||||||
|
{
|
||||||
|
public static (string Text, IReadOnlyList<WizardAction> Actions) Build(
|
||||||
|
WizardDraft draft,
|
||||||
|
WizardPayload payload,
|
||||||
|
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||||
|
{
|
||||||
|
return draft.Step switch
|
||||||
|
{
|
||||||
|
WizardStepNames.Type => BuildType(),
|
||||||
|
WizardStepNames.Title => BuildTitle(),
|
||||||
|
WizardStepNames.Description => BuildDescription(),
|
||||||
|
WizardStepNames.Cover => BuildCover(),
|
||||||
|
WizardStepNames.System => BuildSystem(),
|
||||||
|
WizardStepNames.Duration => BuildDuration(),
|
||||||
|
WizardStepNames.DateTime => BuildDateTime(),
|
||||||
|
WizardStepNames.Capacity => BuildCapacity(),
|
||||||
|
WizardStepNames.Visibility => BuildVisibility(),
|
||||||
|
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
||||||
|
WizardStepNames.Publish => BuildPublish(),
|
||||||
|
WizardStepNames.Confirm => BuildSingleConfirm(payload),
|
||||||
|
|
||||||
|
WizardStepNames.PoolSystemDuration => BuildPoolSystemDuration(),
|
||||||
|
WizardStepNames.PoolAddSlots => BuildPoolAddSlots(payload),
|
||||||
|
WizardStepNames.PoolSlotDateTime => BuildPoolSlotDateTime(),
|
||||||
|
WizardStepNames.PoolSlotCapacity => BuildPoolSlotCapacity(),
|
||||||
|
WizardStepNames.PoolConfirm => BuildPoolConfirm(payload),
|
||||||
|
|
||||||
|
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single-game views ──────────────────────────────────────────────
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildType() => (
|
||||||
|
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new WizardAction("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single"), WizardActionStyle.Primary),
|
||||||
|
new WizardAction("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool"), WizardActionStyle.Primary),
|
||||||
|
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildTitle() => (
|
||||||
|
"📝 Введите название игры одним сообщением.",
|
||||||
|
BackCancel());
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildDescription() => (
|
||||||
|
"📄 Введите описание (или «-», чтобы пропустить).",
|
||||||
|
SkipBackCancel());
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildCover() => (
|
||||||
|
"🖼 Пришлите картинку как вложение или URL (или «-»).",
|
||||||
|
SkipBackCancel());
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildSystem() => (
|
||||||
|
"🎲 Выберите систему.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")),
|
||||||
|
new("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")),
|
||||||
|
new("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")),
|
||||||
|
new("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")),
|
||||||
|
new("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")),
|
||||||
|
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")),
|
||||||
|
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildDuration() => (
|
||||||
|
"⏱ Выберите длительность.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")),
|
||||||
|
new("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")),
|
||||||
|
new("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")),
|
||||||
|
new("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")),
|
||||||
|
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")),
|
||||||
|
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildDateTime() => (
|
||||||
|
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
||||||
|
BackCancel());
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||||
|
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||||
|
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||||
|
"🔒 Выберите видимость.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public"), WizardActionStyle.Primary),
|
||||||
|
new("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club"), WizardActionStyle.Primary),
|
||||||
|
new("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")),
|
||||||
|
new("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildPickClub(IReadOnlyList<WizardClubOption> clubs)
|
||||||
|
{
|
||||||
|
if (clubs.Count == 0)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
||||||
|
BackCancel());
|
||||||
|
}
|
||||||
|
var actions = new List<WizardAction>(clubs.Count);
|
||||||
|
foreach (var club in clubs)
|
||||||
|
{
|
||||||
|
actions.Add(new WizardAction(
|
||||||
|
club.Name,
|
||||||
|
WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString())));
|
||||||
|
}
|
||||||
|
return ("🏷 Выберите клуб:", actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildPublish() => (
|
||||||
|
"✨ Опубликовать в витрине сейчас?",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes"), WizardActionStyle.Success),
|
||||||
|
new("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildSingleConfirm(WizardPayload p)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("👀 Проверьте перед созданием:");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"🎲 {p.Title}");
|
||||||
|
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||||
|
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||||
|
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||||
|
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
|
||||||
|
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
|
||||||
|
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||||
|
return (
|
||||||
|
sb.ToString(),
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("✅ Создать", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||||
|
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||||
|
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pool views ─────────────────────────────────────────────────────
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildPoolSystemDuration() => (
|
||||||
|
"🎲 Выберите систему и длительность пула.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")),
|
||||||
|
new("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")),
|
||||||
|
new("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")),
|
||||||
|
new("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")),
|
||||||
|
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildPoolAddSlots(WizardPayload p) => (
|
||||||
|
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), WizardActionStyle.Primary),
|
||||||
|
new("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), WizardActionStyle.Success),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotDateTime() => (
|
||||||
|
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
||||||
|
BackCancel());
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotCapacity() => (
|
||||||
|
"👥 Введите лимит мест (1..50) и выберите waitlist.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"), WizardActionStyle.Success),
|
||||||
|
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildPoolConfirm(WizardPayload p)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("👀 Проверьте пул перед созданием:");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"📝 {p.Title}");
|
||||||
|
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||||
|
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||||
|
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||||
|
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
||||||
|
if (p.Pool is not null)
|
||||||
|
{
|
||||||
|
foreach (var s in p.Pool.Slots)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
sb.ToString(),
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("✅ Создать пул", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||||
|
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||||
|
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
private static IReadOnlyList<WizardAction> BackCancel() => new[]
|
||||||
|
{
|
||||||
|
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||||
|
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyList<WizardAction> SkipBackCancel() => new[]
|
||||||
|
{
|
||||||
|
new WizardAction("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")),
|
||||||
|
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||||
|
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
||||||
|
{
|
||||||
|
WizardVisibility.Public => "публичная в общем showcase",
|
||||||
|
WizardVisibility.Club => "публичная в витрине клуба",
|
||||||
|
WizardVisibility.Members => "только для членов клуба",
|
||||||
|
_ => "не задана",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the wizard's persistence layer fails. The wizard catches
|
||||||
|
/// this specifically so the user sees a friendly message instead of a
|
||||||
|
/// raw stack trace.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WizardStorageException : Exception
|
||||||
|
{
|
||||||
|
public WizardStorageException(string message, Exception inner)
|
||||||
|
: base(message, inner)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
@@ -39,7 +38,7 @@ public sealed class CreateSessionHandlerSubmitMissingFieldsTests
|
|||||||
// The wizard message is edited to surface the missing-field error.
|
// The wizard message is edited to surface the missing-field error.
|
||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
var edit = messenger.Edits[0];
|
var edit = messenger.Edits[0];
|
||||||
Assert.Equal(draft.ChatId, edit.ChatId);
|
Assert.Equal(long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture), edit.ChatId);
|
||||||
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|||||||
+7
-8
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Cancel();
|
var data = WizardCallbackData.Cancel();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Contains(draft.Id, drafts.DeletedIds);
|
Assert.Contains(draft.Id, drafts.DeletedIds);
|
||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
@@ -36,7 +35,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
// Title is the first step, so Back is a no-op.
|
// Title is the first step, so Back is a no-op.
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
@@ -51,7 +50,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Description, draft.Step);
|
Assert.Equal(WizardStepNames.Description, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -79,7 +78,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -93,7 +92,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
|
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -108,7 +107,7 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Create();
|
var data = WizardCallbackData.Create();
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Confirm, draft.Step);
|
Assert.Equal(WizardStepNames.Confirm, draft.Step);
|
||||||
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
|
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
|
||||||
|
|||||||
+13
-14
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
@@ -7,8 +6,8 @@ using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTest
|
|||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the pool-specific branch of the wizard: the AddSlots flow that
|
/// Verifies the pool-specific branch of the wizard: the AddSlots flow
|
||||||
/// builds up slot metadata through date and capacity steps.
|
/// that builds up slot metadata through date and capacity steps.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GameCreationWizardPoolSlotTests
|
public sealed class GameCreationWizardPoolSlotTests
|
||||||
{
|
{
|
||||||
@@ -28,7 +27,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
|
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(addData, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -49,7 +48,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
|
|
||||||
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
|
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
|
||||||
var dtString = future.ToString("dd.MM.yyyy HH:mm");
|
var dtString = future.ToString("dd.MM.yyyy HH:mm");
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(dtString, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
|
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -61,7 +60,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
|
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -74,7 +73,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
|
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(noWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -87,7 +86,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
|
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(yesWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -107,7 +106,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -150,10 +149,10 @@ public sealed class GameCreationWizardPoolSlotTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
// "add" then "done" — no date/capacity supplied in between.
|
// "add" then "done" — no date/capacity supplied in between.
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(
|
await wizard.HandleInteractionAsync(CallbackInteraction(
|
||||||
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None);
|
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(
|
await wizard.HandleInteractionAsync(CallbackInteraction(
|
||||||
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None);
|
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
// The wizard sees the in-memory slot count > 0 and advances to confirm.
|
// The wizard sees the in-memory slot count > 0 and advances to confirm.
|
||||||
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
||||||
|
|||||||
+5
-6
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(fromStep, choice);
|
var data = WizardCallbackData.Choice(fromStep, choice);
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(expectedStep, draft.Step);
|
Assert.Equal(expectedStep, draft.Step);
|
||||||
Assert.NotEmpty(drafts.Upserts); // was persisted
|
Assert.NotEmpty(drafts.Upserts); // was persisted
|
||||||
@@ -60,7 +59,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
@@ -84,7 +83,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -110,7 +109,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
|
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
|
||||||
Assert.NotEmpty(messenger.Edits);
|
Assert.NotEmpty(messenger.Edits);
|
||||||
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
|
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
|
||||||
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PickClub, draft.Step);
|
Assert.Equal(WizardStepNames.PickClub, draft.Step);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-13
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Title);
|
var draft = NewDraft(WizardStepNames.Title);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -32,8 +31,8 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Title);
|
var draft = NewDraft(WizardStepNames.Title);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var tooLong = new string('a', WizardStep.MaxTitleLength + 1);
|
var tooLong = new string('a', WizardStepLimits.MaxTitleLength + 1);
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Title, draft.Step);
|
Assert.Equal(WizardStepNames.Title, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -53,7 +52,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
// 2020-01-01 is firmly in the past
|
// 2020-01-01 is firmly in the past
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.DateTime);
|
var draft = NewDraft(WizardStepNames.DateTime);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
Assert.Equal(WizardStepNames.DateTime, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -83,7 +82,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Cover, payload);
|
var draft = NewDraft(WizardStepNames.Cover, payload);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -96,7 +95,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -109,7 +108,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
});
|
});
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -148,7 +147,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -161,7 +160,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
Assert.Equal(WizardStepNames.Cover, draft.Step);
|
||||||
}
|
}
|
||||||
@@ -178,7 +177,7 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -30,7 +30,7 @@ public sealed class UpdateRouterDelegationTests
|
|||||||
var draft = NewDraft(WizardStepNames.Title);
|
var draft = NewDraft(WizardStepNames.Title);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId);
|
var update = TextUpdate("Curse of Strahd", ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
await sut.RouteAsync(update, CancellationToken.None);
|
await sut.RouteAsync(update, CancellationToken.None);
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ public sealed class UpdateRouterDelegationTests
|
|||||||
// "wizard:cancel" — wizard owns the cancel callback. The router
|
// "wizard:cancel" — wizard owns the cancel callback. The router
|
||||||
// delegates control-callbacks (resume/reset) but lets the wizard
|
// delegates control-callbacks (resume/reset) but lets the wizard
|
||||||
// handle wizard:* callbacks.
|
// handle wizard:* callbacks.
|
||||||
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId);
|
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
await sut.RouteAsync(update, CancellationToken.None);
|
await sut.RouteAsync(update, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -36,8 +36,8 @@ public sealed class UpdateRouterResetsDraftOnStaleCommandTests
|
|||||||
Message = new Message
|
Message = new Message
|
||||||
{
|
{
|
||||||
Text = "/newsession",
|
Text = "/newsession",
|
||||||
Chat = new Chat { Id = draft.ChatId },
|
Chat = new Chat { Id = long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture) },
|
||||||
From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" },
|
From = new User { Id = long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture), FirstName = "GM" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -49,22 +49,23 @@ public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
|
|||||||
"""
|
"""
|
||||||
CREATE TABLE wizard_drafts (
|
CREATE TABLE wizard_drafts (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
chat_id BIGINT NOT NULL,
|
chat_id TEXT NOT NULL,
|
||||||
message_thread_id INT,
|
message_thread_id TEXT,
|
||||||
owner_telegram_id BIGINT NOT NULL,
|
owner_id TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'Telegram',
|
||||||
step TEXT NOT NULL,
|
step TEXT NOT NULL,
|
||||||
payload JSONB NOT NULL,
|
payload JSONB NOT NULL,
|
||||||
draft_message_id BIGINT,
|
draft_message_id TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL,
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
expires_at TIMESTAMPTZ NOT NULL
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_wizard_drafts_owner
|
CREATE INDEX idx_wizard_drafts_owner
|
||||||
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
|
ON wizard_drafts(platform, owner_id);
|
||||||
|
|
||||||
CREATE INDEX idx_wizard_drafts_expires
|
CREATE INDEX idx_wizard_drafts_platform
|
||||||
ON wizard_drafts(expires_at);
|
ON wizard_drafts(platform);
|
||||||
""",
|
""",
|
||||||
connection);
|
connection);
|
||||||
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||||
|
|||||||
+12
-6
@@ -1,3 +1,6 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
@@ -20,7 +23,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
|
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
|
||||||
Assert.NotNull(loaded);
|
Assert.NotNull(loaded);
|
||||||
Assert.Equal("Title", loaded!.Step);
|
Assert.Equal("Title", loaded!.Step);
|
||||||
}
|
}
|
||||||
@@ -35,7 +38,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
|
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
|
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
|
||||||
Assert.Null(loaded);
|
Assert.Null(loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +52,9 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
|
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, ownerTelegramId: draft.OwnerTelegramId + 1, CancellationToken.None);
|
var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1)
|
||||||
|
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var loaded = await sut.GetActiveAsync(draft.Platform, otherOwner, CancellationToken.None);
|
||||||
Assert.Null(loaded);
|
Assert.Null(loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +74,17 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
|
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
|
||||||
Assert.Equal(1, deleted);
|
Assert.Equal(1, deleted);
|
||||||
|
|
||||||
var loadedFresh = await sut.GetActiveAsync(fresh.ChatId, fresh.MessageThreadId, fresh.OwnerTelegramId, CancellationToken.None);
|
var loadedFresh = await sut.GetActiveAsync(fresh.Platform, fresh.OwnerId, CancellationToken.None);
|
||||||
Assert.NotNull(loadedFresh);
|
Assert.NotNull(loadedFresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
|
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = 42,
|
ChatId = "42",
|
||||||
MessageThreadId = null,
|
MessageThreadId = null,
|
||||||
OwnerTelegramId = 100,
|
OwnerId = "100",
|
||||||
|
Platform = "Telegram",
|
||||||
Step = step,
|
Step = step,
|
||||||
PayloadJson = "{}",
|
PayloadJson = "{}",
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the Telegram <c>Update</c> → <c>WizardInteraction</c> mapping
|
||||||
|
/// that <see cref="WizardInteractionMapper"/> exposes. The mapper is the
|
||||||
|
/// single bridge between Telegram's native update type and the
|
||||||
|
/// platform-neutral wizard core, so its contract needs to be locked
|
||||||
|
/// down: callback queries carry the data payload, text messages carry
|
||||||
|
/// their text, and photos carry the largest photo's <c>FileId</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WizardInteractionMapperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CallbackUpdate_ProducesCallbackInteraction_WithPayloadAndOwner()
|
||||||
|
{
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
CallbackQuery = new CallbackQuery
|
||||||
|
{
|
||||||
|
Id = "cb-42",
|
||||||
|
Data = "wizard:choice:Type:single",
|
||||||
|
From = new User { Id = 100, FirstName = "GM" },
|
||||||
|
Message = new Message { Chat = new Chat { Id = 42 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("100", interaction.OwnerId);
|
||||||
|
Assert.Null(interaction.Text);
|
||||||
|
Assert.Equal("wizard:choice:Type:single", interaction.CallbackPayload);
|
||||||
|
Assert.Null(interaction.PhotoFileId);
|
||||||
|
Assert.Null(interaction.PhotoUrl);
|
||||||
|
Assert.Equal("cb-42", interaction.InteractionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TextUpdate_ProducesTextInteraction_WithTextAndNoCallback()
|
||||||
|
{
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
Text = "My Game Title",
|
||||||
|
Chat = new Chat { Id = 42 },
|
||||||
|
From = new User { Id = 200, FirstName = "GM" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("200", interaction.OwnerId);
|
||||||
|
Assert.Equal("My Game Title", interaction.Text);
|
||||||
|
Assert.Null(interaction.CallbackPayload);
|
||||||
|
Assert.Null(interaction.PhotoFileId);
|
||||||
|
Assert.Equal("msg", interaction.InteractionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PhotoUpdate_ProducesPhotoInteraction_WithLargestFileId()
|
||||||
|
{
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
Chat = new Chat { Id = 42 },
|
||||||
|
From = new User { Id = 300, FirstName = "GM" },
|
||||||
|
Photo = new[]
|
||||||
|
{
|
||||||
|
new PhotoSize { FileId = "small-id", Width = 90, Height = 60 },
|
||||||
|
new PhotoSize { FileId = "medium-id", Width = 320, Height = 240 },
|
||||||
|
new PhotoSize { FileId = "large-id", Width = 800, Height = 600 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("300", interaction.OwnerId);
|
||||||
|
Assert.Null(interaction.Text);
|
||||||
|
Assert.Null(interaction.CallbackPayload);
|
||||||
|
Assert.Equal("large-id", interaction.PhotoFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CaptionedPhoto_ProducesPhotoInteraction_AndKeepsCaptionOutOfText()
|
||||||
|
{
|
||||||
|
// Telegram sometimes attaches a caption to a photo message. The
|
||||||
|
// mapper treats it as a non-text interaction (cover-step uses
|
||||||
|
// PhotoFileId, not caption). This test pins that distinction.
|
||||||
|
var update = new Update
|
||||||
|
{
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
Caption = "ignored",
|
||||||
|
Chat = new Chat { Id = 42 },
|
||||||
|
From = new User { Id = 400 },
|
||||||
|
Photo = new[]
|
||||||
|
{
|
||||||
|
new PhotoSize { FileId = "only-id", Width = 100, Height = 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("only-id", interaction.PhotoFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyUpdate_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var ok = WizardInteractionMapper.TryMap(new Update(), out var interaction);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.Null(interaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -248,7 +248,7 @@ public sealed class WizardStepRenderTests
|
|||||||
private static WizardDraft NewDraft(string step) => new()
|
private static WizardDraft NewDraft(string step) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = 42,
|
ChatId = "42",
|
||||||
Step = step,
|
Step = step,
|
||||||
PayloadJson = "{}",
|
PayloadJson = "{}",
|
||||||
};
|
};
|
||||||
|
|||||||
+112
-38
@@ -1,25 +1,26 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
using WizardBot = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||||
using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hand-rolled test doubles and helpers for wizard unit tests. The project
|
/// Hand-rolled test doubles and helpers for wizard unit tests. The
|
||||||
/// convention is to use fakes (not a mocking framework) so the suite stays
|
/// project convention is to use fakes (not a mocking framework) so the
|
||||||
/// AOT-friendly and the production code doesn't grow virtual members just
|
/// suite stays AOT-friendly and the production code doesn't grow
|
||||||
/// for tests.
|
/// virtual members just for tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class WizardTestFakes
|
internal static class WizardTestFakes
|
||||||
{
|
{
|
||||||
|
public const string PlatformName = "Telegram";
|
||||||
|
|
||||||
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
|
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
|
||||||
{
|
{
|
||||||
drafts = new FakeWizardDraftRepository();
|
drafts = new FakeWizardDraftRepository();
|
||||||
@@ -30,11 +31,12 @@ internal static class WizardTestFakes
|
|||||||
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
|
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = 42,
|
ChatId = "42",
|
||||||
MessageThreadId = null,
|
MessageThreadId = null,
|
||||||
OwnerTelegramId = ownerId,
|
OwnerId = ownerId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Platform = PlatformName,
|
||||||
Step = step,
|
Step = step,
|
||||||
DraftMessageId = 7,
|
DraftMessageId = "7",
|
||||||
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||||
payload ?? new WizardPayload(),
|
payload ?? new WizardPayload(),
|
||||||
WizardPayloadJsonContext.Default.WizardPayload),
|
WizardPayloadJsonContext.Default.WizardPayload),
|
||||||
@@ -43,6 +45,63 @@ internal static class WizardTestFakes
|
|||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the platform-neutral <see cref="WizardInteraction"/> the
|
||||||
|
/// wizard now consumes. Pre-V112 callers passed
|
||||||
|
/// <c>Telegram.Bot.Types.Update</c> directly; tests now build the
|
||||||
|
/// neutral interaction via the same mapper the production code uses.
|
||||||
|
/// </summary>
|
||||||
|
public static WizardInteraction CallbackInteraction(
|
||||||
|
string data, string ownerId = "100", string callbackId = "cb-1")
|
||||||
|
{
|
||||||
|
return new WizardInteraction(
|
||||||
|
OwnerId: ownerId,
|
||||||
|
Text: null,
|
||||||
|
CallbackPayload: data,
|
||||||
|
PhotoFileId: null,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: callbackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a text-style <see cref="WizardInteraction"/> mirroring what
|
||||||
|
/// <c>WizardInteractionMapper</c> would produce for a Telegram text
|
||||||
|
/// message.
|
||||||
|
/// </summary>
|
||||||
|
public static WizardInteraction TextInteraction(
|
||||||
|
string text, string ownerId = "100", int messageId = 1)
|
||||||
|
{
|
||||||
|
return new WizardInteraction(
|
||||||
|
OwnerId: ownerId,
|
||||||
|
Text: text,
|
||||||
|
CallbackPayload: null,
|
||||||
|
PhotoFileId: null,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: $"msg-{messageId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a photo-style <see cref="WizardInteraction"/> mirroring
|
||||||
|
/// what <c>WizardInteractionMapper</c> would produce for a Telegram
|
||||||
|
/// photo message.
|
||||||
|
/// </summary>
|
||||||
|
public static WizardInteraction PhotoInteraction(
|
||||||
|
string fileId, string ownerId = "100", int messageId = 1)
|
||||||
|
{
|
||||||
|
return new WizardInteraction(
|
||||||
|
OwnerId: ownerId,
|
||||||
|
Text: null,
|
||||||
|
CallbackPayload: null,
|
||||||
|
PhotoFileId: fileId,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: $"msg-{messageId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a Telegram <see cref="Update"/> carrying a callback query.
|
||||||
|
/// Used by router-level tests that exercise
|
||||||
|
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
|
||||||
|
/// </summary>
|
||||||
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
|
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
|
||||||
{
|
{
|
||||||
CallbackQuery = new CallbackQuery
|
CallbackQuery = new CallbackQuery
|
||||||
@@ -57,6 +116,11 @@ internal static class WizardTestFakes
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a Telegram <see cref="Update"/> carrying a text message.
|
||||||
|
/// Used by router-level tests that exercise
|
||||||
|
/// <c>UpdateRouter.RouteAsync</c> end-to-end.
|
||||||
|
/// </summary>
|
||||||
public static Update TextUpdate(string text, long ownerId = 100) => new()
|
public static Update TextUpdate(string text, long ownerId = 100) => new()
|
||||||
{
|
{
|
||||||
Message = new Message
|
Message = new Message
|
||||||
@@ -69,9 +133,9 @@ internal static class WizardTestFakes
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records every call the wizard makes against the draft repository. Backed by
|
/// Records every call the wizard makes against the draft repository.
|
||||||
/// an in-memory dictionary so tests can pre-seed an "active" draft for the
|
/// Backed by an in-memory dictionary so tests can pre-seed an "active"
|
||||||
/// wizard to mutate.
|
/// draft for the wizard to mutate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
||||||
{
|
{
|
||||||
@@ -85,13 +149,12 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
|||||||
|
|
||||||
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
|
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
|
||||||
|
|
||||||
public Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
|
public Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
foreach (var d in store.Values)
|
foreach (var d in store.Values)
|
||||||
{
|
{
|
||||||
if (d.ChatId == chatId &&
|
if (d.Platform == platform &&
|
||||||
d.MessageThreadId == messageThreadId &&
|
d.OwnerId == ownerId &&
|
||||||
d.OwnerTelegramId == ownerTelegramId &&
|
|
||||||
d.ExpiresAt > DateTimeOffset.UtcNow)
|
d.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
{
|
{
|
||||||
return Task.FromResult<WizardDraft?>(d);
|
return Task.FromResult<WizardDraft?>(d);
|
||||||
@@ -108,7 +171,8 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
|||||||
Id = draft.Id,
|
Id = draft.Id,
|
||||||
ChatId = draft.ChatId,
|
ChatId = draft.ChatId,
|
||||||
MessageThreadId = draft.MessageThreadId,
|
MessageThreadId = draft.MessageThreadId,
|
||||||
OwnerTelegramId = draft.OwnerTelegramId,
|
OwnerId = draft.OwnerId,
|
||||||
|
Platform = draft.Platform,
|
||||||
Step = draft.Step,
|
Step = draft.Step,
|
||||||
PayloadJson = draft.PayloadJson,
|
PayloadJson = draft.PayloadJson,
|
||||||
DraftMessageId = draft.DraftMessageId,
|
DraftMessageId = draft.DraftMessageId,
|
||||||
@@ -136,11 +200,14 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records every call the wizard makes against the messenger. Default return
|
/// Records every call the wizard makes against the messenger. Default
|
||||||
/// values (empty clubs, message-id 1) match what the wizard expects to see
|
/// return values (empty clubs, message-id 99) match what the wizard
|
||||||
/// in steady state.
|
/// expects to see in steady state. The recorded tuple shapes match
|
||||||
|
/// the old <c>ITelegramWizardMessenger</c> recorders so existing test
|
||||||
|
/// assertions (<c>edit.ChatId</c>, <c>edit.Text</c>, …) keep working
|
||||||
|
/// after the refactor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
|
internal sealed class FakeWizardMessenger : IWizardMessenger
|
||||||
{
|
{
|
||||||
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
|
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
|
||||||
|
|
||||||
@@ -148,37 +215,44 @@ internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
|
|||||||
|
|
||||||
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
|
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
|
||||||
|
|
||||||
|
public List<(string OwnerId, IReadOnlyList<WizardAction> Actions)> EditActions { get; } = new();
|
||||||
|
|
||||||
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
|
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
|
||||||
|
|
||||||
public Task<long> EditMessageTextAsync(
|
public Task<string> EditDraftMessageAsync(
|
||||||
long chatId,
|
WizardDraft draft,
|
||||||
int? messageThreadId,
|
|
||||||
long messageId,
|
|
||||||
string text,
|
string text,
|
||||||
InlineKeyboardMarkup keyboard,
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
Edits.Add((chatId, messageThreadId, messageId, text));
|
Edits.Add((
|
||||||
return Task.FromResult(messageId);
|
long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0,
|
||||||
|
int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null,
|
||||||
|
long.TryParse(draft.DraftMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var msgId) ? msgId : 0,
|
||||||
|
text));
|
||||||
|
EditActions.Add((draft.OwnerId, keyboard));
|
||||||
|
return Task.FromResult(draft.DraftMessageId ?? "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> SendGroupMessageAsync(
|
public Task<string> SendDraftMessageAsync(
|
||||||
long chatId,
|
WizardDraft draft,
|
||||||
int? messageThreadId,
|
|
||||||
string text,
|
string text,
|
||||||
InlineKeyboardMarkup keyboard,
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
Sends.Add((chatId, messageThreadId, text));
|
Sends.Add((
|
||||||
return Task.FromResult(99L);
|
long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0,
|
||||||
|
int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null,
|
||||||
|
text));
|
||||||
|
return Task.FromResult("99");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
|
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
AnsweredCallbacks.Add(callbackId);
|
AnsweredCallbacks.Add(interactionId);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
|
public Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
||||||
=> Task.FromResult(Clubs);
|
=> Task.FromResult(Clubs);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user