Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b81d865832 | |||
| 8f0f2ef7e7 | |||
| 71080aeab6 | |||
| a843c8b278 | |||
| 186492a18d | |||
| 2819786f91 | |||
| 8c1bda73ed | |||
| af345ba765 | |||
| 4a04d7d723 | |||
| eeffae659f | |||
| ea567a36ee | |||
| be86a2a08a | |||
| 1b49211085 | |||
| 96a4807002 | |||
| cff4e48b57 | |||
| 384887a862 | |||
| 4d2aef637f | |||
| c45c46abcf | |||
| 2c7495cd8d | |||
| d5fdc19016 | |||
| 10410d758c | |||
| 771ff9be34 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.7.1
|
||||
VERSION: 3.8.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
-152
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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.");
|
||||
}
|
||||
+19
@@ -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.");
|
||||
}
|
||||
+149
@@ -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);
|
||||
}
|
||||
}
|
||||
+115
@@ -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);
|
||||
}
|
||||
}
|
||||
+160
@@ -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);
|
||||
}
|
||||
}
|
||||
+190
@@ -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(),
|
||||
};
|
||||
}
|
||||
+184
@@ -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);
|
||||
}
|
||||
}
|
||||
+119
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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>());
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+94
@@ -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,
|
||||
};
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
using Telegram.Bot.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Telegram <c>Update</c> → <c>WizardInteraction</c> mapping
|
||||
/// that <see cref="WizardInteractionMapper"/> exposes. The mapper is the
|
||||
/// single bridge between Telegram's native update type and the
|
||||
/// platform-neutral wizard core, so its contract needs to be locked
|
||||
/// down: callback queries carry the data payload, text messages carry
|
||||
/// their text, and photos carry the largest photo's <c>FileId</c>.
|
||||
/// </summary>
|
||||
public sealed class WizardInteractionMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void CallbackUpdate_ProducesCallbackInteraction_WithPayloadAndOwner()
|
||||
{
|
||||
var update = new Update
|
||||
{
|
||||
CallbackQuery = new CallbackQuery
|
||||
{
|
||||
Id = "cb-42",
|
||||
Data = "wizard:choice:Type:single",
|
||||
From = new User { Id = 100, FirstName = "GM" },
|
||||
Message = new Message { Chat = new Chat { Id = 42 } },
|
||||
},
|
||||
};
|
||||
|
||||
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("100", interaction.OwnerId);
|
||||
Assert.Null(interaction.Text);
|
||||
Assert.Equal("wizard:choice:Type:single", interaction.CallbackPayload);
|
||||
Assert.Null(interaction.PhotoFileId);
|
||||
Assert.Null(interaction.PhotoUrl);
|
||||
Assert.Equal("cb-42", interaction.InteractionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextUpdate_ProducesTextInteraction_WithTextAndNoCallback()
|
||||
{
|
||||
var update = new Update
|
||||
{
|
||||
Message = new Message
|
||||
{
|
||||
Text = "My Game Title",
|
||||
Chat = new Chat { Id = 42 },
|
||||
From = new User { Id = 200, FirstName = "GM" },
|
||||
},
|
||||
};
|
||||
|
||||
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("200", interaction.OwnerId);
|
||||
Assert.Equal("My Game Title", interaction.Text);
|
||||
Assert.Null(interaction.CallbackPayload);
|
||||
Assert.Null(interaction.PhotoFileId);
|
||||
Assert.Equal("msg", interaction.InteractionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhotoUpdate_ProducesPhotoInteraction_WithLargestFileId()
|
||||
{
|
||||
var update = new Update
|
||||
{
|
||||
Message = new Message
|
||||
{
|
||||
Chat = new Chat { Id = 42 },
|
||||
From = new User { Id = 300, FirstName = "GM" },
|
||||
Photo = new[]
|
||||
{
|
||||
new PhotoSize { FileId = "small-id", Width = 90, Height = 60 },
|
||||
new PhotoSize { FileId = "medium-id", Width = 320, Height = 240 },
|
||||
new PhotoSize { FileId = "large-id", Width = 800, Height = 600 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("300", interaction.OwnerId);
|
||||
Assert.Null(interaction.Text);
|
||||
Assert.Null(interaction.CallbackPayload);
|
||||
Assert.Equal("large-id", interaction.PhotoFileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptionedPhoto_ProducesPhotoInteraction_AndKeepsCaptionOutOfText()
|
||||
{
|
||||
// Telegram sometimes attaches a caption to a photo message. The
|
||||
// mapper treats it as a non-text interaction (cover-step uses
|
||||
// PhotoFileId, not caption). This test pins that distinction.
|
||||
var update = new Update
|
||||
{
|
||||
Message = new Message
|
||||
{
|
||||
Caption = "ignored",
|
||||
Chat = new Chat { Id = 42 },
|
||||
From = new User { Id = 400 },
|
||||
Photo = new[]
|
||||
{
|
||||
new PhotoSize { FileId = "only-id", Width = 100, Height = 100 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var ok = WizardInteractionMapper.TryMap(update, out var interaction);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("only-id", interaction.PhotoFileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyUpdate_ReturnsFalse()
|
||||
{
|
||||
var ok = WizardInteractionMapper.TryMap(new Update(), out var interaction);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(interaction);
|
||||
}
|
||||
}
|
||||
+260
@@ -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" />
|
||||
|
||||
+10
-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);
|
||||
|
||||
@@ -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, )"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user