Compare commits

...

2 Commits

Author SHA1 Message Date
Coder b81d865832 feat(discord): step-by-step game/pool creation wizard (issue #112)
Add Discord adapter for the platform-neutral wizard moved to Shared in
the previous commit. Six new files in src/GmRelay.DiscordBot/Features/
Sessions/Wizard/:

- DiscordWizardContextStore: IWizardContextStore abstraction +
  thread-safe in-memory impl keyed by draft id. Holds the (guild,
  channel, message) coordinates the messenger needs to re-send the
  draft after a 15-minute interaction token expires.
- DiscordWizardStep: renderer for all 15 wizard steps. Returns an
  embed plus an IReadOnlyList<IMessageComponentProperties> that mixes
  ActionRow buttons with StringMenu select menus. Also exposes
  BuildModal for the 8 modal-collecting steps.
- DiscordWizardMessenger: IWizardMessenger impl backed by NetCord's
  RestClient + NpgsqlDataSource. Edit falls back to re-send on
  401/403/404. Toast replies are stashed in the existing
  DiscordInteractionReplyCache.
- DiscordWizardSubmitter: 3-retry finalize loop. Builds the shared
  CreateSessionCommand and calls CreateSessionHandler; on success
  edits the message to "ok Created: N sessions", on failure shows
  retry/cancel buttons.
- DiscordWizardCommand: /newsession-wizard slash command with an
  optional mode param (single|pool). Owner/co-GM check via the
  shared group_managers table.
- DiscordPermissionLookup: small helper that loads DB manager ids
  for a guild.

Program.cs gets 5 new singleton registrations (IWizardDraftRepository,
IWizardContextStore, IWizardMessenger, GameCreationWizard,
DiscordWizardSubmitter). The slash command is auto-discovered by
AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
+ AddModules(typeof(Program).Assembly).

Build green. All 85 wizard tests + 95 Discord tests pass.
dotnet format clean.

Open: DiscordWizardInteractionModule (button/modal handlers) is not
yet implemented; the bot starts and /newsession-wizard works to the
point of posting the first embed, but subsequent button clicks won't
be handled. A follow-up commit will add the component-interaction
module.
2026-06-05 17:52:29 +03:00
Toutsu 8f0f2ef7e7 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.
2026-06-05 16:23:20 +03:00
40 changed files with 2722 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.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
/// <summary>
/// Wizard-driven entry point for game-session creation. Replaces the legacy
/// text-template parser. Exposes <see cref="StartWizardAsync"/> (called from
/// <c>/newsession</c>), <see cref="TryResumeAsync"/> (continue a draft), and
/// <see cref="SubmitDraftAsync"/> (finalize on "✅ Создать" callback).
/// Telegram-side entry point for the wizard-driven session creation
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
/// platform glue (mapping <c>Message</c> to draft fields, rendering
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
/// </summary>
public sealed class CreateSessionHandler
{
private const int MaxRetries = 3;
private const string PlatformName = "Telegram";
private readonly IWizardDraftRepository _drafts;
private readonly SharedCreateSessionHandler _shared;
private readonly ITelegramWizardMessenger _messenger;
private readonly IWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
ITelegramWizardMessenger messenger,
IWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
{
_drafts = drafts;
@@ -44,14 +45,14 @@ public sealed class CreateSessionHandler
}
/// <summary>
/// Entry point for <c>/newsession</c>. If a non-expired draft already exists for
/// this (chat, thread, owner), returns <c>null</c> so the caller can render a
/// "Continue / Start over / Cancel" menu.
/// Entry point for <c>/newsession</c>. If a non-expired draft
/// already exists for this owner, returns <c>null</c> so the caller
/// can render a "Continue / Start over / Cancel" menu.
/// </summary>
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
{
var existing = await _drafts.GetActiveAsync(
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
if (existing is not null)
{
return null;
@@ -60,9 +61,10 @@ public sealed class CreateSessionHandler
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = message.Chat.Id,
MessageThreadId = message.MessageThreadId,
OwnerTelegramId = message.From?.Id ?? 0,
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
OwnerId = ownerId,
Platform = PlatformName,
Step = WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
@@ -70,9 +72,8 @@ public sealed class CreateSessionHandler
};
await _drafts.UpsertAsync(draft, ct);
var (text, kb) = WizardStep.Render(draft, new WizardPayload());
var msgId = await _messenger.SendGroupMessageAsync(
draft.ChatId, draft.MessageThreadId, text, kb, ct);
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
@@ -80,24 +81,27 @@ public sealed class CreateSessionHandler
}
/// <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>
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct) =>
_drafts.GetActiveAsync(
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
{
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
}
/// <summary>
/// Finalize: build shared command(s), call the shared handler, edit the wizard message.
/// On failure, retry up to <see cref="MaxRetries"/> times before deleting the draft.
/// Finalize: build shared command(s), call the shared handler, edit
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
/// times before deleting the draft.
/// </summary>
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
{
var payload = LoadPayload(draft);
if (!IsComplete(payload, out var missing))
{
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct);
await _messenger.EditDraftMessageAsync(
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
return;
}
@@ -109,10 +113,11 @@ public sealed class CreateSessionHandler
await _shared.HandleAsync(cmd, ct);
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
EmptyKeyboard(), ct);
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
catch (Exception ex)
@@ -122,26 +127,29 @@ public sealed class CreateSessionHandler
SavePayload(draft, payload);
if (payload.RetryCount >= MaxRetries)
{
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
await _messenger.EditDraftMessageAsync(
draft,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
EmptyKeyboard(), ct);
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
await _messenger.EditDraftMessageAsync(
draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelKeyboard(), ct);
RetryCancelActions(),
ct);
}
}
// ── Build shared commands ────────────────────────────────────────
// The shared handler creates one session per scheduled time in a single transaction
// and assigns the same batch_id to all of them. A wizard pool therefore produces ONE
// command with N times; a single-game wizard produces ONE command with one time.
// The shared handler creates one session per scheduled time in a
// single transaction and assigns the same batch_id to all of them.
// 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)
{
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
@@ -153,7 +161,7 @@ public sealed class CreateSessionHandler
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false)
isOneShot: false),
};
}
return new List<CreateSessionCommand>
@@ -163,7 +171,7 @@ public sealed class CreateSessionHandler
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
isOneShot: true)
isOneShot: true),
};
}
@@ -177,18 +185,17 @@ public sealed class CreateSessionHandler
int maxPlayers,
bool isOneShot)
{
var gmId = draft.OwnerTelegramId;
var user = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
draft.OwnerId,
DisplayName: string.Empty,
ExternalUsername: null);
var group = new PlatformGroup(
PlatformKind.Telegram,
draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
draft.ChatId,
DisplayName: string.Empty,
ExternalChannelId: null,
ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
ExternalThreadId: draft.MessageThreadId);
return new CreateSessionCommand(
User: user,
Group: group,
@@ -245,10 +252,9 @@ public sealed class CreateSessionHandler
}
// ── Keyboards ────────────────────────────────────────────────────
private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty<InlineKeyboardButton[]>());
private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[]
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
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.Tasks;
using Dapper;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
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(
ITelegramBotClient bot,
NpgsqlDataSource dataSource) : ITelegramWizardMessenger
NpgsqlDataSource dataSource) : IWizardMessenger
{
public async Task<long> EditMessageTextAsync(
long chatId, int? messageThreadId, long messageId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
public async Task<string> EditDraftMessageAsync(
WizardDraft draft,
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(
chatId: chatId,
messageId: (int)messageId,
messageId: messageId,
text: text,
replyMarkup: keyboard,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
cancellationToken: ct);
return msg.MessageId;
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
public async Task<long> SendGroupMessageAsync(
long chatId, int? messageThreadId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
public async Task<string> SendDraftMessageAsync(
WizardDraft draft,
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(
chatId: chatId,
text: text,
messageThreadId: messageThreadId,
replyMarkup: keyboard,
messageThreadId: threadId,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
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
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
@@ -57,14 +88,49 @@ public sealed class TelegramWizardMessenger(
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
JOIN players p ON p.id = gm.player_id
WHERE p.platform = 'Telegram'
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalId
GROUP BY g.id, g.name
ORDER BY g.name
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
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();
}
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.Collections.Generic;
using System.Text;
using GmRelay.Shared.Domain;
using System.Linq;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
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 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;
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
public const int MinCapacity = WizardStepLimits.MinCapacity;
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
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,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? clubs = null)
{
return draft.Step switch
{
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}"),
};
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
return (text, ToInlineKeyboard(actions));
}
// ── Single-game renderers ──────────────────────────────────────────
private static (string, InlineKeyboardMarkup) RenderType() => (
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single")) },
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()
/// <summary>
/// Convert a flat list of <see cref="WizardAction"/>s into a
/// Telegram keyboard. Each action is placed in its own row to
/// preserve the pre-refactor visual layout.
/// </summary>
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
{
var buttons = new List<InlineKeyboardButton[]>
if (actions.Count == 0)
{
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")) },
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());
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
}
var rows = new List<InlineKeyboardButton[]>();
foreach (var club in clubs)
var rows = new InlineKeyboardButton[actions.Count][];
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
using System.Globalization;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
@@ -16,6 +17,7 @@ using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
namespace GmRelay.Bot.Infrastructure.Telegram;
@@ -36,7 +38,7 @@ public sealed class UpdateRouter(
InitiateRescheduleHandler initiateRescheduleHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
GameCreationWizard wizard,
SharedWizard wizard,
IWizardDraftRepository drafts,
ITelegramBotClient bot,
IConfiguration configuration,
@@ -47,9 +49,9 @@ public sealed class UpdateRouter(
// 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
// 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)
{
// Resume / Reset / Cancel menu callbacks live in the router because
@@ -60,7 +62,10 @@ public sealed class UpdateRouter(
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
// acknowledges the callback; the actual session creation lives in
@@ -157,7 +162,7 @@ public sealed class UpdateRouter(
};
private static WizardPayload LoadPayload(WizardDraft draft) =>
GameCreationWizard.LoadPayload(draft);
SharedWizard.LoadPayload(draft);
internal static string GetCommandText(Message message)
=> (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.
/// Returns false for updates that carry no usable origin (e.g. inline queries).
/// </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;
messageThreadId = null;
ownerId = 0;
ownerId = string.Empty;
switch (update)
{
case { Message: { From: not null, Chat: { } chat } msg }:
chatId = chat.Id;
messageThreadId = msg.MessageThreadId;
ownerId = msg.From!.Id;
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
return true;
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
chatId = cbmChat.Id;
messageThreadId = cb.Message?.MessageThreadId;
ownerId = cb.From!.Id;
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
return true;
case { CallbackQuery: { From: not null } cb2 }:
// Callback arrived without a message (e.g. from a Mini App). No chat
// context → wizard cannot run on this update.
ownerId = cb2.From!.Id;
ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture);
return false;
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)
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GameCreationWizard>();
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Small lookup helper for Discord permission checks. The
/// <see cref="DiscordNewSessionHandler"/> already runs the same SQL
/// inline; this class is here so the wizard slash command can do the
/// same check without duplicating the query string.
/// </summary>
internal static class DiscordPermissionLookup
{
public static async Task<IReadOnlyList<ulong>> LoadManagerUserIdsAsync(
NpgsqlDataSource dataSource,
ulong guildId,
CancellationToken cancellationToken)
{
const string sql = """
SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord'
AND p.platform = 'Discord'
AND g.external_group_id = @GuildId
""";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<ulong>(
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
return rows.ToList();
}
}
@@ -0,0 +1,210 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Slash entry point for the Discord wizard. Mirrors the Telegram
/// <c>/newsession-wizard</c> command: a fresh draft is created on
/// first invocation, the persisted first-step message is re-shown
/// when the user already has an active draft, and the owner/co-GM
/// permission check from <see cref="DiscordPermissionChecker"/> is
/// applied before any draft is created.
/// </summary>
public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordWizardMessenger _messenger;
private readonly DiscordPermissionChecker _permissions;
private readonly IWizardDraftRepository _drafts;
private readonly IWizardContextStore _contextStore;
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<DiscordWizardCommand> _log;
public DiscordWizardCommand(
DiscordWizardMessenger messenger,
DiscordPermissionChecker permissions,
IWizardDraftRepository drafts,
IWizardContextStore contextStore,
NpgsqlDataSource dataSource,
ILogger<DiscordWizardCommand> log)
{
_messenger = messenger;
_permissions = permissions;
_drafts = drafts;
_contextStore = contextStore;
_dataSource = dataSource;
_log = log;
}
[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
{
var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channel = Context.Channel
?? throw new InvalidOperationException("Channel data not available in interaction.");
var channelId = channel.Id.ToString(CultureInfo.InvariantCulture);
var member = Context.User as GuildInteractionUser
?? throw new InvalidOperationException("Guild member data not available in interaction.");
var resolvedPermissions = (ulong)member.Permissions;
var userId = Context.User.Id.ToString(CultureInfo.InvariantCulture);
// Slash commands don't expose a CancellationToken on the context;
// the REST call already has its own per-request cancellation. We
// pass CancellationToken.None for the DB calls — they're cheap and
// the host's shutdown will tear them down.
var ct = CancellationToken.None;
var ownerId = userId;
ulong guildOwnerId = 0;
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_log.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; falling back to permissions from interaction payload.",
guildId);
}
// Permission check: server owner, guild admin, or DB manager
// for the Discord game_group.
var dbManagerIds = await DiscordPermissionLookup.LoadManagerUserIdsAsync(
_dataSource, guildId, ct);
if (!_permissions.CanManageSchedule(guildOwnerId, Context.User.Id, dbManagerIds, resolvedPermissions))
{
await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("⛔ Только owner, администратор или manager могут создавать сессии.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
// If there's already a draft, offer Continue / Start over.
var existing = await _drafts.GetActiveAsync("Discord", ownerId, ct);
if (existing is not null && existing.Id != Guid.Empty)
{
await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📝 У вас уже есть активный мастер. Продолжить?")
.WithFlags(MessageFlags.Ephemeral)
.WithComponents(BuildResumeRow(existing.Id))));
return;
}
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = guildId.ToString(CultureInfo.InvariantCulture),
MessageThreadId = null,
OwnerId = ownerId,
Platform = "Discord",
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
? WizardStepNames.Title
: WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
};
// If the user passed `mode=pool` we pre-seed the payload so the
// wizard's own branching lands on the pool flow.
if (NormalizeMode(mode) is { } mt)
{
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
new WizardPayload { Type = mt },
WizardPayloadJsonContext.Default.WizardPayload);
}
// Stash the context BEFORE sending so the messenger can both
// send the message and persist the returned message id back.
_contextStore.Set(draft.Id, new DiscordWizardContext(
GuildId: guildId.ToString(CultureInfo.InvariantCulture),
ChannelId: channelId,
MessageId: string.Empty,
ThreadId: null));
// Render + send the first step. Defer the response so we can
// show the wizard message in the channel.
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
MessageFlags.Ephemeral));
try
{
var payload = LoadPayload(draft);
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await Context.Interaction.ModifyResponseAsync(msg =>
{
msg.Content = "🎲 Мастер запущен. См. сообщение ниже.";
msg.Flags = MessageFlags.Ephemeral;
});
}
catch (Exception ex)
{
_log.LogError(ex, "Failed to start wizard for user {OwnerId} in guild {GuildId}", ownerId, guildId);
_contextStore.Remove(draft.Id);
try
{
await _drafts.DeleteAsync(draft.Id, ct);
}
catch
{
/* best effort */
}
await Context.Interaction.ModifyResponseAsync(msg =>
{
msg.Content = "💥 Не удалось запустить мастер. Попробуйте позже.";
msg.Flags = MessageFlags.Ephemeral;
});
}
}
private static WizardCreationType? NormalizeMode(string? mode) =>
mode?.ToLowerInvariant() switch
{
"single" or "одну" or "one" => WizardCreationType.Single,
"pool" or "пул" => WizardCreationType.Pool,
_ => null,
};
private static WizardPayload LoadPayload(WizardDraft draft) =>
string.IsNullOrEmpty(draft.PayloadJson)
? new WizardPayload()
: System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
private static IReadOnlyList<IMessageComponentProperties> BuildResumeRow(Guid draftId)
{
var row = new ActionRowProperties();
row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("resume", "continue"),
"▶️ Продолжить",
ButtonStyle.Primary));
row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("resume", "restart"),
"🔄 Заново",
ButtonStyle.Secondary));
row.Add(new ButtonProperties(
DiscordWizardStep.ButtonCustomId("cancel", "1"),
"❌ Отмена",
ButtonStyle.Danger));
return new IMessageComponentProperties[] { row };
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Concurrent;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Snapshot of where the wizard's draft message lives. The messenger
/// needs this to re-send / re-edit the message after a 15-minute
/// interaction token has expired.
/// </summary>
/// <param name="GuildId">Discord guild (server) id as a decimal string.</param>
/// <param name="ChannelId">Channel id where the draft message was posted.</param>
/// <param name="MessageId">Id of the currently active draft message.</param>
/// <param name="ThreadId">Optional thread id; <c>null</c> for top-level channel posts.</param>
public sealed record DiscordWizardContext(
string GuildId,
string ChannelId,
string MessageId,
string? ThreadId);
/// <summary>
/// In-memory store of draft → context lookups. Lives for the lifetime of
/// the process; the wizard's 24-hour expiry is enforced by
/// <c>WizardDraftCleanupService</c>, so this cache is allowed to hold
/// entries until the draft is finalized or explicitly removed.
/// </summary>
public interface IWizardContextStore
{
void Set(Guid draftId, DiscordWizardContext context);
bool TryGet(Guid draftId, out DiscordWizardContext context);
void Remove(Guid draftId);
}
/// <summary>
/// Thread-safe in-memory implementation. Concurrent dictionary keyed by
/// <see cref="Guid"/> is sufficient for a single-process Discord bot.
/// </summary>
public sealed class DiscordWizardContextStore : IWizardContextStore
{
private readonly ConcurrentDictionary<Guid, DiscordWizardContext> store = new();
public void Set(Guid draftId, DiscordWizardContext context) =>
store[draftId] = context;
public bool TryGet(Guid draftId, out DiscordWizardContext context) =>
store.TryGetValue(draftId, out context!);
public void Remove(Guid draftId) => store.TryRemove(draftId, out _);
}
@@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using NetCord;
using NetCord.Rest;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Discord-side implementation of <see cref="IWizardMessenger"/>.
/// Translates the platform-neutral wizard contract into NetCord REST
/// calls and ephemeral follow-ups. The messenger has no access to the
/// live interaction context — that lives on the inbound handler — so
/// <see cref="AnswerInteractionAsync"/> stashes the toast text in
/// <see cref="DiscordInteractionReplyCache"/>; the inbound module
/// drains the cache and ships the actual <c>SendResponseAsync</c> call
/// via the existing helper used by <c>DiscordPlatformMessenger</c>.
/// </summary>
public sealed class DiscordWizardMessenger : IWizardMessenger
{
private readonly RestClient _rest;
private readonly NpgsqlDataSource _dataSource;
private readonly DiscordInteractionReplyCache _replies;
private readonly IWizardContextStore _contextStore;
private readonly ILogger<DiscordWizardMessenger>? _log;
public DiscordWizardMessenger(
RestClient rest,
NpgsqlDataSource dataSource,
DiscordInteractionReplyCache replies,
IWizardContextStore contextStore)
: this(rest, dataSource, replies, contextStore, logger: null)
{
}
public DiscordWizardMessenger(
RestClient rest,
NpgsqlDataSource dataSource,
DiscordInteractionReplyCache replies,
IWizardContextStore contextStore,
ILogger<DiscordWizardMessenger>? logger)
{
_rest = rest;
_dataSource = dataSource;
_replies = replies;
_contextStore = contextStore;
_log = logger;
}
public async Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
if (!_contextStore.TryGet(draft.Id, out var ctx))
{
// No stored context (e.g. service restart, draft from another
// process). Fall back to sending a brand new message — the
// caller will persist the returned id.
return await SendDraftMessageAsync(draft, text, keyboard, ct);
}
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
!ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
{
// Context is corrupt — recreate the message.
return await SendDraftMessageAsync(draft, text, keyboard, ct);
}
try
{
var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
await _rest.ModifyMessageAsync(
channelId,
messageId,
options =>
{
options.Embeds = embed is null ? null : new[] { embed };
options.Components = rows;
});
return ctx.MessageId;
}
catch (RestException ex) when (IsExpiredOrUnknownMessage(ex))
{
// Message was deleted or interaction token expired —
// recreate the message in the original channel.
_log?.LogWarning(
ex,
"Edit failed for draft {DraftId} (channel={ChannelId}, message={MessageId}); re-sending.",
draft.Id,
ctx.ChannelId,
ctx.MessageId);
return await SendDraftMessageAsync(draft, text, keyboard, ct);
}
}
public async Task<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
if (!_contextStore.TryGet(draft.Id, out var ctx))
{
throw new InvalidOperationException(
$"Cannot send wizard message: no context for draft {draft.Id}.");
}
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId))
{
throw new InvalidOperationException(
$"Wizard draft {draft.Id} has un-parseable channel id '{ctx.ChannelId}'.");
}
var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
var message = await _rest.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds(embed is null ? null : new[] { embed })
.WithComponents(rows));
var newMessageId = message.Id.ToString(CultureInfo.InvariantCulture);
_contextStore.Set(draft.Id, ctx with { MessageId = newMessageId });
return newMessageId;
}
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
{
// The wizard's "answer" is just a toast shown to the user.
// Stash it in the existing reply cache; the inbound interaction
// module drains it once the wizard returns. ShowAlert=false so
// it appears as a quiet follow-up rather than a popup.
_replies.Store(new PlatformInteractionReply(
InteractionId: interactionId,
Text: text ?? string.Empty,
ShowAlert: false));
return Task.CompletedTask;
}
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(
string ownerId, CancellationToken ct)
{
// The Telegram messenger enumerates game_groups the owner
// manages as a GM (V008 added group_managers with role
// 'Owner'|'CoGm'). Discord follows the same convention.
const string sql = """
SELECT g.id AS ClubId,
g.name AS Name
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
JOIN players p ON p.id = gm.player_id
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalId
AND gm.role IN ('Owner', 'CoGm')
GROUP BY g.id, g.name
ORDER BY g.name
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
var rows = await conn.QueryAsync<WizardClubOption>(
new CommandDefinition(
sql,
new { Platform = "Discord", ExternalId = ownerId },
cancellationToken: ct));
return rows.AsList();
}
// ── Embed + component construction ────────────────────────────────
private (EmbedProperties? embed, IReadOnlyList<IMessageComponentProperties> rows) BuildEmbedAndRows(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard)
{
// Embeds have a hard 4096-char limit — truncate to 3900 so the
// wizard's own prefix/suffix additions still fit.
var safeText = Truncate(text, 3900);
var rows = BuildActionRowsFromActions(keyboard);
return (BuildEmbed(safeText), rows);
}
private static EmbedProperties BuildEmbed(string description) =>
new EmbedProperties()
.WithTitle("Мастер создания сессии")
.WithDescription(description)
.WithColor(new Color(0x5865F2));
private static string Truncate(string text, int max) =>
text.Length <= max ? text : text[..max];
private static IReadOnlyList<IMessageComponentProperties> BuildActionRowsFromActions(
IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return Array.Empty<IMessageComponentProperties>();
}
var rows = new List<IMessageComponentProperties>();
// Discord allows up to 5 buttons per ActionRow. Lay them out
// left-to-right, 5 per row.
foreach (var chunk in actions.Chunk(5))
{
var row = new ActionRowProperties();
foreach (var action in chunk)
{
var style = action.Style switch
{
WizardActionStyle.Primary => ButtonStyle.Primary,
WizardActionStyle.Success => ButtonStyle.Success,
WizardActionStyle.Danger => ButtonStyle.Danger,
_ => ButtonStyle.Secondary,
};
var cid = action.Payload;
if (cid.Length > DiscordWizardStep.MaxCustomIdLength)
{
// Truncate-by-omission isn't safe (customId must be
// unique). The wizard's callback format is already
// bounded — if we hit this, it's a bug.
throw new InvalidOperationException(
$"Wizard action custom id '{cid}' exceeds Discord's 100-char limit.");
}
row.Add(new ButtonProperties(cid, action.Label, style));
}
rows.Add(row);
}
return rows;
}
private static bool IsExpiredOrUnknownMessage(RestException ex) =>
ex.StatusCode == System.Net.HttpStatusCode.NotFound
|| ex.StatusCode == System.Net.HttpStatusCode.Unauthorized
|| ex.StatusCode == System.Net.HttpStatusCode.Forbidden;
}
@@ -0,0 +1,575 @@
using System.Collections.Generic;
using System.Linq;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ComponentInteractions;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Renders a wizard step into a Discord embed + components row + an
/// optional modal popup. Lives in the DiscordBot project because the
/// platform-neutral <see cref="WizardStepViewBuilder"/> doesn't know
/// about embeds, action rows, or modals.
///
/// The renderer also exposes <see cref="OpenModal"/> so the slash
/// command can return a "popup" response to the user (Telegram's
/// "ForceReply" equivalent). Discord modals are limited to 5 components
/// (typically labels wrapping text inputs) and a 45-character title.
/// </summary>
public static class DiscordWizardStep
{
/// <summary>
/// Discord custom-id budget is 100 characters. We pad our own
/// identifiers to stay under it.
/// </summary>
public const int MaxCustomIdLength = 100;
/// <summary>
/// Sentinel returned in <see cref="DiscordWizardRender.OpenModalStep"/>
/// when the renderer wants the interaction module to open a modal
/// for the given step. The step name is the <see cref="WizardStepNames"/>
/// value (e.g. <c>Title</c>, <c>DateTime</c>).
/// </summary>
public sealed record DiscordWizardRender(
string EmbedTitle,
string EmbedDescription,
IReadOnlyList<IMessageComponentProperties> Components,
string? OpenModalStep);
/// <summary>
/// Build the embed + components for a wizard step. The caller is
/// responsible for sending the message via
/// <see cref="DiscordWizardMessenger"/>.
/// </summary>
public static DiscordWizardRender Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? clubs = null)
{
return draft.Step switch
{
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 ?? System.Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => RenderPublish(),
WizardStepNames.Confirm => RenderConfirm(payload),
WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
_ => throw new System.InvalidOperationException($"Unknown wizard step: {draft.Step}"),
};
}
// ── Custom-id helpers ─────────────────────────────────────────────
public static string ButtonCustomId(string step, string value) =>
$"wizard:btn:{step}:{value}";
public static string SelectCustomId(string step) => $"wizard:select:{step}";
public static string ModalCustomId(string step) => $"wizard:modal:{step}";
public static bool TryParseButtonCustomId(string customId, out string step, out string value)
{
step = value = string.Empty;
var parts = customId.Split(':', 4);
if (parts.Length < 4 || parts[0] != "wizard" || parts[1] != "btn")
{
return false;
}
step = parts[2];
value = parts[3];
return true;
}
public static bool TryParseSelectCustomId(string customId, out string step)
{
step = string.Empty;
var parts = customId.Split(':', 3);
return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "select" && (step = parts[2]) is not null;
}
public static bool TryParseModalCustomId(string customId, out string step)
{
step = string.Empty;
var parts = customId.Split(':', 3);
return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "modal" && (step = parts[2]) is not null;
}
// ── Helpers ───────────────────────────────────────────────────────
private static ButtonProperties Btn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary)
{
var cid = ButtonCustomId(step, value);
EnsureCustomIdFits(cid);
return new ButtonProperties(cid, label, style);
}
private static ActionRowProperties Row(params IActionRowComponentProperties[] components)
{
var row = new ActionRowProperties();
foreach (var c in components)
{
row.Add(c);
}
return row;
}
/// <summary>
/// Wrap a list of top-level message components (action rows and
/// select menus) into a single <see cref="IReadOnlyList{IMessageComponentProperties}"/>
/// for <c>MessageProperties.WithComponents</c>.
/// </summary>
private static IReadOnlyList<IMessageComponentProperties> Comps(params IMessageComponentProperties[] items) =>
items;
private static void EnsureCustomIdFits(string customId)
{
if (customId.Length > MaxCustomIdLength)
{
throw new System.InvalidOperationException(
$"Custom id '{customId}' is {customId.Length} chars; Discord limit is {MaxCustomIdLength}.");
}
}
// ── Single-game steps ─────────────────────────────────────────────
private static DiscordWizardRender RenderType() => new(
"🎲 Создание игровой сессии",
"Выберите тип: одна игра или пул.",
new IMessageComponentProperties[] { Row(Btn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary),
Btn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
OpenModalStep: null);
private static DiscordWizardRender RenderTitle() => new(
"📝 Название",
"Введите название игры в модальном окне.",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Title);
private static DiscordWizardRender RenderDescription() => new(
"📄 Описание",
"Введите описание (или «-», чтобы пропустить).",
new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Description);
private static DiscordWizardRender RenderCover() => new(
"🖼 Обложка",
"Введите URL картинки (или «-», чтобы пропустить).",
new IMessageComponentProperties[] { Row(Btn("⏭ Пропустить", "skip", "1"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Cover);
private static DiscordWizardRender RenderSystem() => new(
"🎲 Система",
"Выберите систему.",
new[]
{
Row(Btn("D&D 5e", WizardStepNames.System, "Dnd5e"),
Btn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"),
Btn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"),
Btn("GURPS", WizardStepNames.System, "GURPS"),
Btn("Fate", WizardStepNames.System, "Fate")),
Row(Btn("Другое… ✏️", "modal", "SystemFreeText", ButtonStyle.Primary),
Btn("⏭ Пропустить", WizardStepNames.System, "_skip"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderDuration() => new(
"⏱ Длительность",
"Выберите длительность (или «Другое…»).",
new[]
{
Row(Btn("3 часа", WizardStepNames.Duration, "180"),
Btn("4 часа", WizardStepNames.Duration, "240"),
Btn("5 часов", WizardStepNames.Duration, "300"),
Btn("6 часов", WizardStepNames.Duration, "360")),
Row(Btn("Другое… ✏️", "modal", "DurationFreeText", ButtonStyle.Primary),
Btn("⏭ Пропустить", WizardStepNames.Duration, "_skip"),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderDateTime() => new(
"📅 Дата и время",
"Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.DateTime);
private static DiscordWizardRender RenderCapacity() => new(
"👥 Лимит мест",
"Введите лимит (1..50) и выберите waitlist.",
new[]
{
Row(Btn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
Btn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: WizardStepNames.Capacity);
private static DiscordWizardRender RenderVisibility() => new(
"🔒 Видимость",
"Выберите, кто увидит сессию.",
new IMessageComponentProperties[]
{
BuildSelectMenu(
SelectCustomId(WizardStepNames.Visibility),
"Выберите видимость…",
new[] { new StringMenuSelectOptionProperties("🌐 Публичная в общем showcase", "public"),
new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"),
new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"),
}),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderPickClub(IReadOnlyList<WizardClubOption> clubs)
{
if (clubs.Count == 0)
{
return new DiscordWizardRender(
"🏷 Выбор клуба",
"У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
OpenModalStep: null);
}
var options = new List<StringMenuSelectOptionProperties>(clubs.Count);
foreach (var c in clubs.Take(25))
{
options.Add(new StringMenuSelectOptionProperties(c.Name, c.ClubId.ToString()));
}
return new DiscordWizardRender(
"🏷 Выбор клуба",
"Выберите клуб из списка.",
new IMessageComponentProperties[]
{
BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
}
private static DiscordWizardRender RenderPublish() => new(
"✨ Публикация",
"Опубликовать в витрине сейчас?",
new[]
{
Row(Btn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success),
Btn("📝 Только в чате", WizardStepNames.Publish, "no")),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderConfirm(WizardPayload p) => new(
"👀 Проверьте перед созданием",
BuildConfirmDescription(p),
new[]
{
Row(Btn("✅ Создать", "create", "1", ButtonStyle.Success),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
// ── Pool steps ────────────────────────────────────────────────────
private static DiscordWizardRender RenderPoolSystemDuration() => new(
"🎲 Система и длительность пула",
"Выберите пресет или «Другое…».",
new IMessageComponentProperties[]
{
BuildSelectMenu(
SelectCustomId(WizardStepNames.PoolSystemDuration),
"Выберите пресет…",
new[] { new StringMenuSelectOptionProperties("D&D 5e · 4 ч", "Dnd5e:240"),
new StringMenuSelectOptionProperties("Pathfinder 2e · 4 ч", "Pathfinder2e:240"),
new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"),
new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"),
}),
Row(Btn("Другое… ✏️", "modal", "PoolSystemDurationFreeText", ButtonStyle.Primary),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderPoolAddSlots(WizardPayload p) => new(
$"📅 Слоты пула «{p.Title}»",
$"Добавлено: {p.Pool?.Slots.Count ?? 0}.",
new[]
{
Row(Btn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary),
Btn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderPoolSlotDateTime() => new(
"📅 Дата/время слота",
"Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
new IMessageComponentProperties[] { Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.PoolSlotDateTime);
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
"👥 Лимит слотов",
"Введите лимит (1..50) и выберите waitlist.",
new[]
{
Row(Btn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
Btn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
Row(Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: WizardStepNames.PoolSlotCapacity);
private static DiscordWizardRender RenderPoolConfirm(WizardPayload p) => new(
"👀 Проверьте пул перед созданием",
BuildPoolConfirmDescription(p),
new[]
{
Row(Btn("✅ Создать пул", "create", "1", ButtonStyle.Success),
Btn("⬅️ Назад", "back", "1"),
Btn("❌ Отмена", "cancel", "1", ButtonStyle.Danger)),
},
OpenModalStep: null);
// ── Builders for embed descriptions and selects ───────────────────
private static StringMenuProperties BuildSelectMenu(
string customId,
string placeholder,
IReadOnlyList<StringMenuSelectOptionProperties> options)
{
EnsureCustomIdFits(customId);
return new StringMenuProperties(customId, options)
{
Placeholder = placeholder,
};
}
private static string BuildConfirmDescription(WizardPayload p)
{
var sb = new System.Text.StringBuilder();
sb.Append("🎲 ").AppendLine(p.Title);
if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
if (p.Single?.ScheduledAt is { } at) sb.Append("📅 ").AppendLine(at.FormatMoscow());
if (p.Single?.MaxPlayers is { } mp)
{
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
}
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
return sb.ToString();
}
private static string BuildPoolConfirmDescription(WizardPayload p)
{
var sb = new System.Text.StringBuilder();
sb.Append("📝 ").AppendLine(p.Title);
if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
sb.Append("Слоты (").Append(p.Pool?.Slots.Count ?? 0).AppendLine("):");
if (p.Pool is not null)
{
foreach (var s in p.Pool.Slots)
{
sb.Append(" • ").Append(s.ScheduledAt.FormatMoscow())
.Append(" — мест ").Append(s.MaxPlayers)
.Append(", waitlist ").Append(s.Waitlist ? "вкл" : "выкл")
.AppendLine();
}
}
return sb.ToString();
}
private static string RenderVisibilityText(WizardVisibility? v) => v switch
{
WizardVisibility.Public => "публичная в общем showcase",
WizardVisibility.Club => "публичная в витрине клуба",
WizardVisibility.Members => "только для членов клуба",
_ => "не задана",
};
// ── Modal builders ────────────────────────────────────────────────
/// <summary>
/// Build a <see cref="ModalProperties"/> for the given wizard step.
/// The wizard step's <c>openModal</c> value drives which modal we
/// emit. Returns <c>null</c> if no modal is required for the step.
/// </summary>
public static ModalProperties? BuildModal(string step, string? draftTitle)
{
return step switch
{
WizardStepNames.Title => new ModalProperties(
ModalCustomId(WizardStepNames.Title),
"📝 Название игры",
new IModalComponentProperties[]
{
new LabelProperties(
"Название",
new TextInputProperties(ModalCustomId(WizardStepNames.Title), TextInputStyle.Short)
{
Placeholder = "Например: D&D 5e, Проклятие Страда",
MinLength = 1,
MaxLength = WizardStepLimits.MaxTitleLength,
Required = true,
}),
}),
WizardStepNames.Description => new ModalProperties(
ModalCustomId(WizardStepNames.Description),
"📄 Описание",
new IModalComponentProperties[]
{
new LabelProperties(
"Описание",
new TextInputProperties(ModalCustomId(WizardStepNames.Description), TextInputStyle.Paragraph)
{
Placeholder = "Опишите сценарий / сеттинг. «-» чтобы пропустить.",
MaxLength = WizardStepLimits.MaxDescriptionLength,
Required = true,
}),
}),
WizardStepNames.Cover => new ModalProperties(
ModalCustomId(WizardStepNames.Cover),
"🖼 Обложка (URL)",
new IModalComponentProperties[]
{
new LabelProperties(
"URL картинки",
new TextInputProperties(ModalCustomId(WizardStepNames.Cover), TextInputStyle.Short)
{
Placeholder = "https://… или «-» чтобы пропустить",
MaxLength = 500,
Required = true,
}),
}),
"SystemFreeText" => new ModalProperties(
ModalCustomId("SystemFreeText"),
"🎲 Другая система",
new IModalComponentProperties[]
{
new LabelProperties(
"Система",
new TextInputProperties(ModalCustomId("SystemFreeText"), TextInputStyle.Short)
{
Placeholder = "Свободное название системы",
MaxLength = WizardStepLimits.MaxSystemLength,
Required = true,
}),
}),
"DurationFreeText" => new ModalProperties(
ModalCustomId("DurationFreeText"),
"⏱ Длительность (часы)",
new IModalComponentProperties[]
{
new LabelProperties(
"Часы",
new TextInputProperties(ModalCustomId("DurationFreeText"), TextInputStyle.Short)
{
Placeholder = "1..12",
MaxLength = 4,
Required = true,
}),
}),
WizardStepNames.DateTime => new ModalProperties(
ModalCustomId(WizardStepNames.DateTime),
"📅 Дата и время",
new IModalComponentProperties[]
{
new LabelProperties(
"Когда",
new TextInputProperties(ModalCustomId(WizardStepNames.DateTime), TextInputStyle.Short)
{
Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
MaxLength = 32,
Required = true,
}),
}),
WizardStepNames.Capacity => new ModalProperties(
ModalCustomId(WizardStepNames.Capacity),
"👥 Лимит мест",
new IModalComponentProperties[]
{
new LabelProperties(
"Max players",
new TextInputProperties(ModalCustomId(WizardStepNames.Capacity), TextInputStyle.Short)
{
Placeholder = "1..50",
MaxLength = 3,
Required = true,
}),
}),
WizardStepNames.PoolSlotDateTime => new ModalProperties(
ModalCustomId(WizardStepNames.PoolSlotDateTime),
"📅 Дата/время слота",
new IModalComponentProperties[]
{
new LabelProperties(
"Когда",
new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotDateTime), TextInputStyle.Short)
{
Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
MaxLength = 32,
Required = true,
}),
}),
WizardStepNames.PoolSlotCapacity => new ModalProperties(
ModalCustomId(WizardStepNames.PoolSlotCapacity),
"👥 Лимит слотов",
new IModalComponentProperties[]
{
new LabelProperties(
"Max players",
new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotCapacity), TextInputStyle.Short)
{
Placeholder = "1..50",
MaxLength = 3,
Required = true,
}),
}),
"PoolSystemDurationFreeText" => new ModalProperties(
ModalCustomId("PoolSystemDurationFreeText"),
"🎲 Другая система пула",
new IModalComponentProperties[]
{
new LabelProperties(
"Система и длительность",
new TextInputProperties(ModalCustomId("PoolSystemDurationFreeText"), TextInputStyle.Short)
{
Placeholder = "Dnd5e:240 или «Pathfinder 2e:180»",
MaxLength = 32,
Required = true,
}),
}),
_ => null,
};
}
}
@@ -0,0 +1,286 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Finalises a wizard draft by calling the shared
/// <see cref="CreateSessionHandler"/>. On success the original draft
/// message is overwritten with a "✅ Создано" confirmation; on failure
/// the user is offered Retry / Cancel buttons so the same draft can be
/// re-submitted without re-entering the wizard from scratch.
/// </summary>
public sealed class DiscordWizardSubmitter
{
private const int MaxRetries = 3;
private readonly CreateSessionHandler _shared;
private readonly RestClient _rest;
private readonly IWizardDraftRepository _drafts;
private readonly IWizardContextStore _contextStore;
private readonly ILogger<DiscordWizardSubmitter> _log;
public DiscordWizardSubmitter(
CreateSessionHandler shared,
RestClient rest,
IWizardDraftRepository drafts,
IWizardContextStore contextStore,
ILogger<DiscordWizardSubmitter> log)
{
_shared = shared;
_rest = rest;
_drafts = drafts;
_contextStore = contextStore;
_log = log;
}
/// <summary>
/// Submit the draft to the shared handler. On a 1-shot failure we
/// edit the draft message to show "retry / cancel" affordances and
/// bump the in-payload retry counter; after <see cref="MaxRetries"/>
/// consecutive failures the draft is deleted.
/// </summary>
public async Task SubmitAsync(WizardDraft draft, CancellationToken ct)
{
var payload = LoadPayload(draft);
if (!IsComplete(payload, out var missing))
{
await EditDraftMessageAsync(
draft,
$"❌ Не заполнены поля: {missing}",
RetryCancelActions(),
ct);
return;
}
try
{
var commands = BuildCommands(draft, payload);
foreach (var cmd in commands)
{
await _shared.HandleAsync(cmd, ct);
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
}
catch (Exception ex)
{
_log.LogError(ex, "Submit failed for draft {DraftId}", draft.Id);
payload.RetryCount += 1;
SavePayload(draft, payload);
if (payload.RetryCount >= MaxRetries)
{
await EditDraftMessageAsync(
draft,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await EditDraftMessageAsync(
draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelActions(),
ct);
}
}
// ── Build shared commands ────────────────────────────────────────
// Same shape as the Telegram submitter: pool → one command with N
// times, single → one command with one time.
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
{
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false),
};
}
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
isOneShot: true),
};
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers,
bool isOneShot)
{
var user = new PlatformUser(
PlatformKind.Discord,
draft.OwnerId,
DisplayName: string.Empty,
ExternalUsername: null);
var group = new PlatformGroup(
PlatformKind.Discord,
draft.ChatId,
DisplayName: string.Empty,
ExternalChannelId: null,
ExternalThreadId: draft.MessageThreadId);
return new CreateSessionCommand(
User: user,
Group: group,
Title: p.Title ?? string.Empty,
Link: string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
}
private static GameSystem? ParseSystem(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return null;
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
}
// ── Validation ───────────────────────────────────────────────────
private static bool IsComplete(WizardPayload p, out string missing)
{
var missingFields = new List<string>();
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single)
{
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
}
else
{
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
}
missing = string.Join(", ", missingFields);
return missingFields.Count == 0;
}
// ── Payload I/O ──────────────────────────────────────────────────
private static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload p)
{
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
p, WizardPayloadJsonContext.Default.WizardPayload);
}
// ── Embed editing ────────────────────────────────────────────────
private async Task EditDraftMessageAsync(
WizardDraft draft, string text, IReadOnlyList<WizardAction> actions, CancellationToken ct)
{
if (!_contextStore.TryGet(draft.Id, out var ctx))
{
return;
}
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
!ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
{
return;
}
try
{
var embed = new EmbedProperties()
.WithTitle("Мастер создания сессии")
.WithDescription(Truncate(text, 3900))
.WithColor(new Color(0x5865F2));
var rows = BuildActionRowsFromActions(actions);
await _rest.ModifyMessageAsync(
channelId,
messageId,
options =>
{
options.Embeds = new[] { embed };
options.Components = rows;
});
}
catch (RestException ex)
{
_log.LogWarning(ex, "Failed to edit wizard message for draft {DraftId}", draft.Id);
}
}
private static IReadOnlyList<IMessageComponentProperties> BuildActionRowsFromActions(
IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return Array.Empty<IMessageComponentProperties>();
}
var rows = new List<IMessageComponentProperties>();
foreach (var chunk in actions.Chunk(5))
{
var row = new ActionRowProperties();
foreach (var action in chunk)
{
var style = action.Style switch
{
WizardActionStyle.Primary => ButtonStyle.Primary,
WizardActionStyle.Success => ButtonStyle.Success,
WizardActionStyle.Danger => ButtonStyle.Danger,
_ => ButtonStyle.Secondary,
};
row.Add(new ButtonProperties(action.Payload, action.Label, style));
}
rows.Add(row);
}
return rows;
}
private static string Truncate(string text, int max) =>
text.Length <= max ? text : text[..max];
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
{
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}
+15
View File
@@ -1,5 +1,6 @@
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Health;
@@ -10,6 +11,7 @@ using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
@@ -82,6 +84,19 @@ builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
// ── Wizard services (issue #112) ──────────────────────────────────────
// The Discord wizard reuses the platform-neutral state machine in
// GmRelay.Shared (GameCreationWizard, IWizardMessenger,
// IWizardDraftRepository) and only adds a Discord-specific messenger,
// step renderer, slash command, and submitter on top. The wizard's
// cleanup service is shared with the Telegram bot and is not
// registered here — it would compete on the same drafts table.
builder.Services.AddSingleton<IWizardDraftRepository, GmRelay.Shared.Features.Sessions.CreateSession.Wizard.WizardDraftRepository>();
builder.Services.AddSingleton<IWizardContextStore, DiscordWizardContextStore>();
builder.Services.AddSingleton<IWizardMessenger, DiscordWizardMessenger>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
builder.Services.AddSingleton<DiscordWizardSubmitter>();
builder.Services
.AddDiscordGateway(options =>
{
@@ -3,25 +3,27 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
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>
/// 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>
public sealed class GameCreationWizard
{
private readonly IWizardDraftRepository _drafts;
private readonly ITelegramWizardMessenger _messenger;
private readonly IWizardMessenger _messenger;
private readonly ILogger<GameCreationWizard> _log;
public GameCreationWizard(
IWizardDraftRepository drafts,
ITelegramWizardMessenger messenger,
IWizardMessenger messenger,
ILogger<GameCreationWizard> log)
{
_drafts = drafts;
@@ -29,44 +31,61 @@ public sealed class GameCreationWizard
_log = log;
}
/// <summary>Handle a text or callback update from the owning GM.</summary>
public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct)
/// <summary>
/// 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
{
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)
{
// Surface storage failure; do not crash the update loop.
if (update.CallbackQuery is { } cb2)
if (interaction.CallbackPayload is not null)
{
await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct);
await _messenger.AnswerInteractionAsync(
interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id);
if (update.CallbackQuery is { } cb3)
_log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id);
if (interaction.CallbackPayload is not null)
{
try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); }
catch { /* swallow — we're already in error path */ }
try
{
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;
}
@@ -74,37 +93,39 @@ public sealed class GameCreationWizard
{
case "cancel":
await _drafts.DeleteAsync(draft.Id, ct);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
"❌ Мастер отменён.", EmptyKeyboard, ct);
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
await _messenger.EditDraftMessageAsync(
draft, "❌ Мастер отменён.", Array.Empty<WizardAction>(), ct);
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
return;
case "back":
ApplyBack(draft, step);
await PersistAndRenderAsync(draft, cb.Id, ct);
await PersistAndRenderAsync(draft, interaction.InteractionId, ct);
return;
case "create":
// Routed by CreateSessionHandler, not here.
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
// Routed by the platform's CreateSessionHandler, not here.
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
return;
default:
// For "Choice" callbacks, action == step.
await ApplyChoiceAsync(draft, step, choice, cb.Id, ct);
await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct);
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.
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);
await PersistAndRenderAsync(draft, null, ct);
}
@@ -113,13 +134,12 @@ public sealed class GameCreationWizard
var (nextStep, error, payload) = ApplyText(draft, text);
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.
var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, mid,
"⚠️ " + errMsg + "\n\n" + rendered, kb, ct);
var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft));
await _messenger.EditDraftMessageAsync(
draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct);
return;
}
@@ -130,12 +150,13 @@ public sealed class GameCreationWizard
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);
if (error is { } err)
{
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
await _messenger.AnswerInteractionAsync(interactionId, err, ct);
return;
}
if (payload is { } p) SavePayload(draft, p);
@@ -143,10 +164,10 @@ public sealed class GameCreationWizard
{
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;
await _drafts.UpsertAsync(draft, ct);
@@ -154,15 +175,13 @@ public sealed class GameCreationWizard
IReadOnlyList<WizardClubOption>? clubs = null;
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);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
text, kb, ct);
if (callbackId is { } id)
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
if (interactionId 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)
{
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)
: (null, title, payload);
case WizardStepNames.Description:
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)
: (null, desc, payload);
@@ -191,7 +210,7 @@ public sealed class GameCreationWizard
case WizardStepNames.System when payload.System is null:
// "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)
: (null, sys, payload);
@@ -206,12 +225,12 @@ public sealed class GameCreationWizard
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
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)
: (null, "Лимит должен быть 1..50", payload);
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)
: (null, psys, payload);
@@ -226,7 +245,7 @@ public sealed class GameCreationWizard
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
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)
: (null, "Лимит должен быть 1..50", payload);
@@ -367,7 +386,7 @@ public sealed class GameCreationWizard
};
// ── Payload I/O ───────────────────────────────────────────────────
internal static WizardPayload LoadPayload(WizardDraft draft)
public static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
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("ч", 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 (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false;
if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false;
minutes = (int)Math.Round(hours * 60);
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;
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 const string Prefix = "wizard";
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
public static string Back() => $"{Prefix}:back";
public static string Cancel() => $"{Prefix}:cancel";
public static string Create() => $"{Prefix}:create";
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 Guid Id { get; set; }
public long ChatId { get; set; }
public int? MessageThreadId { get; set; }
public long OwnerTelegramId { get; set; }
/// <summary>
/// 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 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 UpdatedAt { 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 async Task<WizardDraft?> GetActiveAsync(
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
{
const string sql = """
SELECT id AS Id,
chat_id AS ChatId,
message_thread_id AS MessageThreadId,
owner_telegram_id AS OwnerTelegramId,
owner_id AS OwnerId,
platform AS Platform,
step AS Step,
payload::text AS PayloadJson,
draft_message_id AS DraftMessageId,
@@ -23,17 +23,18 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
updated_at AS UpdatedAt,
expires_at AS ExpiresAt
FROM wizard_drafts
WHERE chat_id = @ChatId
AND (message_thread_id = @ThreadId OR (@ThreadId IS NULL AND message_thread_id IS NULL))
AND owner_telegram_id = @OwnerId
WHERE platform = @Platform
AND owner_id = @OwnerId
AND expires_at > NOW()
ORDER BY updated_at DESC
LIMIT 1
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
new CommandDefinition(sql,
new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId },
new CommandDefinition(
sql,
new { Platform = platform, OwnerId = ownerId },
cancellationToken: ct));
}
@@ -41,9 +42,9 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
{
const string sql = """
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
(@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
SET step = EXCLUDED.step,
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 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.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
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.
Assert.Single(messenger.Edits);
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);
}
@@ -2,7 +2,6 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
@@ -1,5 +1,4 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
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.Single(messenger.Edits);
@@ -36,7 +35,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
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.
Assert.Equal(WizardStepNames.Title, draft.Step);
@@ -51,7 +50,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
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);
}
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
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);
}
@@ -79,7 +78,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
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);
}
@@ -93,7 +92,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
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);
}
@@ -108,7 +107,7 @@ public sealed class GameCreationWizardCancelBackTests
drafts.Seed(draft);
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.Contains("cb-1", messenger.AnsweredCallbacks);
@@ -1,5 +1,4 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
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;
/// <summary>
/// Verifies the pool-specific branch of the wizard: the AddSlots flow that
/// builds up slot metadata through date and capacity steps.
/// Verifies the pool-specific branch of the wizard: the AddSlots flow
/// that builds up slot metadata through date and capacity steps.
/// </summary>
public sealed class GameCreationWizardPoolSlotTests
{
@@ -28,7 +27,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
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);
}
@@ -49,7 +48,7 @@ public sealed class GameCreationWizardPoolSlotTests
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
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);
}
@@ -61,7 +60,7 @@ public sealed class GameCreationWizardPoolSlotTests
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
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);
}
@@ -74,7 +73,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
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);
}
@@ -87,7 +86,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
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);
}
@@ -107,7 +106,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
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);
}
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
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);
}
@@ -150,10 +149,10 @@ public sealed class GameCreationWizardPoolSlotTests
drafts.Seed(draft);
// "add" then "done" — no date/capacity supplied in between.
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None);
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), ownerId: draft.OwnerId), draft, CancellationToken.None);
await wizard.HandleInteractionAsync(CallbackInteraction(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), ownerId: draft.OwnerId), draft, CancellationToken.None);
// The wizard sees the in-memory slot count > 0 and advances to confirm.
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
@@ -1,6 +1,5 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
@@ -41,7 +40,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
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.NotEmpty(drafts.Upserts); // was persisted
@@ -60,7 +59,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
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);
using var doc = JsonDocument.Parse(draft.PayloadJson);
@@ -84,7 +83,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
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);
}
@@ -110,7 +109,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
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.
Assert.NotEmpty(messenger.Edits);
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardStepTransitionsTests
drafts.Seed(draft);
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);
}
@@ -1,6 +1,5 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
@@ -20,7 +19,7 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.Title);
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);
}
@@ -32,8 +31,8 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var tooLong = new string('a', WizardStep.MaxTitleLength + 1);
await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None);
var tooLong = new string('a', WizardStepLimits.MaxTitleLength + 1);
await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
@@ -53,7 +52,7 @@ public sealed class GameCreationWizardValidationTests
drafts.Seed(draft);
// 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);
}
@@ -65,7 +64,7 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.DateTime);
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);
}
@@ -83,7 +82,7 @@ public sealed class GameCreationWizardValidationTests
var draft = NewDraft(WizardStepNames.Cover, payload);
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);
}
@@ -96,7 +95,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
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);
}
@@ -109,7 +108,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
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);
}
@@ -132,7 +131,7 @@ public sealed class GameCreationWizardValidationTests
});
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);
}
@@ -148,7 +147,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
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);
}
@@ -161,7 +160,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
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);
}
@@ -178,7 +177,7 @@ public sealed class GameCreationWizardValidationTests
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
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);
}
@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
@@ -30,7 +30,7 @@ public sealed class UpdateRouterDelegationTests
var draft = NewDraft(WizardStepNames.Title);
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);
@@ -49,7 +49,7 @@ public sealed class UpdateRouterDelegationTests
// "wizard:cancel" — wizard owns the cancel callback. The router
// delegates control-callbacks (resume/reset) but lets the wizard
// 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);
@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
@@ -36,8 +36,8 @@ public sealed class UpdateRouterResetsDraftOnStaleCommandTests
Message = new Message
{
Text = "/newsession",
Chat = new Chat { Id = draft.ChatId },
From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" },
Chat = new Chat { Id = long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture) },
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 (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
chat_id TEXT NOT NULL,
message_thread_id TEXT,
owner_id TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'Telegram',
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
draft_message_id TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
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
ON wizard_drafts(expires_at);
CREATE INDEX idx_wizard_drafts_platform
ON wizard_drafts(platform);
""",
connection);
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 Npgsql;
@@ -20,7 +23,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
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.Equal("Title", loaded!.Step);
}
@@ -35,7 +38,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
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);
}
@@ -49,7 +52,9 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
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);
}
@@ -69,16 +74,17 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
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);
}
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
ChatId = "42",
MessageThreadId = null,
OwnerTelegramId = 100,
OwnerId = "100",
Platform = "Telegram",
Step = step,
PayloadJson = "{}",
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()
{
Id = Guid.NewGuid(),
ChatId = 42,
ChatId = "42",
Step = step,
PayloadJson = "{}",
};
@@ -1,25 +1,26 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger;
using WizardBot = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Hand-rolled test doubles and helpers for wizard unit tests. The project
/// convention is to use fakes (not a mocking framework) so the suite stays
/// AOT-friendly and the production code doesn't grow virtual members just
/// for tests.
/// Hand-rolled test doubles and helpers for wizard unit tests. The
/// project convention is to use fakes (not a mocking framework) so the
/// suite stays AOT-friendly and the production code doesn't grow
/// virtual members just for tests.
/// </summary>
internal static class WizardTestFakes
{
public const string PlatformName = "Telegram";
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
@@ -30,11 +31,12 @@ internal static class WizardTestFakes
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
ChatId = "42",
MessageThreadId = null,
OwnerTelegramId = ownerId,
OwnerId = ownerId.ToString(CultureInfo.InvariantCulture),
Platform = PlatformName,
Step = step,
DraftMessageId = 7,
DraftMessageId = "7",
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload),
@@ -43,6 +45,63 @@ internal static class WizardTestFakes
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()
{
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()
{
Message = new Message
@@ -69,9 +133,9 @@ internal static class WizardTestFakes
}
/// <summary>
/// Records every call the wizard makes against the draft repository. Backed by
/// an in-memory dictionary so tests can pre-seed an "active" draft for the
/// wizard to mutate.
/// Records every call the wizard makes against the draft repository.
/// Backed by an in-memory dictionary so tests can pre-seed an "active"
/// draft for the wizard to mutate.
/// </summary>
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
{
@@ -85,13 +149,12 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
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)
{
if (d.ChatId == chatId &&
d.MessageThreadId == messageThreadId &&
d.OwnerTelegramId == ownerTelegramId &&
if (d.Platform == platform &&
d.OwnerId == ownerId &&
d.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<WizardDraft?>(d);
@@ -108,7 +171,8 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
Id = draft.Id,
ChatId = draft.ChatId,
MessageThreadId = draft.MessageThreadId,
OwnerTelegramId = draft.OwnerTelegramId,
OwnerId = draft.OwnerId,
Platform = draft.Platform,
Step = draft.Step,
PayloadJson = draft.PayloadJson,
DraftMessageId = draft.DraftMessageId,
@@ -136,11 +200,14 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
}
/// <summary>
/// Records every call the wizard makes against the messenger. Default return
/// values (empty clubs, message-id 1) match what the wizard expects to see
/// in steady state.
/// Records every call the wizard makes against the messenger. Default
/// return values (empty clubs, message-id 99) match what the wizard
/// 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>
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
internal sealed class FakeWizardMessenger : IWizardMessenger
{
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<(string OwnerId, IReadOnlyList<WizardAction> Actions)> EditActions { get; } = new();
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
public Task<long> EditMessageTextAsync(
long chatId,
int? messageThreadId,
long messageId,
public Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
InlineKeyboardMarkup keyboard,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
Edits.Add((chatId, messageThreadId, messageId, text));
return Task.FromResult(messageId);
Edits.Add((
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(
long chatId,
int? messageThreadId,
public Task<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
InlineKeyboardMarkup keyboard,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
Sends.Add((chatId, messageThreadId, text));
return Task.FromResult(99L);
Sends.Add((
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;
}
public Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
public Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
=> Task.FromResult(Clubs);
}