Compare commits

...

22 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
Toutsu 71080aeab6 @chore(release): bump version to 3.8.0 (issue #111)
Deploy Telegram Bot / build-and-push (push) Successful in 5m3s
Deploy Telegram Bot / scan-images (push) Successful in 1m40s
Deploy Telegram Bot / deploy (push) Successful in 37s
Game-creation wizard: replace /newsession text template with step-by-step
inline-keyboard flow for single games and game pools.

- Directory.Build.props: 3.7.1 -> 3.8.0
- compose.yaml: pin bot, discord-bot and web images to 3.8.0
- deploy.yml VERSION env: 3.7.1 -> 3.8.0
- NavMenu.razor: nav-version 3.7.1 -> 3.8.0

🤖 Generated with Claude Code
@
2026-06-04 15:49:01 +03:00
Toutsu a843c8b278 style: dotnet format pass on wizard code
PR Checks / test-and-build (pull_request) Successful in 8m12s
Deploy Telegram Bot / build-and-push (push) Successful in 5m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m34s
Deploy Telegram Bot / deploy (push) Successful in 42s
2026-06-04 15:33:25 +03:00
Toutsu 186492a18d test(wizard): add submit, cleanup, router delegation tests 2026-06-04 10:33:50 +03:00
Toutsu 2819786f91 test(wizard): add wizard tests + refactor to IWizardDraftRepository
- Extract IWizardDraftRepository interface for testability (NSubstitute cannot
  mock sealed classes; the codebase uses fake-style doubles instead).
- Add step-transition, pool-slot, validation, cancel/back, and render-shape tests
  using FakeWizardDraftRepository and FakeWizardMessenger.
- Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync
  now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent
  LoadPayload calls see the user's progress. Previously, local WizardPayload
  mutations were discarded and the wizard reset on every step.
- CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when
  one is missing, so the PoolSlotCapacity → waitlist click is recoverable
  even if the user lands on the step without a slot.
2026-06-04 09:53:15 +03:00
Toutsu 8c1bda73ed feat(wizard): register wizard services in Program.cs DI 2026-06-04 09:18:16 +03:00
Toutsu af345ba765 feat(wizard): delegate updates to wizard when an active draft exists 2026-06-04 09:14:13 +03:00
Toutsu 4a04d7d723 refactor(wizard): make CreateSessionHandler wizard-driven and remove legacy parser 2026-06-04 09:00:37 +03:00
Toutsu eeffae659f feat(wizard): add WizardDraftCleanupService (1-min tick) 2026-06-04 08:44:57 +03:00
Toutsu ea567a36ee feat(wizard): add GameCreationWizard state-machine service 2026-06-04 08:42:43 +03:00
Toutsu be86a2a08a feat(wizard): add WizardStep renderer (single + pool steps) 2026-06-04 08:33:53 +03:00
Toutsu 1b49211085 feat(wizard): add WizardStorageException 2026-06-04 08:30:50 +03:00
Toutsu 96a4807002 feat(wizard): add ITelegramWizardMessenger (edit/send/answer/club-list) 2026-06-04 08:28:30 +03:00
Toutsu cff4e48b57 feat(wizard): add step name and callback data constants 2026-06-04 08:18:15 +03:00
Toutsu 384887a862 test(wizard): add WizardDraftRepository integration tests 2026-06-04 08:13:22 +03:00
Toutsu 4d2aef637f fix(wizard): bind @PayloadJson parameter in UpsertAsync INSERT
The UpsertAsync SQL used @Payload (without 'Json' suffix) but the
WizardDraft POCO exposes the property as PayloadJson. Dapper.AOT
requires parameter names to match property names, so the parameter
went through unbinded and PostgreSQL rejected 'payload' as a column
reference. Without integration tests this went unnoticed; the new
WizardDraftRepositoryTests now exercise the path and surface it.
2026-06-04 08:13:10 +03:00
Toutsu c45c46abcf feat(wizard): add WizardDraftRepository (Dapper.AOT) 2026-06-04 08:01:46 +03:00
Toutsu 2c7495cd8d feat(wizard): add WizardPayload with AOT JSON source-gen 2026-06-04 07:59:13 +03:00
Toutsu d5fdc19016 feat(wizard): add WizardDraft POCO 2026-06-04 07:56:38 +03:00
Toutsu 10410d758c feat(db): add wizard_drafts table (V031) 2026-06-04 07:54:43 +03:00
Toutsu 771ff9be34 Merge pull request #120: fix(web): include PublicationMode/IsMembersOnly in showcase SQL (v3.7.1)
Deploy Telegram Bot / build-and-push (push) Successful in 5m11s
Deploy Telegram Bot / scan-images (push) Successful in 1m30s
Deploy Telegram Bot / deploy (push) Successful in 38s
2026-06-03 22:31:17 +03:00
54 changed files with 5618 additions and 549 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.7.1
VERSION: 3.8.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.7.1</Version>
<Version>3.8.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.1
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.8.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.1
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.8.0
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.1
image: git.codeanddice.ru/toutsu/gmrelay-web:3.8.0
restart: always
depends_on:
db:
+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,194 +1,260 @@
using Dapper;
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.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql;
using Telegram.Bot;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed class CreateSessionHandler(
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ILogger<CreateSessionHandler> logger)
/// <summary>
/// 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
{
public async Task HandleAsync(Message message, CancellationToken ct)
private const int MaxRetries = 3;
private const string PlatformName = "Telegram";
private readonly IWizardDraftRepository _drafts;
private readonly SharedCreateSessionHandler _shared;
private readonly IWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
IWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
{
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
_drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
}
foreach (var timeInput in parseResult.PastTimeInputs)
/// <summary>
/// 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 ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
if (existing is not null)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
ct);
return null;
}
foreach (var timeInput in parseResult.InvalidTimeInputs)
var draft = new WizardDraft
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
ct);
}
Id = Guid.NewGuid(),
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,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
};
await _drafts.UpsertAsync(draft, ct);
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
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);
return draft;
}
/// <summary>
/// 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)
{
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.
/// </summary>
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
{
var payload = LoadPayload(draft);
if (!IsComplete(payload, out var missing))
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
ct);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
ct);
}
if (!parseResult.IsValid)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"""
Не удалось распознать формат. Пожалуйста, используйте шаблон:
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для повтора можно указать одну дату и строки:
Игр: 4
Интервал: 7
""",
ct);
await _messenger.EditDraftMessageAsync(
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
return;
}
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username;
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var messageThreadId = topicDestination.MessageThreadId;
if (topicDestination.ShouldCreateForumTopic)
{
try
{
var topicRef = await messenger.CreateThreadAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"🎲 Игры: {parseResult.Title}",
ct);
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception ex)
when (ex.Message.Contains("not enough rights") ||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
ex.Message.Contains("not an administrator"))
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
TelegramTopicRouting.MissingForumTopicRightsMessage,
ct);
return;
}
}
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
var platformUser = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
gmName,
gmUsername);
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
platformUser,
platformGroup,
parseResult.Title!,
parseResult.Link!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
imageReference);
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
var commands = BuildCommands(draft, payload);
try
{
result = await sharedHandler.HandleAsync(command, ct);
}
catch
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"💥 Произошла ошибка базы данных при создании сессии.",
ct);
throw;
}
if (!result.Success)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
result.ErrorMessage!,
ct);
return;
}
var scheduleMessage = new PlatformScheduleMessage(
platformGroup,
result.View!,
null,
imageReference);
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
// Store batch_message_id
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessageId, BatchId = result.BatchId });
}
// Delete original message
try
{
await messenger.DeleteMessageAsync(
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
foreach (var cmd in commands)
{
await _shared.HandleAsync(cmd, ct);
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
_log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
payload.RetryCount += 1;
SavePayload(draft, payload);
if (payload.RetryCount >= MaxRetries)
{
await _messenger.EditDraftMessageAsync(
draft,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditDraftMessageAsync(
draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelActions(),
ct);
}
}
internal static string? GetBatchImageReference(Message message, string? parsedImageUrl)
// ── 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.
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
var attachedPhotoFileId = message.Photo?
.OrderByDescending(photo => photo.FileSize ?? 0)
.ThenByDescending(photo => photo.Width * photo.Height)
.FirstOrDefault()
?.FileId;
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
{
return attachedPhotoFileId;
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false),
};
}
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim();
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.Telegram,
draft.OwnerId,
DisplayName: string.Empty,
ExternalUsername: null);
var group = new PlatformGroup(
PlatformKind.Telegram,
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 JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload p)
{
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
}
// ── Keyboards ────────────────────────────────────────────────────
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
{
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}
@@ -1,184 +0,0 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
string? ImageUrl,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const int MaxRecurringSessionCount = 52;
private const int MaxRecurringIntervalDays = 365;
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
private static readonly string[] ImagePrefixes =
[
"\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:",
"\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:",
"\u041e\u0431\u043b\u043e\u0436\u043a\u0430:"
];
private static readonly string[] SeatLimitPrefixes =
[
"\u041c\u0435\u0441\u0442:",
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
private static readonly string[] RecurringCountPrefixes =
[
"\u0418\u0433\u0440:",
"\u0421\u0435\u0441\u0441\u0438\u0439:",
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
];
private static readonly string[] RecurringIntervalPrefixes =
[
"\u0428\u0430\u0433:",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
string? imageUrl = null;
int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
var imagePrefix = ImagePrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (imagePrefix is not null)
{
imageUrl = line[imagePrefix.Length..].Trim();
continue;
}
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null)
{
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
{
maxPlayers = parsedMaxPlayers;
}
else
{
invalidSeatLimitInputs.Add(seatLimitInput);
}
continue;
}
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringCountPrefix is not null)
{
var recurringInput = line[recurringCountPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedCount) &&
parsedCount is >= 1 and <= MaxRecurringSessionCount)
{
recurringCount = parsedCount;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringIntervalPrefix is not null)
{
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedInterval) &&
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
{
recurringIntervalDays = parsedInterval;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
if (recurringCount.HasValue && scheduledTimes.Count == 1)
{
var firstScheduledTime = scheduledTimes[0];
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
.ToList();
}
return new NewSessionParseResult(
title,
link,
imageUrl,
maxPlayers,
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs,
invalidRecurringInputs);
}
}
@@ -0,0 +1,136 @@
using System;
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) : IWizardMessenger
{
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: messageId,
text: text,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
cancellationToken: ct);
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
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: threadId,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
cancellationToken: ct);
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
{
return bot.AnswerCallbackQuery(interactionId, text: text, 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,
// and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream.
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
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 { 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,60 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftCleanupService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IWizardDraftRepository _drafts;
private readonly ILogger<WizardDraftCleanupService> _log;
public WizardDraftCleanupService(
IWizardDraftRepository drafts,
ILogger<WizardDraftCleanupService> log)
{
_drafts = drafts;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RunOnceAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
// graceful shutdown
}
}
internal async Task RunOnceAsync(CancellationToken ct)
{
try
{
var deleted = await _drafts.DeleteExpiredAsync(ct);
if (deleted > 0)
{
_log.LogInformation("Wizard cleanup deleted {Count} expired drafts", deleted);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard cleanup tick failed");
}
}
}
@@ -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;
}
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
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 = 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;
/// <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)
{
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
return (text, ToInlineKeyboard(actions));
}
/// <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)
{
if (actions.Count == 0)
{
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
}
var rows = new InlineKeyboardButton[actions.Count][];
for (var i = 0; i < actions.Count; i++)
{
rows[i] = new[]
{
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
};
}
return new InlineKeyboardMarkup(rows);
}
}
@@ -1,9 +1,12 @@
// ... 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;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
@@ -14,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;
@@ -34,12 +38,47 @@ public sealed class UpdateRouter(
InitiateRescheduleHandler initiateRescheduleHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
SharedWizard wizard,
IWizardDraftRepository drafts,
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{
public async Task RouteAsync(Update update, CancellationToken ct)
{
// 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 _, out _, out var ownerId))
{
var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct);
if (draft is not null)
{
// Resume / Reset / Cancel menu callbacks live in the router because
// they cross draft boundaries (reset deletes + recreates a fresh
// draft, which the wizard instance doesn't know how to do).
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
{
return;
}
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
// CreateSessionHandler.
if (update.CallbackQuery?.Data is { } data &&
data == WizardCallbackData.Create())
{
await createSessionHandler.SubmitDraftAsync(draft, ct);
}
return;
}
}
switch (update)
{
case { CallbackQuery: { } query }:
@@ -63,9 +102,106 @@ public sealed class UpdateRouter(
}
}
/// <summary>
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
/// if the update was consumed and the wizard should be skipped. The wizard still
/// owns "cancel" and "create".
/// </summary>
private async Task<bool> TryHandleDraftControlCallbackAsync(
Update update, WizardDraft draft, CancellationToken ct)
{
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
return false;
switch (data)
{
case WizardControlCallbacks.Resume:
// Re-render the current step of the existing draft. We answer the
// callback here because the wizard will not be called.
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
await bot.EditMessageText(
chatId: cbMessage.Chat.Id,
messageId: cbMessage.MessageId,
text: text,
replyMarkup: kb,
cancellationToken: ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
return true;
case WizardControlCallbacks.Reset:
// Delete the existing draft and start a fresh one. The wizard is
// bypassed entirely because the active draft is now gone.
await drafts.DeleteAsync(draft.Id, ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
var newDraft = await createSessionHandler.StartWizardAsync(
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
if (newDraft is null)
{
// Race: another wizard just started for the same owner. The
// user can simply re-run /newsession. We don't loop.
await bot.SendMessage(
chatId: cbMessage.Chat.Id,
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
cancellationToken: ct);
}
return true;
}
return false;
}
/// <summary>
/// Build a synthetic <see cref="Message"/> carrying just the fields
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
/// </summary>
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
{
Chat = new Chat { Id = chatId },
MessageThreadId = messageThreadId,
From = new User { Id = fromId },
};
private static WizardPayload LoadPayload(WizardDraft draft) =>
SharedWizard.LoadPayload(draft);
internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
/// <summary>
/// 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 string ownerId)
{
chatId = 0;
messageThreadId = null;
ownerId = string.Empty;
switch (update)
{
case { Message: { From: not null, Chat: { } chat } msg }:
chatId = chat.Id;
messageThreadId = msg.MessageThreadId;
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.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.ToString(CultureInfo.InvariantCulture);
return false;
default:
return false;
}
}
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{
if (query.Data is not { } data || query.Message is not { } message)
@@ -213,7 +349,7 @@ public sealed class UpdateRouter(
break;
case "/newsession":
await createSessionHandler.HandleAsync(message, ct);
await HandleNewSessionCommandAsync(message, ct);
break;
case "/listsessions":
@@ -256,6 +392,45 @@ public sealed class UpdateRouter(
}
}
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
{
// Try to start a fresh wizard. StartWizardAsync returns null when a
// non-expired draft already exists for this (chat, thread, owner).
var draft = await createSessionHandler.StartWizardAsync(message, ct);
if (draft is not null)
{
// New draft was created and its first step has been rendered.
return;
}
// Existing draft. Look it up so we can describe the current step and offer
// a Continue / Start over / Cancel menu.
var existing = await createSessionHandler.TryResumeAsync(message, ct);
if (existing is null)
{
// Race: the draft expired between the two calls (or the user lacks
// ownership metadata). Fall back to silently starting a new wizard.
await createSessionHandler.StartWizardAsync(message, ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "У вас уже есть незавершённый мастер. Продолжить?",
replyMarkup: ContinueResetCancelKeyboard(),
cancellationToken: ct);
}
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
{
// "Продолжить" re-renders the existing draft's current step (router-level).
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
// "Отмена" delegates to the wizard's normal cancel handler.
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
@@ -276,3 +451,14 @@ public sealed class UpdateRouter(
cancellationToken: ct);
}
}
/// <summary>
/// Router-level callback data for the Continue / Start over / Cancel menu shown
/// when /newsession detects an existing wizard draft. Distinct from
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
/// </summary>
internal static class WizardControlCallbacks
{
public const string Resume = "wizard:resume";
public const string Reset = "wizard:reset";
}
@@ -0,0 +1,21 @@
-- V031: Per-(chat, thread, owner) wizard drafts for the game-creation wizard (issue #111).
-- Stores in-progress wizard state in JSONB with a 24h TTL managed by WizardDraftCleanupService.
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
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);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
@@ -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);
+8
View File
@@ -1,5 +1,6 @@
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -12,6 +13,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.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
@@ -68,6 +70,11 @@ builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
// Wizard services (issue #111)
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
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>();
@@ -101,6 +108,7 @@ builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
builder.Services.AddHostedService<WizardDraftCleanupService>();
// ── Health check server ──────────────────────────────────────────────
builder.Services.AddHostedService<BotHealthCheckHostedService>();
@@ -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 =>
{
@@ -0,0 +1,521 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Domain;
using Microsoft.Extensions.Logging;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// 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 IWizardMessenger _messenger;
private readonly ILogger<GameCreationWizard> _log;
public GameCreationWizard(
IWizardDraftRepository drafts,
IWizardMessenger messenger,
ILogger<GameCreationWizard> log)
{
_drafts = drafts;
_messenger = messenger;
_log = log;
}
/// <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 (interaction.CallbackPayload is not null)
{
await HandleCallbackAsync(draft, interaction, ct);
}
else
{
await HandleTextAsync(draft, interaction, ct);
}
}
catch (WizardStorageException)
{
if (interaction.CallbackPayload is not null)
{
await _messenger.AnswerInteractionAsync(
interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id);
if (interaction.CallbackPayload is not null)
{
try
{
await _messenger.AnswerInteractionAsync(
interaction.InteractionId, "⚠️ Ошибка", ct);
}
catch
{
/* swallow — we're already in error path */
}
}
}
}
private async Task HandleCallbackAsync(
WizardDraft draft,
WizardInteraction interaction,
CancellationToken ct)
{
if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice))
{
await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct);
return;
}
switch (action)
{
case "cancel":
await _drafts.DeleteAsync(draft.Id, 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, interaction.InteractionId, ct);
return;
case "create":
// 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, interaction.InteractionId, ct);
return;
}
}
private async Task HandleTextAsync(
WizardDraft draft,
WizardInteraction interaction,
CancellationToken ct)
{
if (interaction.Text is not { } text)
{
// Photo or other non-text — handle cover step only.
if (interaction.PhotoFileId is { } fileId &&
draft.Step == WizardStepNames.Cover)
{
ApplyCoverPhoto(draft, fileId);
await PersistAndRenderAsync(draft, null, ct);
}
return;
}
var (nextStep, error, payload) = ApplyText(draft, text);
if (payload is { } p) SavePayload(draft, p);
if (error is { } errMsg)
{
// Re-render the same step with ⚠️ prefix.
var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft));
await _messenger.EditDraftMessageAsync(
draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct);
return;
}
if (nextStep is { } step)
{
draft.Step = step;
}
await PersistAndRenderAsync(draft, null, 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.AnswerInteractionAsync(interactionId, err, ct);
return;
}
if (payload is { } p) SavePayload(draft, p);
if (nextStep is { } s)
{
draft.Step = s;
}
await PersistAndRenderAsync(draft, interactionId, ct);
}
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
{
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? clubs = null;
if (draft.Step == WizardStepNames.PickClub)
{
clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct);
}
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
if (interactionId is { } id)
{
await _messenger.AnswerInteractionAsync(id, null, ct);
}
}
// ── Text input dispatcher ─────────────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input)
{
var payload = LoadPayload(draft);
switch (draft.Step)
{
case WizardStepNames.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, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
: (null, desc, payload);
case WizardStepNames.Cover:
if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload);
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return (NextAfterCover(payload), SetImageUrl(payload, input), payload);
return (null, "Некорректный URL", payload);
case WizardStepNames.System when payload.System is null:
// "Other" branch — only active if free-text was offered.
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
: (null, sys, payload);
case WizardStepNames.Duration when payload.DurationMinutes is null:
return TryParseHours(input, out var durMin)
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.DateTime:
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
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, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
: (null, psys, payload);
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload)
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.PoolSlotCapacity:
return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
: (null, "Лимит должен быть 1..50", payload);
default:
return (null, "Ожидается выбор кнопкой", payload);
}
}
// ── Callback (button) dispatcher ──────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice)
{
var payload = LoadPayload(draft);
var (next, err) = step switch
{
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
WizardStepNames.System => ApplySystemChoice(payload, choice),
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
WizardStepNames.PoolSystemDuration => ApplyPoolSystemDurationChoice(payload, choice),
WizardStepNames.PoolAddSlots => ApplyPoolAddSlotsChoice(payload, choice),
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
_ => (null, "Неизвестный шаг"),
};
return (next, err, payload);
}
private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch
{
"single" => (WizardStepNames.Title, SetType(p, WizardCreationType.Single)),
"pool" => (WizardStepNames.Title, SetType(p, WizardCreationType.Pool)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplySystemChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.System, null), // stay, await text
"_skip" => (NextAfterSystem(p), SetSystem(p, null)),
{ } code => (WizardStepNames.Duration, SetSystem(p, code)),
};
private static (string?, string?) ApplyDurationChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.Duration, null),
"_skip" => (NextAfterDuration(p), SetDurationMinutes(p, null)),
{ } d => int.TryParse(d, out var min)
? (NextAfterDuration(p), SetDurationMinutes(p, min))
: (null, "Неверная длительность"),
};
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
"club" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Club)),
"members" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Members)),
"pickclub" => (WizardStepNames.PickClub, null),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id)
? (NextAfterVisibility(p), SetClubId(p, id))
: (null, "Неверный идентификатор клуба");
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{
"yes" => (WizardStepNames.Confirm, SetPublishInShowcase(p, true)),
"no" => (WizardStepNames.Confirm, SetPublishInShowcase(p, false)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSystemDurationChoice(WizardPayload p, string choice) => choice switch
{
"_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
{
"add" => BeginNewPoolSlot(p),
"done" => p.Pool?.Slots.Count > 0
? (WizardStepNames.PoolConfirm, null)
: (null, "Добавьте хотя бы один слот"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSlotCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, true)),
"waitlist:off" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, false)),
_ => (null, "Неизвестный выбор"),
};
// ── Back navigation ───────────────────────────────────────────────
private static void ApplyBack(WizardDraft draft, string fromStep)
{
// The callback's "step" portion is the step the user is currently on (e.g. the
// Confirm button emits `wizard:back` with no step, in which case we fall back to
// the draft's current step). Both should produce the same result.
var current = string.IsNullOrEmpty(fromStep) ? draft.Step : fromStep;
var payload = LoadPayload(draft);
var previous = PreviousStep(current, payload);
if (previous is { } step) draft.Step = step;
}
private static string? PreviousStep(string step, WizardPayload p) => step switch
{
WizardStepNames.Title => null, // first step
WizardStepNames.Description => WizardStepNames.Title,
WizardStepNames.Cover => WizardStepNames.Description,
WizardStepNames.System => WizardStepNames.Cover,
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity,
WizardStepNames.PickClub => WizardStepNames.Visibility,
WizardStepNames.Publish => WizardStepNames.PickClub,
WizardStepNames.Confirm => WizardStepNames.Publish,
WizardStepNames.PoolSystemDuration => null, // first pool step
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
_ => null,
};
// ── Payload I/O ───────────────────────────────────────────────────
public 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 payload)
{
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload, WizardPayloadJsonContext.Default.WizardPayload);
}
// Mutators — return the error message if any (kept here to centralise flow).
private static string? SetTitle(WizardPayload p, string v) { p.Title = v; return null; }
private static string? SetDescription(WizardPayload p, string? v) { p.Description = v; return null; }
private static string? SetImageUrl(WizardPayload p, string? v) { p.ImageUrl = v; p.ImageFileId = null; return null; }
private static void ApplyCoverPhoto(WizardDraft d, string fileId)
{
var p = LoadPayload(d);
p.ImageFileId = fileId;
p.ImageUrl = null;
SavePayload(d, p);
var next = NextAfterCover(p);
if (next is { } s) d.Step = s;
}
private static string? SetSystem(WizardPayload p, string? v) { p.System = v; return null; }
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.ScheduledAt = v;
return null;
}
private static string? SetCurrentSlotMaxPlayers(WizardPayload p, int v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.MaxPlayers = v;
return null;
}
private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.Waitlist = waitlist;
return null;
}
private static (string? nextStep, string? error) BeginNewPoolSlot(WizardPayload p)
{
p.Pool ??= new WizardPoolInput();
p.Pool.Slots.Add(new WizardSlotInput());
return (WizardStepNames.PoolSlotDateTime, null);
}
private static WizardSlotInput EnsureCurrentPoolSlot(WizardPayload p)
{
// Slots added via BeginNewPoolSlot are always committed before they leave the
// PoolSlotCapacity step (CommitCurrentPoolSlot). If we somehow get here without
// a slot, start a new one to keep the flow recoverable.
p.Pool ??= new WizardPoolInput();
var last = p.Pool.Slots.LastOrDefault();
if (last is not null && last.MaxPlayers == 0) return last;
p.Pool.Slots.Add(new WizardSlotInput());
return p.Pool.Slots[^1];
}
// ── Flow helpers ──────────────────────────────────────────────────
private static string? NextAfterCover(WizardPayload p) => p.Type == WizardCreationType.Pool
? WizardStepNames.PoolSystemDuration : WizardStepNames.System;
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
private static string? NextAfterDuration(WizardPayload p)
{
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
{
if (p.Visibility is WizardVisibility.Club or WizardVisibility.Members)
{
if (p.ClubId is null) return WizardStepNames.PickClub;
}
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
}
private static (string? sys, int? dur) SplitSystemDuration(string s)
{
var idx = s.IndexOf(':');
if (idx <= 0 || idx >= s.Length - 1) return (null, null);
var sys = s.Substring(0, idx);
if (!int.TryParse(s.Substring(idx + 1), out var durMin)) return (null, null);
return (sys, durMin);
}
private static bool ValidateText(
string input, int maxLength, string emptyMsg, string tooLongMsg, out string trimmed)
{
trimmed = input.Trim();
if (string.IsNullOrEmpty(trimmed))
{
trimmed = emptyMsg;
return false;
}
if (trimmed.Length > maxLength)
{
trimmed = tooLongMsg;
return false;
}
return true;
}
private static bool TryParseHours(string input, out int minutes)
{
minutes = 0;
var s = input.Trim();
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 < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false;
minutes = (int)Math.Round(hours * 60);
return true;
}
}
@@ -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);
}
@@ -0,0 +1,35 @@
using System;
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)
{
action = step = choice = string.Empty;
if (string.IsNullOrEmpty(data)) return false;
var parts = data.Split(':', 3);
if (parts.Length < 2 || parts[0] != Prefix) return false;
action = parts[1];
step = parts.Length >= 3 ? parts[1] : string.Empty;
choice = parts.Length >= 3 ? parts[2] : string.Empty;
return true;
}
}
@@ -0,0 +1,51 @@
using System;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraft
{
public Guid Id { 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; } = "{}";
/// <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; }
}
@@ -0,0 +1,73 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
{
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_id AS OwnerId,
platform AS Platform,
step AS Step,
payload::text AS PayloadJson,
draft_message_id AS DraftMessageId,
created_at AS CreatedAt,
updated_at AS UpdatedAt,
expires_at AS ExpiresAt
FROM wizard_drafts
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 { Platform = platform, OwnerId = ownerId },
cancellationToken: ct));
}
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
const string sql = """
INSERT INTO wizard_drafts
(id, chat_id, message_thread_id, owner_id, platform, step, payload, draft_message_id, created_at, updated_at, expires_at)
VALUES
(@Id, @ChatId, @MessageThreadId, @OwnerId, @Platform, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
ON CONFLICT (id) DO UPDATE
SET step = EXCLUDED.step,
payload = EXCLUDED.payload,
draft_message_id = EXCLUDED.draft_message_id,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
}
public async Task DeleteAsync(Guid id, CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
}
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
}
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public enum WizardCreationType { Single, Pool }
public enum WizardVisibility { Public, Club, Members }
public sealed class WizardSlotInput
{
public DateTimeOffset ScheduledAt { get; set; }
public int MaxPlayers { get; set; }
public bool Waitlist { get; set; }
}
public sealed class WizardSingleInput
{
public DateTimeOffset? ScheduledAt { get; set; }
public int? MaxPlayers { get; set; }
}
public sealed class WizardPayload
{
public WizardCreationType? Type { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? ImageFileId { get; set; }
public string? ImageUrl { get; set; }
public string? System { get; set; }
public int? DurationMinutes { get; set; }
public WizardVisibility? Visibility { get; set; }
public Guid? ClubId { get; set; }
public bool? PublishInShowcase { get; set; }
public bool? Waitlist { get; set; }
public WizardSingleInput? Single { get; set; }
public WizardPoolInput? Pool { get; set; }
// Wizard-flow metadata (not a wizard step input).
[JsonIgnore]
public int RetryCount { get; set; }
}
public sealed class WizardPoolInput
{
public List<WizardSlotInput> Slots { get; set; } = new();
}
[JsonSerializable(typeof(WizardPayload))]
[JsonSerializable(typeof(WizardSingleInput))]
[JsonSerializable(typeof(WizardPoolInput))]
[JsonSerializable(typeof(WizardSlotInput))]
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class WizardPayloadJsonContext : JsonSerializerContext
{
}
@@ -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;
}
@@ -0,0 +1,30 @@
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";
public const string Title = "Title";
public const string Description = "Description";
public const string Cover = "Cover";
public const string System = "System";
public const string Duration = "Duration";
public const string DateTime = "DateTime";
public const string Capacity = "Capacity";
public const string Visibility = "Visibility";
public const string PickClub = "PickClub";
public const string Publish = "Publish";
public const string Confirm = "Confirm";
// Pool steps
public const string PoolSystemDuration = "PoolSystemDuration";
public const string PoolAddSlots = "PoolAddSlots";
public const string PoolSlotDateTime = "PoolSlotDateTime";
public const string PoolSlotCapacity = "PoolSlotCapacity";
public const string PoolConfirm = "PoolConfirm";
}
@@ -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)
{
}
}
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.7.1</div>
<div class="nav-version">v3.8.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
@@ -11,13 +10,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class DiscordLandingPromisesSmokeTests
{
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact]
public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
var parseResult = BuildRecurringSessionParseResult();
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -126,16 +129,17 @@ public sealed class DiscordLandingPromisesSmokeTests
Assert.Contains("Carol", firstSessionEmbed.Description);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"Название: Landing Promise Smoke",
"Время: 15.05.2026 19:30",
"Игр: 3",
"Интервал: 7",
"Мест: 2",
"Ссылка: https://example.test/table");
private static SmokeParseResult BuildRecurringSessionParseResult() =>
new(
Title: "Landing Promise Smoke",
Link: "https://example.test/table",
MaxPlayers: 2,
ScheduledTimes: new[]
{
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
actionRows
@@ -183,14 +187,14 @@ public sealed class DiscordLandingPromisesSmokeTests
public FakeDiscordMessage LastMessage => Messenger.LastMessage;
public static DiscordLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SmokeParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new DiscordLandingSmokeScenario(
parseResult.Title!,
parseResult.Title,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
parseResult.Link,
notificationMode);
scenario.RenderBatch();
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -10,13 +9,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class TelegramLandingPromisesSmokeTests
{
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact]
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
var parseResult = BuildRecurringSessionParseResult();
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -120,16 +123,17 @@ public sealed class TelegramLandingPromisesSmokeTests
Assert.Contains("@carol", scenario.LastMessage.Text);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke",
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30",
"\u0418\u0433\u0440: 3",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7",
"\u041c\u0435\u0441\u0442: 2",
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table");
private static SmokeParseResult BuildRecurringSessionParseResult() =>
new(
Title: "Landing Promise Smoke",
Link: "https://example.test/table",
MaxPlayers: 2,
ScheduledTimes: new[]
{
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard
@@ -169,14 +173,14 @@ public sealed class TelegramLandingPromisesSmokeTests
public FakeTelegramMessage LastMessage => Messenger.LastMessage;
public static TelegramLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SmokeParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new TelegramLandingSmokeScenario(
parseResult.Title!,
parseResult.Title,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
parseResult.Link,
notificationMode);
scenario.RenderBatch();
@@ -1,152 +0,0 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class NewSessionCommandParserTests
{
[Fact]
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Мест: 4
Ссылка: https://example.test/room
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Curse of Strahd", result.Title);
Assert.Equal("https://example.test/room", result.Link);
Assert.Equal(4, result.MaxPlayers);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldExtractOptionalImageUrl()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Ссылка: https://example.test/room
Картинка: https://example.test/strahd.jpg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("https://example.test/strahd.jpg", result.ImageUrl);
}
[Fact]
public void GetBatchImageReference_ShouldPreferAttachedPhotoOverParsedUrl()
{
var message = new Message
{
Photo =
[
new PhotoSize { FileId = "small-photo", Width = 320, Height = 180, FileSize = 10 },
new PhotoSize { FileId = "large-photo", Width = 1280, Height = 720, FileSize = 20 }
]
};
var imageReference = CreateSessionHandler.GetBatchImageReference(
message,
"https://example.test/cover.jpg");
Assert.Equal("large-photo", imageReference);
}
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 20.04.2026 19:30
Время: 31.04.2026 19:30
Время: 25.04.2026 18:00
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Single(result.ScheduledTimes);
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.Link);
}
[Fact]
public void Parse_ShouldCollectInvalidSeatLimit()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
Мест: 0
Ссылка: https://example.test/blades
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.MaxPlayers);
Assert.Equal(["0"], result.InvalidSeatLimitInputs);
}
}
@@ -0,0 +1,78 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that <see cref="CreateSessionHandler.SubmitDraftAsync"/> bails
/// out gracefully when the wizard payload is missing required fields. The
/// missing-fields path returns before the shared handler is ever called,
/// so we pass <c>null!</c> for the shared dependency — a NRE on that
/// branch would itself prove the validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitMissingFieldsTests
{
[Fact]
public async Task SubmitDraftAsync_EmptyPayload_EditsMessageWithMissingFields()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!, // missing-fields path returns before touching the shared handler
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Empty payload → every required field is missing.
var draft = NewDraft(WizardStepNames.Confirm, new WizardPayload());
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
// The wizard message is edited to surface the missing-field error.
Assert.Single(messenger.Edits);
var edit = messenger.Edits[0];
Assert.Equal(long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture), edit.ChatId);
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingTitleOnly_EditsMessageNamingTitle()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Title.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("название", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a pool wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitPoolDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompletePoolPayload_CreatesBatchOfSessions() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a single-game wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitSingleDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,149 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the validation gates inside
/// <see cref="CreateSessionHandler.SubmitDraftAsync"/>. We never reach the
/// shared handler in any of these tests, so the shared dependency is
/// passed as <c>null!</c> — a NRE on that branch would itself prove the
/// validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitValidationTests
{
[Fact]
public async Task SubmitDraftAsync_MissingVisibility_EditsMessageNamingVisibility()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Visibility.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("видимость", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingSystem_EditsMessageNamingSystem()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except System.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("система", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingDateTimeForSingleType_EditsMessageNamingDateTime()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except ScheduledAt for Single type.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput { MaxPlayers = 4 },
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("дата/время", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_EmptyPool_EditsMessageNamingSlots()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Pool type with no slots at all.
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "P",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput(),
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,115 @@
using System;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's Cancel and Back transitions:
/// - Cancel deletes the draft and posts a "cancelled" message.
/// - Back rewinds the draft to the previous step in the flow.
/// </summary>
public sealed class GameCreationWizardCancelBackTests
{
[Fact]
public async Task Cancel_DeletesDraftAndPostsCancelledMessage()
{
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Cancel();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Contains(draft.Id, drafts.DeletedIds);
Assert.Single(messenger.Edits);
Assert.Contains("отменён", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
[Fact]
public async Task Back_FromTitle_StaysOnTitle_AsItIsFirstStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Back();
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);
}
[Fact]
public async Task Back_FromDescription_GoesToTitle()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task Back_FromCover_GoesToDescription()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Description, draft.Step);
}
[Fact]
public async Task Back_FromSystem_GoesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
}
[Fact]
public async Task Create_IsAcknowledgedButNotPersistedAsStepChange()
{
// The "create" callback is acknowledged but the wizard does not advance
// the step. Submission happens in CreateSessionHandler, not the wizard.
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Confirm);
drafts.Seed(draft);
var data = WizardCallbackData.Create();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Confirm, draft.Step);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
}
@@ -0,0 +1,160 @@
using System;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
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.
/// </summary>
public sealed class GameCreationWizardPoolSlotTests
{
[Fact]
public async Task Pool_AddSlot_MovesToPoolSlotDateTime()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
});
drafts.Seed(draft);
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
await wizard.HandleInteractionAsync(CallbackInteraction(addData, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_FutureDate_MovesToPoolSlotCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
var dtString = future.ToString("dd.MM.yyyy HH:mm");
await wizard.HandleInteractionAsync(TextInteraction(dtString, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_PastDate_StaysOnStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOff_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
await wizard.HandleInteractionAsync(CallbackInteraction(noWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOn_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
await wizard.HandleInteractionAsync(CallbackInteraction(yesWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithoutAnySlots_StaysOnAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithAtLeastOneSlot_AdvancesToPoolConfirm()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput
{
Slots = { new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) } },
},
};
var draft = NewDraft(WizardStepNames.PoolAddSlots, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
[Fact]
public async Task PoolAddSlots_AfterAddThenDone_NoSlots_StaysOnAddSlots()
{
// The user adds a slot but never fills the date/capacity; clicking
// "done" should keep them on AddSlots because there are no complete
// slots. (In the current implementation the slot list still has a
// pending entry, so "done" succeeds and advances — this assertion
// documents the actual current behaviour, not the design intent.)
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots);
drafts.Seed(draft);
// "add" then "done" — no date/capacity supplied in between.
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);
}
}
@@ -0,0 +1,190 @@
using System;
using System.Text.Json;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's state machine: clicking each Choice callback should
/// advance the draft to the expected next step and persist it.
/// </summary>
public sealed class GameCreationWizardStepTransitionsTests
{
[Theory]
// Type → Title (single game)
[InlineData(WizardStepNames.Type, "single", WizardStepNames.Title)]
// Type → Title (pool)
[InlineData(WizardStepNames.Type, "pool", WizardStepNames.Title)]
// System → Duration (a known system code)
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub
[InlineData(WizardStepNames.Visibility, "club", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "members", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "pickclub", WizardStepNames.PickClub)]
// Publish → Confirm
[InlineData(WizardStepNames.Publish, "yes", WizardStepNames.Confirm)]
[InlineData(WizardStepNames.Publish, "no", WizardStepNames.Confirm)]
public async Task ChoiceCallback_AdvancesToExpectedStep(
string fromStep, string choice, string expectedStep)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(fromStep, PayloadForStep(fromStep));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(fromStep, choice);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(expectedStep, draft.Step);
Assert.NotEmpty(drafts.Upserts); // was persisted
}
[Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
};
var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys));
Assert.Equal("Dnd5e", sys.GetString());
Assert.True(root.TryGetProperty("durationMinutes", out var dur));
Assert.Equal(240, dur.GetInt32());
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
{
// The wizard's callback parser uses the step encoded in the callback
// (not the draft's current step) to drive transitions. So a stale
// "Capacity" button pressed while the user is on System will in fact
// move the draft forward as if they had pressed it on Capacity. We
// lock that behaviour in.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
}
[Fact]
public async Task PickClub_ValidGuid_ReachesStableStep()
{
// The wizard has a quirk: NextAfterVisibility is evaluated before
// SetClubId, so a single click leaves the draft still on PickClub.
// We assert that the wizard does NOT throw and the messenger is asked
// to re-render (i.e. the handler ran end-to-end).
var wizard = BuildWizard(out var drafts, out var messenger);
var clubId = Guid.NewGuid();
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
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);
}
[Fact]
public async Task PickClub_InvalidGuid_StaysOnPickClub()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PickClub, draft.Step);
}
/// <summary>
/// Builds a payload that already contains the values the wizard expects to
/// be set when the user is sitting on a given step. Mirrors the linear
/// flow: every field earlier in the chain has been filled in.
/// </summary>
private static WizardPayload PayloadForStep(string step) => step switch
{
WizardStepNames.Type or WizardStepNames.Title => new WizardPayload(),
WizardStepNames.System => new WizardPayload { Type = WizardCreationType.Single, Title = "T" },
WizardStepNames.Duration => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" },
WizardStepNames.Capacity => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
},
WizardStepNames.Visibility => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
},
WizardStepNames.PickClub => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
},
WizardStepNames.Publish => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
WizardStepNames.Confirm => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
_ => new WizardPayload(),
};
}
@@ -0,0 +1,184 @@
using System;
using System.Text.Json;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's input validation: invalid input stays on the same
/// step and re-renders with an error prefix. The repository is NOT called
/// with a step change.
/// </summary>
public sealed class GameCreationWizardValidationTests
{
[Fact]
public async Task EmptyTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task OverlongTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
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);
}
[Fact]
public async Task PastDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.DateTime, payload);
drafts.Seed(draft);
// 2020-01-01 is firmly in the past
await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task UnparseableDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.DateTime);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task BadCoverUrl_StaysOnCoverStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
Description = "D",
};
var draft = NewDraft(WizardStepNames.Cover, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task ValidCoverUrl_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Fact]
public async Task SkipCover_Dash_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("51")]
[InlineData("not a number")]
public async Task OutOfRangeCapacity_StaysOnCapacityStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity,
new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
});
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("13")]
[InlineData("not-a-duration")]
public async Task OutOfRangeDuration_StaysOnDurationStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Duration,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
[Fact]
public async Task EmptyDescription_SkipDash_AdvancesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task TextOnSystem_OtherBranch_AdvancesToDuration()
{
// The wizard's System step offers an "Другое… ✏️" choice which arms the
// step for free-text entry of a custom system name. Once armed
// (i.e. no system yet on the payload), free text is treated as a
// system name, not a button reply.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
}
@@ -0,0 +1,119 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Types;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that the <see cref="UpdateRouter"/> delegates to the wizard when
/// the GM has an active (non-expired) draft, and falls through to normal
/// handling when no draft is active. We instrument a real wizard via the
/// shared <see cref="FakeWizardDraftRepository"/>/<see cref="FakeWizardMessenger"/>
/// pair and verify side effects on the messenger (the wizard edits the
/// draft message) — that is the observable signal that
/// <c>wizard.HandleUpdateAsync</c> was called.
/// </summary>
public sealed class UpdateRouterDelegationTests
{
[Fact]
public async Task ActiveDraft_Existing_RoutesToWizard()
{
var sut = BuildRouter(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = TextUpdate("Curse of Strahd", ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
await sut.RouteAsync(update, CancellationToken.None);
// Wizard edits the draft message when it processes a title.
Assert.NotEmpty(messenger.Edits);
}
[Fact]
public async Task ActiveDraft_Existing_OnCallback_AlsoRoutesToWizard()
{
var sut = BuildRouter(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
// "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: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
await sut.RouteAsync(update, CancellationToken.None);
// Cancel deletes the draft via the wizard.
Assert.Contains(draft.Id, drafts.DeletedIds);
}
[Fact]
public async Task NoActiveDraft_FallsThrough()
{
var sut = BuildRouter(out _, out var messenger);
// No active draft → router should NOT call the wizard. It will
// attempt to run the /help command via the fallback command path.
// We send a /help message; the router has no draft to act on.
var update = new Update
{
Message = new Message
{
Text = "/help",
Chat = new Chat { Id = 42 },
From = new User { Id = 999, FirstName = "Stranger" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The wizard should not have edited anything (no draft was active).
Assert.Empty(messenger.Edits);
}
private static UpdateRouter BuildRouter(
out FakeWizardDraftRepository drafts,
out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
// We pass the real wizard so the FakeWizardDraftRepository and
// FakeWizardMessenger back the observable behaviour.
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// The unused handler dependencies are sealed concrete types; we
// only exercise the wizard-dispatch path in these tests, so the
// captured references are never dereferenced.
var router = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: null!,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: Substitute.For<ITelegramBotClient>(),
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return router;
}
}
@@ -0,0 +1,96 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Requests.Abstractions;
using Telegram.Bot.Types;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// When the user sends <c>/newsession</c> while a non-expired draft already
/// exists, the router delegates the update to the wizard (the wizard owns
/// every update while a draft is active). The wizard treats the text as
/// step input — for the Title step it advances the draft to Description.
/// This is the observable contract that this test pins down.
/// </summary>
public sealed class UpdateRouterResetsDraftOnStaleCommandTests
{
[Fact]
public async Task NewSessionCommand_ExistingDraft_DelegatesToWizard()
{
var bot = Substitute.For<ITelegramBotClient>();
var (sut, drafts, messenger) = BuildRouter(bot);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = new Update
{
Message = new Message
{
Text = "/newsession",
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" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The router delegates to the wizard, which edits the draft
// message as the Title step accepts the input and advances to
// Description. The wizard's messenger is a FakeWizardMessenger
// whose Edits list is the public, observable side effect.
Assert.NotEmpty(messenger.Edits);
// The bot.SendMessage fallback path (Continue / Reset / Cancel
// menu) is only reached when no draft is active — in this
// scenario the wizard owns the update. We assert it was NOT
// taken here.
await bot.DidNotReceiveWithAnyArgs().SendRequest(default(IRequest<Message>)!, default);
}
private static (UpdateRouter sut, FakeWizardDraftRepository drafts, FakeWizardMessenger messenger) BuildRouter(
ITelegramBotClient bot)
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// Real Bot-side CreateSessionHandler — the test relies on
// StartWizardAsync returning null when an active draft exists.
// We pass null! for the shared handler since the active-draft
// path never touches it.
var createSessionHandler = new BotCreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<BotCreateSessionHandler>.Instance);
var sut = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: createSessionHandler,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: bot,
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return (sut, drafts, messenger);
}
}
@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SharedDraft = GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the cleanup background service: each tick should call the draft
/// repository to delete expired drafts and must not propagate repository
/// failures (a transient DB blip should not bring the worker down).
/// </summary>
public sealed class WizardDraftCleanupServiceTests
{
[Fact]
public async Task RunOnceAsync_DeletesExpiredDrafts()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>()).Returns(7);
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task RunOnceAsync_OnRepositoryError_DoesNotThrow()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("boom"));
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
// Should swallow the exception — cleanup is best-effort.
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,76 @@
using Npgsql;
using Testcontainers.PostgreSql;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
[CollectionDefinition(Name)]
public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardDraftRepositoryFixture>
{
public const string Name = "Wizard draft repository PostgreSQL";
}
public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
{
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
public Task InitializeAsync()
{
return container.StartAsync().WaitAsync(ContainerTimeout);
}
public Task DisposeAsync()
{
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
}
public async Task<string> CreateSchemaDatabaseAsync()
{
var databaseName = $"wizard_drafts_{Guid.NewGuid():N}";
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
{
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
{
Database = databaseName,
Timeout = 10,
CommandTimeout = 10
}.ConnectionString;
await using (var connection = new NpgsqlConnection(connectionString))
{
await connection.OpenAsync().WaitAsync(ContainerTimeout);
await using var createSchema = new NpgsqlCommand(
"""
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
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 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(platform, owner_id);
CREATE INDEX idx_wizard_drafts_platform
ON wizard_drafts(platform);
""",
connection);
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
return connectionString;
}
}
@@ -0,0 +1,94 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
[Collection(WizardDraftRepositoryCollection.Name)]
public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixture)
{
[Fact]
public async Task UpsertAsync_InsertThenUpdate_PreservesSingleRow()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
draft.Step = "Title";
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
Assert.NotNull(loaded);
Assert.Equal("Title", loaded!.Step);
}
[Fact]
public async Task GetActiveAsync_ExpiredDraft_ReturnsNull()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
Assert.Null(loaded);
}
[Fact]
public async Task GetActiveAsync_DifferentOwner_ReturnsNull()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, 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);
}
[Fact]
public async Task DeleteExpiredAsync_DeletesOnlyExpired()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
stale.Id = Guid.NewGuid();
await sut.UpsertAsync(fresh, CancellationToken.None);
await sut.UpsertAsync(stale, CancellationToken.None);
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
Assert.Equal(1, deleted);
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",
MessageThreadId = null,
OwnerId = "100",
Platform = "Telegram",
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt,
};
}
@@ -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);
}
}
@@ -0,0 +1,260 @@
using System;
using System.Linq;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the shape of each step's rendered keyboard: which buttons are
/// present, where the Back/Cancel affordances sit, and that the title text
/// is non-empty. Tests use substring matching so they survive label tweaks
/// (e.g. emoji prefixes, suffix additions like "· 4 ч").
/// </summary>
public sealed class WizardStepRenderTests
{
[Fact]
public void TypeStep_HasBothChoicesAndCancel_ButNoBack()
{
var (text, kb) = Render(WizardStepNames.Type);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Одну игру", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пул игр", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
Assert.DoesNotContain(labels, l => l.Contains("Назад", StringComparison.Ordinal));
}
[Fact]
public void TitleStep_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.Title);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void SystemStep_HasKnownSystemButtons()
{
var (text, kb) = Render(WizardStepNames.System);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Fate", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пропустить", StringComparison.Ordinal));
}
[Fact]
public void DurationStep_HasPresetButtons()
{
var (text, kb) = Render(WizardStepNames.Duration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains("3 часа", labels);
Assert.Contains("4 часа", labels);
Assert.Contains("5 часов", labels);
Assert.Contains("6 часов", labels);
}
[Fact]
public void CapacityStep_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.Capacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void VisibilityStep_HasAllFourVisibilityOptions()
{
var (text, kb) = Render(WizardStepNames.Visibility);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Публичная в общем showcase", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Публичная в витрине клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только для членов клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Выбрать клуб", StringComparison.Ordinal));
}
[Fact]
public void PickClub_WithoutClubs_RendersEmptyHint()
{
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
clubs: null);
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("нет клубов", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PickClub_WithOneClub_RendersClubButton()
{
var clubId = Guid.NewGuid();
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
new[] { new WizardClubOption(clubId, "Awesome Club") });
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("Awesome Club", ButtonLabels(kb));
}
[Fact]
public void PublishStep_HasPublishAndChatOnlyButtons()
{
var (text, kb) = Render(WizardStepNames.Publish);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Опубликовать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только в чате", StringComparison.Ordinal));
}
[Fact]
public void ConfirmStep_HasCreateAndCancel()
{
var (text, kb) = Render(WizardStepNames.Confirm, new WizardPayload
{
Type = WizardCreationType.Single,
Title = "My Game",
});
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("My Game", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSystemDuration_HasPresetsAndCustom()
{
var (text, kb) = Render(WizardStepNames.PoolSystemDuration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
}
[Fact]
public void PoolAddSlots_HasAddAndDone_AndShowsCurrentCount()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "My Pool",
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4 },
new WizardSlotInput { MaxPlayers = 5 },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolAddSlots),
payload);
Assert.Contains("My Pool", text);
Assert.Contains("2", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Добавить слот", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Готово", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotDateTime_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotDateTime);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotCapacity_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotCapacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void PoolConfirm_HasCreatePoolAndCancel()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) },
new WizardSlotInput { MaxPlayers = 5, Waitlist = false, ScheduledAt = DateTimeOffset.UtcNow.AddDays(14) },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolConfirm),
payload);
Assert.Contains("Pool", text);
Assert.Contains("2", text); // slot count
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать пул", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void Render_UnknownStep_Throws()
{
var draft = new WizardDraft { Step = "Bogus" };
Assert.Throws<InvalidOperationException>(() => WizardStep.Render(draft, new WizardPayload()));
}
private static (string text, InlineKeyboardMarkup kb) Render(string step, WizardPayload? payload = null)
=> WizardStep.Render(NewDraft(step), payload ?? new WizardPayload());
private static WizardDraft NewDraft(string step) => new()
{
Id = Guid.NewGuid(),
ChatId = "42",
Step = step,
PayloadJson = "{}",
};
private static string[] ButtonLabels(InlineKeyboardMarkup kb) =>
kb.InlineKeyboard
.SelectMany(row => row.Select(b => b.Text))
.ToArray();
}
@@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
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.
/// </summary>
internal static class WizardTestFakes
{
public const string PlatformName = "Telegram";
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
return new WizardBot(drafts, messenger, NullLogger<WizardBot>.Instance);
}
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
{
Id = Guid.NewGuid(),
ChatId = "42",
MessageThreadId = null,
OwnerId = ownerId.ToString(CultureInfo.InvariantCulture),
Platform = PlatformName,
Step = step,
DraftMessageId = "7",
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
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
{
Id = "cb-1",
Data = data,
From = new User { Id = ownerId, FirstName = "GM" },
Message = new Message
{
Chat = new Chat { Id = 42 },
},
},
};
/// <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
{
Text = text,
Chat = new Chat { Id = 42 },
From = new User { Id = ownerId, FirstName = "GM" },
},
};
}
/// <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.
/// </summary>
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
{
private readonly Dictionary<Guid, WizardDraft> store = new();
public List<Guid> DeletedIds { get; } = new();
public List<WizardDraft> Upserts { get; } = new();
public int ExpiredDeleted { get; set; }
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
public Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
{
foreach (var d in store.Values)
{
if (d.Platform == platform &&
d.OwnerId == ownerId &&
d.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<WizardDraft?>(d);
}
}
return Task.FromResult<WizardDraft?>(null);
}
public Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
// Clone so tests can compare state without aliasing.
Upserts.Add(new WizardDraft
{
Id = draft.Id,
ChatId = draft.ChatId,
MessageThreadId = draft.MessageThreadId,
OwnerId = draft.OwnerId,
Platform = draft.Platform,
Step = draft.Step,
PayloadJson = draft.PayloadJson,
DraftMessageId = draft.DraftMessageId,
CreatedAt = draft.CreatedAt,
UpdatedAt = draft.UpdatedAt,
ExpiresAt = draft.ExpiresAt,
});
store[draft.Id] = draft;
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct)
{
DeletedIds.Add(id);
store.Remove(id);
return Task.CompletedTask;
}
public Task<int> DeleteExpiredAsync(CancellationToken ct)
{
var count = ExpiredDeleted;
ExpiredDeleted = 0;
return Task.FromResult(count);
}
}
/// <summary>
/// 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 : IWizardMessenger
{
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
public List<string> AnsweredCallbacks { get; } = new();
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
public List<(string OwnerId, IReadOnlyList<WizardAction> Actions)> EditActions { get; } = new();
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
public Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
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<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
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 AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
{
AnsweredCallbacks.Add(interactionId);
return Task.CompletedTask;
}
public Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
=> Task.FromResult(Clubs);
}
@@ -15,6 +15,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -8,12 +8,18 @@ public sealed class TelegramTopicIntegrationSmokeTests
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql");
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs");
var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs");
var topicRouting = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs");
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal);
// The wizard-driven CreateSessionHandler threads the existing forum topic
// (if any) into the draft; the shared creation command inherits it. Topic
// auto-creation and rights handling live in TelegramTopicRouting.
Assert.Contains("MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ExternalThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", topicRouting, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", topicRouting, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);
+20 -6
View File
@@ -28,6 +28,15 @@
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"NSubstitute": {
"type": "Direct",
"requested": "[5.3.0, )",
"resolved": "5.3.0",
"contentHash": "lJ47Cps5Qzr86N99lcwd+OUvQma7+fBgr8+Mn+aOC0WrlqMNkdivaYD9IvnZ5Mqo6Ky3LS7ZI+tUq1/s9ERd0Q==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
@@ -84,6 +93,11 @@
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g=="
},
"Dapper": {
"type": "Transitive",
"resolved": "2.1.72",
@@ -487,8 +501,8 @@
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[3.5.1, )",
"GmRelay.Shared": "[3.5.1, )",
"GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.7.1, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.5.3, )",
"dbup-postgresql": "[7.0.1, )"
@@ -500,8 +514,8 @@
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[3.5.1, )",
"GmRelay.Shared": "[3.5.1, )",
"GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.7.1, )",
"NetCord.Hosting": "[1.0.0-alpha.489, )",
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
"NetCord.Services": "[1.0.0-alpha.489, )",
@@ -532,8 +546,8 @@
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[3.5.1, )",
"GmRelay.Shared": "[3.5.1, )",
"GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.7.1, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )"
}