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:
2026-06-05 16:23:20 +03:00
parent 71080aeab6
commit 8f0f2ef7e7
33 changed files with 1308 additions and 534 deletions
+157
View File
@@ -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);
+2 -2
View File
@@ -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>();
@@ -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);
}
@@ -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; }
} }
@@ -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;
}
@@ -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)
{
}
}
@@ -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);
} }
@@ -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;
@@ -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);
@@ -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);
@@ -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);
} }
@@ -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);
} }
@@ -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);
@@ -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" },
}, },
}; };
@@ -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);
@@ -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,
@@ -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);
}
}
@@ -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 = "{}",
}; };
@@ -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);
} }