diff --git a/deliverable.md b/deliverable.md new file mode 100644 index 0000000..eada7dd --- /dev/null +++ b/deliverable.md @@ -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 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)` 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). diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 0bf9f19..798e384 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -1,40 +1,41 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Platform; using Microsoft.Extensions.Logging; using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler; namespace GmRelay.Bot.Features.Sessions.CreateSession; /// -/// Wizard-driven entry point for game-session creation. Replaces the legacy -/// text-template parser. Exposes (called from -/// /newsession), (continue a draft), and -/// (finalize on "✅ Создать" callback). +/// Telegram-side entry point for the wizard-driven session creation +/// flow. Talks to the shared wizard through +/// and the platform-neutral . Keeps the +/// platform glue (mapping Message to draft fields, rendering +/// error keyboards, etc.) local to GmRelay.Bot. /// public sealed class CreateSessionHandler { private const int MaxRetries = 3; + private const string PlatformName = "Telegram"; private readonly IWizardDraftRepository _drafts; private readonly SharedCreateSessionHandler _shared; - private readonly ITelegramWizardMessenger _messenger; + private readonly IWizardMessenger _messenger; private readonly ILogger _log; public CreateSessionHandler( IWizardDraftRepository drafts, SharedCreateSessionHandler shared, - ITelegramWizardMessenger messenger, + IWizardMessenger messenger, ILogger log) { _drafts = drafts; @@ -44,14 +45,14 @@ public sealed class CreateSessionHandler } /// - /// Entry point for /newsession. If a non-expired draft already exists for - /// this (chat, thread, owner), returns null so the caller can render a - /// "Continue / Start over / Cancel" menu. + /// Entry point for /newsession. If a non-expired draft + /// already exists for this owner, returns null so the caller + /// can render a "Continue / Start over / Cancel" menu. /// public async Task StartWizardAsync(Message message, CancellationToken ct) { - var existing = await _drafts.GetActiveAsync( - message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct); + var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture); + var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct); if (existing is not null) { return null; @@ -60,9 +61,10 @@ public sealed class CreateSessionHandler var draft = new WizardDraft { Id = Guid.NewGuid(), - ChatId = message.Chat.Id, - MessageThreadId = message.MessageThreadId, - OwnerTelegramId = message.From?.Id ?? 0, + ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture), + MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture), + OwnerId = ownerId, + Platform = PlatformName, Step = WizardStepNames.Type, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, @@ -70,9 +72,8 @@ public sealed class CreateSessionHandler }; await _drafts.UpsertAsync(draft, ct); - var (text, kb) = WizardStep.Render(draft, new WizardPayload()); - var msgId = await _messenger.SendGroupMessageAsync( - draft.ChatId, draft.MessageThreadId, text, kb, ct); + var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload()); + var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct); draft.DraftMessageId = msgId; draft.UpdatedAt = DateTimeOffset.UtcNow; await _drafts.UpsertAsync(draft, ct); @@ -80,24 +81,27 @@ public sealed class CreateSessionHandler } /// - /// Resume an existing draft — returns the draft row so the caller can re-render. + /// Resume an existing draft — returns the draft row so the caller + /// can re-render the resume/reset menu. /// - public Task TryResumeAsync(Message message, CancellationToken ct) => - _drafts.GetActiveAsync( - message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct); + public Task TryResumeAsync(Message message, CancellationToken ct) + { + var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture); + return _drafts.GetActiveAsync(PlatformName, ownerId, ct); + } /// - /// Finalize: build shared command(s), call the shared handler, edit the wizard message. - /// On failure, retry up to times before deleting the draft. + /// Finalize: build shared command(s), call the shared handler, edit + /// the wizard message. On failure, retry up to + /// times before deleting the draft. /// public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct) { var payload = LoadPayload(draft); if (!IsComplete(payload, out var missing)) { - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, - $"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct); + await _messenger.EditDraftMessageAsync( + draft, $"❌ Не заполнены поля: {missing}", Array.Empty(), ct); return; } @@ -109,10 +113,11 @@ public sealed class CreateSessionHandler await _shared.HandleAsync(cmd, ct); } var totalSessions = commands.Sum(c => c.ScheduledTimes.Count); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + await _messenger.EditDraftMessageAsync( + draft, $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}", - EmptyKeyboard(), ct); + Array.Empty(), + ct); await _drafts.DeleteAsync(draft.Id, ct); } catch (Exception ex) @@ -122,26 +127,29 @@ public sealed class CreateSessionHandler SavePayload(draft, payload); if (payload.RetryCount >= MaxRetries) { - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + await _messenger.EditDraftMessageAsync( + draft, "💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.", - EmptyKeyboard(), ct); + Array.Empty(), + ct); await _drafts.DeleteAsync(draft.Id, ct); return; } draft.UpdatedAt = DateTimeOffset.UtcNow; await _drafts.UpsertAsync(draft, ct); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, + await _messenger.EditDraftMessageAsync( + draft, $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", - RetryCancelKeyboard(), ct); + RetryCancelActions(), + ct); } } // ── Build shared commands ──────────────────────────────────────── - // The shared handler creates one session per scheduled time in a single transaction - // and assigns the same batch_id to all of them. A wizard pool therefore produces ONE - // command with N times; a single-game wizard produces ONE command with one time. + // The shared handler creates one session per scheduled time in a + // single transaction and assigns the same batch_id to all of them. + // A wizard pool therefore produces ONE command with N times; a + // single-game wizard produces ONE command with one time. private static List BuildCommands(WizardDraft draft, WizardPayload p) { if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0) @@ -153,7 +161,7 @@ public sealed class CreateSessionHandler p, pool.Slots.Select(s => s.ScheduledAt).ToList(), MaxPlayersForPool(pool), - isOneShot: false) + isOneShot: false), }; } return new List @@ -163,7 +171,7 @@ public sealed class CreateSessionHandler p, new[] { p.Single?.ScheduledAt ?? default }, p.Single?.MaxPlayers ?? 0, - isOneShot: true) + isOneShot: true), }; } @@ -177,18 +185,17 @@ public sealed class CreateSessionHandler int maxPlayers, bool isOneShot) { - var gmId = draft.OwnerTelegramId; var user = new PlatformUser( PlatformKind.Telegram, - gmId.ToString(System.Globalization.CultureInfo.InvariantCulture), + draft.OwnerId, DisplayName: string.Empty, ExternalUsername: null); var group = new PlatformGroup( PlatformKind.Telegram, - draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture), + draft.ChatId, DisplayName: string.Empty, ExternalChannelId: null, - ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture)); + ExternalThreadId: draft.MessageThreadId); return new CreateSessionCommand( User: user, Group: group, @@ -245,10 +252,9 @@ public sealed class CreateSessionHandler } // ── Keyboards ──────────────────────────────────────────────────── - private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty()); - private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[] + private static IReadOnlyList RetryCancelActions() => new[] { - new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - }); + new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary), + new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }; } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs deleted file mode 100644 index 86ac0c1..0000000 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/ITelegramWizardMessenger.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; - -public sealed record WizardClubOption(Guid ClubId, string Name); - -public interface ITelegramWizardMessenger -{ - Task EditMessageTextAsync(long chatId, int? messageThreadId, long messageId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct); - Task SendGroupMessageAsync(long chatId, int? messageThreadId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct); - Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct); - Task> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct); -} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs index e671e02..3aec26f 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs @@ -3,48 +3,79 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Dapper; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +/// +/// Telegram-side implementation of . +/// Translates the platform-neutral wizard contracts into the +/// Telegram.Bot SDK calls. All Telegram-specific behaviour +/// (message editing, callback ack, group lookup) lives behind the +/// interface so the wizard core stays in GmRelay.Shared. +/// public sealed class TelegramWizardMessenger( ITelegramBotClient bot, - NpgsqlDataSource dataSource) : ITelegramWizardMessenger + NpgsqlDataSource dataSource) : IWizardMessenger { - public async Task EditMessageTextAsync( - long chatId, int? messageThreadId, long messageId, string text, - InlineKeyboardMarkup keyboard, CancellationToken ct) + public async Task EditDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct) { + if (!TryParseChatId(draft.ChatId, out var chatId)) + { + throw new InvalidOperationException( + $"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'."); + } + if (!TryParseMessageId(draft.DraftMessageId, out var messageId)) + { + // No draft message recorded yet — fall back to sending a new one. + return await SendDraftMessageAsync(draft, text, keyboard, ct); + } var msg = await bot.EditMessageText( chatId: chatId, - messageId: (int)messageId, + messageId: messageId, text: text, - replyMarkup: keyboard, + replyMarkup: WizardStep.ToInlineKeyboard(keyboard), cancellationToken: ct); - return msg.MessageId; + return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture); } - public async Task SendGroupMessageAsync( - long chatId, int? messageThreadId, string text, - InlineKeyboardMarkup keyboard, CancellationToken ct) + public async Task SendDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct) { + if (!TryParseChatId(draft.ChatId, out var chatId)) + { + throw new InvalidOperationException( + $"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'."); + } + int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread) + ? parsedThread + : null; + var msg = await bot.SendMessage( chatId: chatId, text: text, - messageThreadId: messageThreadId, - replyMarkup: keyboard, + messageThreadId: threadId, + replyMarkup: WizardStep.ToInlineKeyboard(keyboard), cancellationToken: ct); - return msg.MessageId; + return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture); } - public async Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct) + public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct) { - await bot.AnswerCallbackQuery(callbackId, text: text, cancellationToken: ct); + return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct); } - public async Task> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct) + public async Task> GetOwnerClubsAsync(string ownerId, CancellationToken ct) { // Adjusted from the plan: this codebase models "clubs" as game_groups // (V001 created game_groups; V026 added public_slug; no `clubs` table exists, @@ -57,14 +88,49 @@ public sealed class TelegramWizardMessenger( FROM game_groups g JOIN group_managers gm ON gm.group_id = g.id JOIN players p ON p.id = gm.player_id - WHERE p.platform = 'Telegram' + WHERE p.platform = @Platform AND p.external_user_id = @ExternalId GROUP BY g.id, g.name ORDER BY g.name """; await using var connection = await dataSource.OpenConnectionAsync(ct); var rows = await connection.QueryAsync( - new CommandDefinition(sql, new { ExternalId = ownerTelegramId.ToString() }, cancellationToken: ct)); + new CommandDefinition( + sql, + new { Platform = "Telegram", ExternalId = ownerId }, + cancellationToken: ct)); return rows.AsList(); } + + private static bool TryParseChatId(string raw, out long chatId) + { + if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId)) + { + return true; + } + chatId = 0; + return false; + } + + private static bool TryParseMessageId(string? raw, out int messageId) + { + if (raw is not null && + int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId)) + { + return true; + } + messageId = 0; + return false; + } + + private static bool TryParseThreadId(string? raw, out int threadId) + { + if (raw is not null && + int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId)) + { + return true; + } + threadId = 0; + return false; + } } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs new file mode 100644 index 0000000..3687826 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardInteractionMapper.cs @@ -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; + +/// +/// Converts a Telegram into the +/// platform-neutral consumed by +/// . The mapping is the only place in +/// the bot that knows about both Telegram.Bot.Types and the +/// shared wizard contract, so a future Discord adapter can do the same +/// for its native event without changing the wizard core. +/// +public static class WizardInteractionMapper +{ + /// + /// Returns true if carries a + /// wizard-relevant interaction (text message, photo, or + /// callback). Side-effect-free: the wizard state is not touched. + /// + 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; + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs index 6bb141e..f85a0cc 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStep.cs @@ -1,253 +1,65 @@ using System; using System.Collections.Generic; -using System.Text; -using GmRelay.Shared.Domain; +using System.Linq; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +/// +/// Telegram-side renderer for wizard keyboards. Acts as the adapter +/// between the platform-neutral list +/// produced by and Telegram's +/// . Each +/// becomes its own row (matching the pre-refactor Telegram layout). +/// is currently ignored by Telegram +/// because the platform has no native primary/danger/success button +/// colours. +/// public static class WizardStep { - public const int MaxTitleLength = 200; - public const int MaxDescriptionLength = 4000; - public const int MaxSystemLength = 100; - public const int MaxCapacity = 50; - public const int MinCapacity = 1; - public const int MinDurationHours = 1; - public const int MaxDurationHours = 12; + public const int MaxTitleLength = WizardStepLimits.MaxTitleLength; + public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength; + public const int MaxSystemLength = WizardStepLimits.MaxSystemLength; + public const int MaxCapacity = WizardStepLimits.MaxCapacity; + public const int MinCapacity = WizardStepLimits.MinCapacity; + public const int MinDurationHours = WizardStepLimits.MinDurationHours; + public const int MaxDurationHours = WizardStepLimits.MaxDurationHours; - public static (string text, InlineKeyboardMarkup keyboard) Render( + /// + /// 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. + /// + public static (string Text, InlineKeyboardMarkup Keyboard) Render( WizardDraft draft, WizardPayload payload, IReadOnlyList? clubs = null) { - return draft.Step switch - { - WizardStepNames.Type => RenderType(), - WizardStepNames.Title => RenderTitle(), - WizardStepNames.Description => RenderDescription(), - WizardStepNames.Cover => RenderCover(), - WizardStepNames.System => RenderSystem(), - WizardStepNames.Duration => RenderDuration(), - WizardStepNames.DateTime => RenderDateTime(), - WizardStepNames.Capacity => RenderCapacity(), - WizardStepNames.Visibility => RenderVisibility(), - WizardStepNames.PickClub => RenderPickClub(clubs ?? Array.Empty()), - WizardStepNames.Publish => RenderPublish(), - WizardStepNames.Confirm => RenderSingleConfirm(payload), - - WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(), - WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload), - WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(), - WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(), - WizardStepNames.PoolConfirm => RenderPoolConfirm(payload), - - _ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"), - }; + var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs); + return (text, ToInlineKeyboard(actions)); } - // ── Single-game renderers ────────────────────────────────────────── - private static (string, InlineKeyboardMarkup) RenderType() => ( - "🎲 Создание новой игровой сессии\n\nЧто создаём?", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single")) }, - new[] { InlineKeyboardButton.WithCallbackData("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool")) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - })); - - private static (string, InlineKeyboardMarkup) RenderTitle() => ( - "📝 Введите название игры одним сообщением.", - BackCancel()); - - private static (string, InlineKeyboardMarkup) RenderDescription() => ( - "📄 Введите описание (или «-», чтобы пропустить).", - SkipBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderCover() => ( - "🖼 Пришлите картинку как вложение или URL (или «-»).", - SkipBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderSystem() + /// + /// Convert a flat list of s into a + /// Telegram keyboard. Each action is placed in its own row to + /// preserve the pre-refactor visual layout. + /// + public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList actions) { - var buttons = new List + if (actions.Count == 0) { - new[] { InlineKeyboardButton.WithCallbackData("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")) }, - new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")) }, - new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")) }, - new[] { InlineKeyboardButton.WithCallbackData("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")) }, - new[] { InlineKeyboardButton.WithCallbackData("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")) }, - new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")) }, - new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")) }, - }; - return ("🎲 Выберите систему.", new InlineKeyboardMarkup(buttons).AppendBackCancel()); - } - - private static (string, InlineKeyboardMarkup) RenderDuration() => ( - "⏱ Выберите длительность.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")) }, - new[] { InlineKeyboardButton.WithCallbackData("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")) }, - new[] { InlineKeyboardButton.WithCallbackData("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")) }, - new[] { InlineKeyboardButton.WithCallbackData("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")) }, - new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")) }, - new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderDateTime() => ( - "📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).", - BackCancel()); - - private static (string, InlineKeyboardMarkup) RenderCapacity() => ( - "👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on")) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderVisibility() => ( - "🔒 Выберите видимость.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public")) }, - new[] { InlineKeyboardButton.WithCallbackData("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club")) }, - new[] { InlineKeyboardButton.WithCallbackData("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")) }, - new[] { InlineKeyboardButton.WithCallbackData("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPickClub(IReadOnlyList clubs) - { - if (clubs.Count == 0) - { - return ( - "🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.", - BackCancel()); + return new InlineKeyboardMarkup(Array.Empty()); } - var rows = new List(); - foreach (var club in clubs) + var rows = new InlineKeyboardButton[actions.Count][]; + for (var i = 0; i < actions.Count; i++) { - rows.Add(new[] + rows[i] = new[] { - InlineKeyboardButton.WithCallbackData(club.Name, WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString())) - }); + InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload), + }; } - return ("🏷 Выберите клуб:", new InlineKeyboardMarkup(rows).AppendBackCancel()); + return new InlineKeyboardMarkup(rows); } - - private static (string, InlineKeyboardMarkup) RenderPublish() => ( - "✨ Опубликовать в витрине сейчас?", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes")) }, - new[] { InlineKeyboardButton.WithCallbackData("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderSingleConfirm(WizardPayload p) - { - var sb = new StringBuilder(); - sb.AppendLine("👀 Проверьте перед созданием:"); - sb.AppendLine(); - sb.AppendLine($"🎲 {p.Title}"); - if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); - if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); - if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); - if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)"); - if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}"); - sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); - return (sb.ToString(), new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Создать", WizardCallbackData.Create()) }, - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - })); - } - - // ── Pool renderers ───────────────────────────────────────────────── - private static (string, InlineKeyboardMarkup) RenderPoolSystemDuration() => ( - "🎲 Выберите систему и длительность пула.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")) }, - new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")) }, - new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")) }, - new[] { InlineKeyboardButton.WithCallbackData("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")) }, - new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolAddSlots(WizardPayload p) => ( - $"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")) }, - new[] { InlineKeyboardButton.WithCallbackData("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolSlotDateTime() => ( - "📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).", - BackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolSlotCapacity() => ( - "👥 Введите лимит мест (1..50) и выберите waitlist.", - new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on")) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off")) }, - }).AppendBackCancel()); - - private static (string, InlineKeyboardMarkup) RenderPoolConfirm(WizardPayload p) - { - var sb = new StringBuilder(); - sb.AppendLine("👀 Проверьте пул перед созданием:"); - sb.AppendLine(); - sb.AppendLine($"📝 {p.Title}"); - if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}"); - if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}"); - if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч"); - sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}"); - sb.AppendLine(); - sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):"); - if (p.Pool is not null) - { - foreach (var s in p.Pool.Slots) - { - sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}"); - } - } - return (sb.ToString(), new InlineKeyboardMarkup(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("✅ Создать пул", WizardCallbackData.Create()) }, - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - })); - } - - // ── Helpers ──────────────────────────────────────────────────────── - private static InlineKeyboardMarkup BackCancel() => new(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - }); - - private static InlineKeyboardMarkup SkipBackCancel() => new(new[] - { - new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")) }, - new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) }, - new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) }, - }); - - private static string RenderVisibilityText(WizardVisibility? v) => v switch - { - WizardVisibility.Public => "публичная в общем showcase", - WizardVisibility.Club => "публичная в витрине клуба", - WizardVisibility.Members => "только для членов клуба", - _ => "не задана", - }; -} - -internal static class InlineKeyboardMarkupExtensions -{ - public static InlineKeyboardMarkup AppendBackCancel(this InlineKeyboardMarkup kb) => kb; } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs deleted file mode 100644 index a565e0f..0000000 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; - -public sealed class WizardStorageException : Exception -{ - public WizardStorageException(string message, Exception inner) : base(message, inner) { } -} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index dd05aa3..9b0df35 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -1,4 +1,5 @@ // ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment +using System.Globalization; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Sessions.CreateSession; @@ -16,6 +17,7 @@ using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.ReplyMarkups; +using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard; namespace GmRelay.Bot.Infrastructure.Telegram; @@ -36,7 +38,7 @@ public sealed class UpdateRouter( InitiateRescheduleHandler initiateRescheduleHandler, BotRescheduleTimeInputHandler rescheduleTimeInputHandler, BotRescheduleVoteHandler rescheduleVoteHandler, - GameCreationWizard wizard, + SharedWizard wizard, IWizardDraftRepository drafts, ITelegramBotClient bot, IConfiguration configuration, @@ -47,9 +49,9 @@ public sealed class UpdateRouter( // 1) Wizard delegation. If the GM has an active (non-expired) draft for this // (chat, thread, owner), every update routes to the wizard. The wizard is // responsible for both text input and callback handling. - if (TryGetWizardContext(update, out var chatId, out var threadId, out var ownerId)) + if (TryGetWizardContext(update, out _, out _, out var ownerId)) { - var draft = await drafts.GetActiveAsync(chatId, threadId, ownerId, ct); + var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct); if (draft is not null) { // Resume / Reset / Cancel menu callbacks live in the router because @@ -60,7 +62,10 @@ public sealed class UpdateRouter( return; } - await wizard.HandleUpdateAsync(update, draft, ct); + if (WizardInteractionMapper.TryMap(update, out var interaction)) + { + await wizard.HandleInteractionAsync(interaction, draft, ct); + } // The "✅ Создать" / "✅ Создать пул" button — the wizard only // acknowledges the callback; the actual session creation lives in @@ -157,7 +162,7 @@ public sealed class UpdateRouter( }; private static WizardPayload LoadPayload(WizardDraft draft) => - GameCreationWizard.LoadPayload(draft); + SharedWizard.LoadPayload(draft); internal static string GetCommandText(Message message) => (message.Text ?? message.Caption ?? string.Empty).TrimStart(); @@ -166,30 +171,30 @@ public sealed class UpdateRouter( /// Extracts the (chat, thread, owner) triple from an update for wizard lookups. /// Returns false for updates that carry no usable origin (e.g. inline queries). /// - private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out long ownerId) + private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId) { chatId = 0; messageThreadId = null; - ownerId = 0; + ownerId = string.Empty; switch (update) { case { Message: { From: not null, Chat: { } chat } msg }: chatId = chat.Id; messageThreadId = msg.MessageThreadId; - ownerId = msg.From!.Id; + ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture); return true; case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }: chatId = cbmChat.Id; messageThreadId = cb.Message?.MessageThreadId; - ownerId = cb.From!.Id; + ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture); return true; case { CallbackQuery: { From: not null } cb2 }: // Callback arrived without a message (e.g. from a Mini App). No chat // context → wizard cannot run on this update. - ownerId = cb2.From!.Id; + ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture); return false; default: diff --git a/src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql b/src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql new file mode 100644 index 0000000..f435166 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql @@ -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); diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index f5bfd0a..3a8b0e3 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -73,8 +73,8 @@ builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs similarity index 79% rename from src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs rename to src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs index dc870f1..6f88dfd 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -3,25 +3,27 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using GmRelay.Shared.Domain; -using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging; -using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; /// -/// Central state machine for the game/pool creation wizard. +/// Central state machine for the game/pool creation wizard. Lives in +/// GmRelay.Shared so it can be driven from any platform +/// messenger. Platform-specific code (Telegram.Bot, +/// NetCord, …) lives in the corresponding adapter and converts +/// its native update type into a before +/// calling . /// public sealed class GameCreationWizard { private readonly IWizardDraftRepository _drafts; - private readonly ITelegramWizardMessenger _messenger; + private readonly IWizardMessenger _messenger; private readonly ILogger _log; public GameCreationWizard( IWizardDraftRepository drafts, - ITelegramWizardMessenger messenger, + IWizardMessenger messenger, ILogger log) { _drafts = drafts; @@ -29,44 +31,61 @@ public sealed class GameCreationWizard _log = log; } - /// Handle a text or callback update from the owning GM. - public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct) + /// + /// Handle a single user interaction with the wizard. Adapters should + /// map their native event (Telegram Update, Discord + /// interaction, …) into a first. + /// + public async Task HandleInteractionAsync( + WizardInteraction interaction, + WizardDraft draft, + CancellationToken ct) { try { - if (update.CallbackQuery is { } cb) + if (interaction.CallbackPayload is not null) { - await HandleCallbackAsync(draft, cb, ct); + await HandleCallbackAsync(draft, interaction, ct); } - else if (update.Message is { } msg) + else { - await HandleTextAsync(draft, msg, ct); + await HandleTextAsync(draft, interaction, ct); } } catch (WizardStorageException) { - // Surface storage failure; do not crash the update loop. - if (update.CallbackQuery is { } cb2) + if (interaction.CallbackPayload is not null) { - await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct); + await _messenger.AnswerInteractionAsync( + interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct); } } catch (Exception ex) { - _log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id); - if (update.CallbackQuery is { } cb3) + _log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id); + if (interaction.CallbackPayload is not null) { - try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); } - catch { /* swallow — we're already in error path */ } + try + { + await _messenger.AnswerInteractionAsync( + interaction.InteractionId, "⚠️ Ошибка", ct); + } + catch + { + /* swallow — we're already in error path */ + } } } } - private async Task HandleCallbackAsync(WizardDraft draft, CallbackQuery cb, CancellationToken ct) + private async Task HandleCallbackAsync( + WizardDraft draft, + WizardInteraction interaction, + CancellationToken ct) { - if (!WizardCallbackData.TryParse(cb.Data, out var action, out var step, out var choice)) + if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice)) { - await _messenger.AnswerCallbackAsync(cb.Id, "Неизвестная команда", ct); + await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct); return; } @@ -74,37 +93,39 @@ public sealed class GameCreationWizard { case "cancel": await _drafts.DeleteAsync(draft.Id, ct); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, - "❌ Мастер отменён.", EmptyKeyboard, ct); - await _messenger.AnswerCallbackAsync(cb.Id, null, ct); + await _messenger.EditDraftMessageAsync( + draft, "❌ Мастер отменён.", Array.Empty(), ct); + await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct); return; case "back": ApplyBack(draft, step); - await PersistAndRenderAsync(draft, cb.Id, ct); + await PersistAndRenderAsync(draft, interaction.InteractionId, ct); return; case "create": - // Routed by CreateSessionHandler, not here. - await _messenger.AnswerCallbackAsync(cb.Id, null, ct); + // Routed by the platform's CreateSessionHandler, not here. + await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct); return; default: // For "Choice" callbacks, action == step. - await ApplyChoiceAsync(draft, step, choice, cb.Id, ct); + await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct); return; } } - private async Task HandleTextAsync(WizardDraft draft, Message msg, CancellationToken ct) + private async Task HandleTextAsync( + WizardDraft draft, + WizardInteraction interaction, + CancellationToken ct) { - if (msg.Text is not { } text) + if (interaction.Text is not { } text) { // Photo or other non-text — handle cover step only. - if (msg.Photo is { Length: > 0 } && draft.Step == WizardStepNames.Cover) + if (interaction.PhotoFileId is { } fileId && + draft.Step == WizardStepNames.Cover) { - var fileId = msg.Photo[^1].FileId; ApplyCoverPhoto(draft, fileId); await PersistAndRenderAsync(draft, null, ct); } @@ -113,13 +134,12 @@ public sealed class GameCreationWizard var (nextStep, error, payload) = ApplyText(draft, text); if (payload is { } p) SavePayload(draft, p); - if (error is { } errMsg && draft.DraftMessageId is { } mid) + if (error is { } errMsg) { // Re-render the same step with ⚠️ prefix. - var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, mid, - "⚠️ " + errMsg + "\n\n" + rendered, kb, ct); + var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft)); + await _messenger.EditDraftMessageAsync( + draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct); return; } @@ -130,12 +150,13 @@ public sealed class GameCreationWizard await PersistAndRenderAsync(draft, null, ct); } - private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct) + private async Task ApplyChoiceAsync( + WizardDraft draft, string step, string choice, string interactionId, CancellationToken ct) { var (nextStep, error, payload) = ApplyChoice(draft, step, choice); if (error is { } err) { - await _messenger.AnswerCallbackAsync(callbackId, err, ct); + await _messenger.AnswerInteractionAsync(interactionId, err, ct); return; } if (payload is { } p) SavePayload(draft, p); @@ -143,10 +164,10 @@ public sealed class GameCreationWizard { draft.Step = s; } - await PersistAndRenderAsync(draft, callbackId, ct); + await PersistAndRenderAsync(draft, interactionId, ct); } - private async Task PersistAndRenderAsync(WizardDraft draft, string? callbackId, CancellationToken ct) + private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct) { draft.UpdatedAt = DateTimeOffset.UtcNow; await _drafts.UpsertAsync(draft, ct); @@ -154,15 +175,13 @@ public sealed class GameCreationWizard IReadOnlyList? clubs = null; if (draft.Step == WizardStepNames.PickClub) { - clubs = await _messenger.GetGmClubsAsync(draft.OwnerTelegramId, ct); + clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct); } - var (text, kb) = WizardStep.Render(draft, payload, clubs); - await _messenger.EditMessageTextAsync( - draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0, - text, kb, ct); - if (callbackId is { } id) + var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs); + await _messenger.EditDraftMessageAsync(draft, text, actions, ct); + if (interactionId is { } id) { - await _messenger.AnswerCallbackAsync(id, null, ct); + await _messenger.AnswerInteractionAsync(id, null, ct); } } @@ -173,13 +192,13 @@ public sealed class GameCreationWizard switch (draft.Step) { case WizardStepNames.Title: - return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title) + return ValidateText(input, WizardStepLimits.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title) ? (WizardStepNames.Description, SetTitle(payload, title), payload) : (null, title, payload); case WizardStepNames.Description: if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload); - return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc) + return ValidateText(input, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc) ? (WizardStepNames.Cover, SetDescription(payload, desc), payload) : (null, desc, payload); @@ -191,7 +210,7 @@ public sealed class GameCreationWizard case WizardStepNames.System when payload.System is null: // "Other" branch — only active if free-text was offered. - return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys) + return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys) ? (WizardStepNames.Duration, SetSystem(payload, sys), payload) : (null, sys, payload); @@ -206,12 +225,12 @@ public sealed class GameCreationWizard : (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: - return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity + return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity ? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload) : (null, "Лимит должен быть 1..50", payload); case WizardStepNames.PoolSystemDuration when payload.System is null: - return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) + return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload) : (null, psys, payload); @@ -226,7 +245,7 @@ public sealed class GameCreationWizard : (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.PoolSlotCapacity: - return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity + return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity ? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload) : (null, "Лимит должен быть 1..50", payload); @@ -367,7 +386,7 @@ public sealed class GameCreationWizard }; // ── Payload I/O ─────────────────────────────────────────────────── - internal static WizardPayload LoadPayload(WizardDraft draft) + public static WizardPayload LoadPayload(WizardDraft draft) { if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload(); return System.Text.Json.JsonSerializer.Deserialize( @@ -495,10 +514,8 @@ public sealed class GameCreationWizard if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false; - if (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false; + if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false; minutes = (int)Math.Round(hours * 60); return true; } - - private static readonly InlineKeyboardMarkup EmptyKeyboard = new(Array.Empty()); } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs deleted file mode 100644 index a3aa7bd..0000000 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardDraftRepository.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; - -/// -/// 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). -/// -public interface IWizardDraftRepository -{ - Task GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct); - - Task UpsertAsync(WizardDraft draft, CancellationToken ct); - - Task DeleteAsync(Guid id, CancellationToken ct); - - Task DeleteExpiredAsync(CancellationToken ct); -} diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs new file mode 100644 index 0000000..3ffb709 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// 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). +/// +public enum WizardActionStyle +{ + Primary, + Secondary, + Success, + Danger, +} + +/// +/// A single button on a wizard keyboard. is the +/// platform-neutral callback token — usually produced by +/// but adapters are free to interpret +/// any string. +/// +public sealed record WizardAction( + string Label, + string Payload, + WizardActionStyle Style = WizardActionStyle.Secondary); + +/// +/// 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. +/// +public sealed record WizardKeyboard(IReadOnlyList Actions); + +/// +/// A user-owned group/club selectable from the visibility step. Moved +/// from GmRelay.Bot so the wizard can ask for the list without +/// taking a dependency on Telegram. +/// +public sealed record WizardClubOption(Guid ClubId, string Name); + +/// +/// Platform-neutral user interaction with the wizard. Adapters convert +/// their native event (Telegram Update, Discord interaction, …) +/// into one of these before handing it to . +/// +public sealed record WizardInteraction( + string OwnerId, + string? Text, + string? CallbackPayload, + string? PhotoFileId, + string? PhotoUrl, + string InteractionId); + +/// +/// 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). +/// +public interface IWizardDraftRepository +{ + Task GetActiveAsync(string platform, string ownerId, CancellationToken ct); + + Task UpsertAsync(WizardDraft draft, CancellationToken ct); + + Task DeleteAsync(Guid id, CancellationToken ct); + + Task DeleteExpiredAsync(CancellationToken ct); +} + +/// +/// 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). +/// +public interface IWizardMessenger +{ + /// + /// Edit the message that currently represents the wizard draft. + /// Returns the new message id as a string — Telegram exposes + /// int32, Discord uses 64-bit snowflakes, both fit in + /// for cross-platform uniformity. + /// + Task EditDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct); + + /// + /// Post a fresh wizard draft message and return its id. + /// + Task SendDraftMessageAsync( + WizardDraft draft, + string text, + IReadOnlyList keyboard, + CancellationToken ct); + + /// + /// Acknowledge a callback / interaction. + /// is an optional toast the user sees briefly. + /// + Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct); + + /// + /// 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. + /// + Task> GetOwnerClubsAsync(string ownerId, CancellationToken ct); +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs similarity index 66% rename from src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs rename to src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs index fb208ed..8e1d9b7 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardCallbackData.cs @@ -1,14 +1,24 @@ using System; -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +/// +/// 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 wizard to keep the namespace separate +/// from the rest of the bot's command callbacks. +/// 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) diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs index 9125db6..4c79b29 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs @@ -5,13 +5,47 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; public sealed class WizardDraft { public Guid Id { get; set; } - public long ChatId { get; set; } - public int? MessageThreadId { get; set; } - public long OwnerTelegramId { get; set; } + + /// + /// Stable string id of the chat/guild/channel this draft lives in. + /// Stored as TEXT to fit both Telegram's long chat ids + /// and Discord's snowflakes. + /// + public string ChatId { get; set; } = string.Empty; + + /// + /// Optional thread/topic id within the chat. Telegram's + /// message_thread_id, Discord's thread snowflake, null + /// when the chat has no sub-thread concept. + /// + public string? MessageThreadId { get; set; } + + /// + /// Platform-specific user id of the wizard owner. Telegram uses + /// long, Discord uses snowflakes — both fit in a string. + /// + public string OwnerId { get; set; } = string.Empty; + + /// + /// Which messenger platform owns this draft. Defaults to + /// "Telegram" for backward compatibility with pre-V032 rows. + /// + public string Platform { get; set; } = "Telegram"; + public string Step { get; set; } = string.Empty; + public string PayloadJson { get; set; } = "{}"; - public long? DraftMessageId { get; set; } + + /// + /// Id of the message that the wizard last edited. Stored as + /// TEXT to fit both Telegram's int32 ids and Discord's + /// 64-bit snowflakes. + /// + public string? DraftMessageId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs index 3f2a592..20b777d 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs @@ -8,14 +8,14 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository { - public async Task GetActiveAsync( - long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct) + public async Task GetActiveAsync(string platform, string ownerId, CancellationToken ct) { const string sql = """ SELECT id AS Id, chat_id AS ChatId, message_thread_id AS MessageThreadId, - owner_telegram_id AS OwnerTelegramId, + owner_id AS OwnerId, + platform AS Platform, step AS Step, payload::text AS PayloadJson, draft_message_id AS DraftMessageId, @@ -23,17 +23,18 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard updated_at AS UpdatedAt, expires_at AS ExpiresAt FROM wizard_drafts - WHERE chat_id = @ChatId - AND (message_thread_id = @ThreadId OR (@ThreadId IS NULL AND message_thread_id IS NULL)) - AND owner_telegram_id = @OwnerId + WHERE platform = @Platform + AND owner_id = @OwnerId AND expires_at > NOW() + ORDER BY updated_at DESC LIMIT 1 """; await using var connection = await dataSource.OpenConnectionAsync(ct); return await connection.QuerySingleOrDefaultAsync( - new CommandDefinition(sql, - new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId }, + new CommandDefinition( + sql, + new { Platform = platform, OwnerId = ownerId }, cancellationToken: ct)); } @@ -41,9 +42,9 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard { const string sql = """ INSERT INTO wizard_drafts - (id, chat_id, message_thread_id, owner_telegram_id, step, payload, draft_message_id, created_at, updated_at, expires_at) + (id, chat_id, message_thread_id, owner_id, platform, step, payload, draft_message_id, created_at, updated_at, expires_at) VALUES - (@Id, @ChatId, @MessageThreadId, @OwnerTelegramId, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt) + (@Id, @ChatId, @MessageThreadId, @OwnerId, @Platform, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt) ON CONFLICT (id) DO UPDATE SET step = EXCLUDED.step, payload = EXCLUDED.payload, diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs new file mode 100644 index 0000000..2cacb2e --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepLimits.cs @@ -0,0 +1,17 @@ +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// Limits and bounds used by the wizard's input validation. Kept here +/// (rather than on the Telegram-only WizardStep) so the state +/// machine can reference them without pulling in a platform dependency. +/// +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; +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs similarity index 71% rename from src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs rename to src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs index 200fdc2..725e6ac 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepNames.cs @@ -1,5 +1,11 @@ -namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard; +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +/// +/// Symbolic step identifiers used by and +/// the payload. Strings (rather than an +/// enum) so that future platforms can extend the set without breaking +/// the wire format stored in PostgreSQL. +/// public static class WizardStepNames { public const string Type = "Type"; diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs new file mode 100644 index 0000000..fac6fae --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStepViewBuilder.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Text; +using GmRelay.Shared.Domain; + +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// Produces a (text, list of 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 InlineKeyboardMarkup today, Discord components +/// later). +/// +public static class WizardStepViewBuilder +{ + public static (string Text, IReadOnlyList Actions) Build( + WizardDraft draft, + WizardPayload payload, + IReadOnlyList? 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()), + 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) 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) BuildTitle() => ( + "📝 Введите название игры одним сообщением.", + BackCancel()); + + private static (string, IReadOnlyList) BuildDescription() => ( + "📄 Введите описание (или «-», чтобы пропустить).", + SkipBackCancel()); + + private static (string, IReadOnlyList) BuildCover() => ( + "🖼 Пришлите картинку как вложение или URL (или «-»).", + SkipBackCancel()); + + private static (string, IReadOnlyList) BuildSystem() => ( + "🎲 Выберите систему.", + new List + { + 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) BuildDuration() => ( + "⏱ Выберите длительность.", + new List + { + 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) BuildDateTime() => ( + "📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).", + BackCancel()); + + private static (string, IReadOnlyList) BuildCapacity() => ( + "👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.", + new List + { + new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success), + new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger), + }); + + private static (string, IReadOnlyList) BuildVisibility() => ( + "🔒 Выберите видимость.", + new List + { + 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) BuildPickClub(IReadOnlyList clubs) + { + if (clubs.Count == 0) + { + return ( + "🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.", + BackCancel()); + } + var actions = new List(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) BuildPublish() => ( + "✨ Опубликовать в витрине сейчас?", + new List + { + new("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes"), WizardActionStyle.Success), + new("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")), + }); + + private static (string, IReadOnlyList) 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 + { + new("✅ Создать", WizardCallbackData.Create(), WizardActionStyle.Success), + new("⬅️ Назад", WizardCallbackData.Back()), + new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }); + } + + // ── Pool views ───────────────────────────────────────────────────── + private static (string, IReadOnlyList) BuildPoolSystemDuration() => ( + "🎲 Выберите систему и длительность пула.", + new List + { + 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) BuildPoolAddSlots(WizardPayload p) => ( + $"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}", + new List + { + new("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), WizardActionStyle.Primary), + new("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), WizardActionStyle.Success), + }); + + private static (string, IReadOnlyList) BuildPoolSlotDateTime() => ( + "📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).", + BackCancel()); + + private static (string, IReadOnlyList) BuildPoolSlotCapacity() => ( + "👥 Введите лимит мест (1..50) и выберите waitlist.", + new List + { + new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"), WizardActionStyle.Success), + new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"), WizardActionStyle.Danger), + }); + + private static (string, IReadOnlyList) 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 + { + new("✅ Создать пул", WizardCallbackData.Create(), WizardActionStyle.Success), + new("⬅️ Назад", WizardCallbackData.Back()), + new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }); + } + + // ── Helpers ──────────────────────────────────────────────────────── + private static IReadOnlyList BackCancel() => new[] + { + new WizardAction("⬅️ Назад", WizardCallbackData.Back()), + new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), + }; + + private static IReadOnlyList 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 => "только для членов клуба", + _ => "не задана", + }; +} diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs new file mode 100644 index 0000000..46baf22 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardStorageException.cs @@ -0,0 +1,16 @@ +using System; + +namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; + +/// +/// 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. +/// +public sealed class WizardStorageException : Exception +{ + public WizardStorageException(string message, Exception inner) + : base(message, inner) + { + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs index ca40308..872d4ae 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitMissingFieldsTests.cs @@ -2,7 +2,6 @@ using System; using System.Threading; using System.Threading.Tasks; using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging.Abstractions; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -39,7 +38,7 @@ public sealed class CreateSessionHandlerSubmitMissingFieldsTests // The wizard message is edited to surface the missing-field error. Assert.Single(messenger.Edits); var edit = messenger.Edits[0]; - Assert.Equal(draft.ChatId, edit.ChatId); + Assert.Equal(long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture), edit.ChatId); Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase); } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs index ea75164..134a935 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitValidationTests.cs @@ -2,7 +2,6 @@ using System; using System.Threading; using System.Threading.Tasks; using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging.Abstractions; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs index 1f8d3a4..428ace6 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardCancelBackTests.cs @@ -1,5 +1,4 @@ using System; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -20,7 +19,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Cancel(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Contains(draft.Id, drafts.DeletedIds); Assert.Single(messenger.Edits); @@ -36,7 +35,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); // Title is the first step, so Back is a no-op. Assert.Equal(WizardStepNames.Title, draft.Step); @@ -51,7 +50,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Title, draft.Step); } @@ -65,7 +64,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Description, draft.Step); } @@ -79,7 +78,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Cover, draft.Step); } @@ -93,7 +92,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Back(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step); } @@ -108,7 +107,7 @@ public sealed class GameCreationWizardCancelBackTests drafts.Seed(draft); var data = WizardCallbackData.Create(); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Confirm, draft.Step); Assert.Contains("cb-1", messenger.AnsweredCallbacks); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs index 5d10078..6ec571b 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardPoolSlotTests.cs @@ -1,5 +1,4 @@ using System; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -7,8 +6,8 @@ using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTest namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; /// -/// Verifies the pool-specific branch of the wizard: the AddSlots flow that -/// builds up slot metadata through date and capacity steps. +/// Verifies the pool-specific branch of the wizard: the AddSlots flow +/// that builds up slot metadata through date and capacity steps. /// public sealed class GameCreationWizardPoolSlotTests { @@ -28,7 +27,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"); - await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(addData, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step); } @@ -49,7 +48,7 @@ public sealed class GameCreationWizardPoolSlotTests var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow(); var dtString = future.ToString("dd.MM.yyyy HH:mm"); - await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(dtString, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step); } @@ -61,7 +60,7 @@ public sealed class GameCreationWizardPoolSlotTests var draft = NewDraft(WizardStepNames.PoolSlotDateTime); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step); } @@ -74,7 +73,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"); - await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(noWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); } @@ -87,7 +86,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"); - await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(yesWaitlist, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); } @@ -107,7 +106,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step); } @@ -132,7 +131,7 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PoolConfirm, draft.Step); } @@ -150,10 +149,10 @@ public sealed class GameCreationWizardPoolSlotTests drafts.Seed(draft); // "add" then "done" — no date/capacity supplied in between. - await wizard.HandleUpdateAsync(CallbackUpdate( - WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None); - await wizard.HandleUpdateAsync(CallbackUpdate( - WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction( + WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), ownerId: draft.OwnerId), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction( + WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), ownerId: draft.OwnerId), draft, CancellationToken.None); // The wizard sees the in-memory slot count > 0 and advances to confirm. Assert.Equal(WizardStepNames.PoolConfirm, draft.Step); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs index a6a69cb..b9c1ef5 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardStepTransitionsTests.cs @@ -1,6 +1,5 @@ using System; using System.Text.Json; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -41,7 +40,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(fromStep, choice); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(expectedStep, draft.Step); Assert.NotEmpty(drafts.Upserts); // was persisted @@ -60,7 +59,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Visibility, draft.Step); using var doc = JsonDocument.Parse(draft.PayloadJson); @@ -84,7 +83,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Visibility, draft.Step); } @@ -110,7 +109,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString()); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); // Wizard acknowledged the callback and re-rendered the (still PickClub) step. Assert.NotEmpty(messenger.Edits); @@ -132,7 +131,7 @@ public sealed class GameCreationWizardStepTransitionsTests drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid"); - await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PickClub, draft.Step); } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs index cd58ea7..d33ec9f 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/GameCreationWizardValidationTests.cs @@ -1,6 +1,5 @@ using System; using System.Text.Json; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; @@ -20,7 +19,7 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.Title); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Title, draft.Step); } @@ -32,8 +31,8 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.Title); drafts.Seed(draft); - var tooLong = new string('a', WizardStep.MaxTitleLength + 1); - await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None); + var tooLong = new string('a', WizardStepLimits.MaxTitleLength + 1); + await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Title, draft.Step); } @@ -53,7 +52,7 @@ public sealed class GameCreationWizardValidationTests drafts.Seed(draft); // 2020-01-01 is firmly in the past - await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.DateTime, draft.Step); } @@ -65,7 +64,7 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.DateTime); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.DateTime, draft.Step); } @@ -83,7 +82,7 @@ public sealed class GameCreationWizardValidationTests var draft = NewDraft(WizardStepNames.Cover, payload); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Cover, draft.Step); } @@ -96,7 +95,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.System, draft.Step); } @@ -109,7 +108,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.System, draft.Step); } @@ -132,7 +131,7 @@ public sealed class GameCreationWizardValidationTests }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Capacity, draft.Step); } @@ -148,7 +147,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Duration, draft.Step); } @@ -161,7 +160,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Cover, draft.Step); } @@ -178,7 +177,7 @@ public sealed class GameCreationWizardValidationTests new WizardPayload { Type = WizardCreationType.Single, Title = "T" }); drafts.Seed(draft); - await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None); + await wizard.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Duration, draft.Step); } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs index 108465b..b223b4d 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterDelegationTests.cs @@ -1,7 +1,7 @@ using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Bot.Infrastructure.Telegram; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -30,7 +30,7 @@ public sealed class UpdateRouterDelegationTests var draft = NewDraft(WizardStepNames.Title); drafts.Seed(draft); - var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId); + var update = TextUpdate("Curse of Strahd", ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture)); await sut.RouteAsync(update, CancellationToken.None); @@ -49,7 +49,7 @@ public sealed class UpdateRouterDelegationTests // "wizard:cancel" — wizard owns the cancel callback. The router // delegates control-callbacks (resume/reset) but lets the wizard // handle wizard:* callbacks. - var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId); + var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture)); await sut.RouteAsync(update, CancellationToken.None); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs index 2ca8e91..22c2239 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/UpdateRouterResetsDraftOnStaleCommandTests.cs @@ -1,7 +1,7 @@ using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Bot.Infrastructure.Telegram; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -36,8 +36,8 @@ public sealed class UpdateRouterResetsDraftOnStaleCommandTests Message = new Message { Text = "/newsession", - Chat = new Chat { Id = draft.ChatId }, - From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" }, + Chat = new Chat { Id = long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture) }, + From = new User { Id = long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture), FirstName = "GM" }, }, }; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs index 9634262..9b6a8ac 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryFixture.cs @@ -49,22 +49,23 @@ public sealed class WizardDraftRepositoryFixture : IAsyncLifetime """ CREATE TABLE wizard_drafts ( id UUID PRIMARY KEY, - chat_id BIGINT NOT NULL, - message_thread_id INT, - owner_telegram_id BIGINT NOT NULL, + chat_id TEXT NOT NULL, + message_thread_id TEXT, + owner_id TEXT NOT NULL, + platform TEXT NOT NULL DEFAULT 'Telegram', step TEXT NOT NULL, payload JSONB NOT NULL, - draft_message_id BIGINT, + draft_message_id TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_wizard_drafts_owner - ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id); + ON wizard_drafts(platform, owner_id); - CREATE INDEX idx_wizard_drafts_expires - ON wizard_drafts(expires_at); + CREATE INDEX idx_wizard_drafts_platform + ON wizard_drafts(platform); """, connection); await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs index d2c15c4..f4d5046 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Threading; +using System.Threading.Tasks; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Npgsql; @@ -20,7 +23,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1); await sut.UpsertAsync(draft, CancellationToken.None); - var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None); + var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None); Assert.NotNull(loaded); Assert.Equal("Title", loaded!.Step); } @@ -35,7 +38,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1)); await sut.UpsertAsync(draft, CancellationToken.None); - var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None); + var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None); Assert.Null(loaded); } @@ -49,7 +52,9 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1)); await sut.UpsertAsync(draft, CancellationToken.None); - var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, ownerTelegramId: draft.OwnerTelegramId + 1, CancellationToken.None); + var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1) + .ToString(System.Globalization.CultureInfo.InvariantCulture); + var loaded = await sut.GetActiveAsync(draft.Platform, otherOwner, CancellationToken.None); Assert.Null(loaded); } @@ -69,16 +74,17 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt var deleted = await sut.DeleteExpiredAsync(CancellationToken.None); Assert.Equal(1, deleted); - var loadedFresh = await sut.GetActiveAsync(fresh.ChatId, fresh.MessageThreadId, fresh.OwnerTelegramId, CancellationToken.None); + var loadedFresh = await sut.GetActiveAsync(fresh.Platform, fresh.OwnerId, CancellationToken.None); Assert.NotNull(loadedFresh); } private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new() { Id = Guid.NewGuid(), - ChatId = 42, + ChatId = "42", MessageThreadId = null, - OwnerTelegramId = 100, + OwnerId = "100", + Platform = "Telegram", Step = step, PayloadJson = "{}", CreatedAt = DateTimeOffset.UtcNow, diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs new file mode 100644 index 0000000..0202d23 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardInteractionMapperTests.cs @@ -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; + +/// +/// Verifies the Telegram UpdateWizardInteraction mapping +/// that 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 FileId. +/// +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); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs index 3ae4255..99d2993 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardStepRenderTests.cs @@ -248,7 +248,7 @@ public sealed class WizardStepRenderTests private static WizardDraft NewDraft(string step) => new() { Id = Guid.NewGuid(), - ChatId = 42, + ChatId = "42", Step = step, PayloadJson = "{}", }; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs index ace85e3..1636517 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs @@ -1,25 +1,26 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading; using System.Threading.Tasks; -using GmRelay.Bot.Features.Sessions.CreateSession.Wizard; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using Microsoft.Extensions.Logging.Abstractions; using Telegram.Bot.Types; using Telegram.Bot.Types.ReplyMarkups; -using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard; -using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger; +using WizardBot = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; /// -/// Hand-rolled test doubles and helpers for wizard unit tests. The project -/// convention is to use fakes (not a mocking framework) so the suite stays -/// AOT-friendly and the production code doesn't grow virtual members just -/// for tests. +/// Hand-rolled test doubles and helpers for wizard unit tests. The +/// project convention is to use fakes (not a mocking framework) so the +/// suite stays AOT-friendly and the production code doesn't grow +/// virtual members just for tests. /// internal static class WizardTestFakes { + public const string PlatformName = "Telegram"; + public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger) { drafts = new FakeWizardDraftRepository(); @@ -30,11 +31,12 @@ internal static class WizardTestFakes public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new() { Id = Guid.NewGuid(), - ChatId = 42, + ChatId = "42", MessageThreadId = null, - OwnerTelegramId = ownerId, + OwnerId = ownerId.ToString(CultureInfo.InvariantCulture), + Platform = PlatformName, Step = step, - DraftMessageId = 7, + DraftMessageId = "7", PayloadJson = System.Text.Json.JsonSerializer.Serialize( payload ?? new WizardPayload(), WizardPayloadJsonContext.Default.WizardPayload), @@ -43,6 +45,63 @@ internal static class WizardTestFakes ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), }; + /// + /// Build the platform-neutral the + /// wizard now consumes. Pre-V112 callers passed + /// Telegram.Bot.Types.Update directly; tests now build the + /// neutral interaction via the same mapper the production code uses. + /// + 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); + } + + /// + /// Build a text-style mirroring what + /// WizardInteractionMapper would produce for a Telegram text + /// message. + /// + 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}"); + } + + /// + /// Build a photo-style mirroring + /// what WizardInteractionMapper would produce for a Telegram + /// photo message. + /// + 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}"); + } + + /// + /// Build a Telegram carrying a callback query. + /// Used by router-level tests that exercise + /// UpdateRouter.RouteAsync end-to-end. + /// public static Update CallbackUpdate(string data, long ownerId = 100) => new() { CallbackQuery = new CallbackQuery @@ -57,6 +116,11 @@ internal static class WizardTestFakes }, }; + /// + /// Build a Telegram carrying a text message. + /// Used by router-level tests that exercise + /// UpdateRouter.RouteAsync end-to-end. + /// public static Update TextUpdate(string text, long ownerId = 100) => new() { Message = new Message @@ -69,9 +133,9 @@ internal static class WizardTestFakes } /// -/// Records every call the wizard makes against the draft repository. Backed by -/// an in-memory dictionary so tests can pre-seed an "active" draft for the -/// wizard to mutate. +/// Records every call the wizard makes against the draft repository. +/// Backed by an in-memory dictionary so tests can pre-seed an "active" +/// draft for the wizard to mutate. /// internal sealed class FakeWizardDraftRepository : IWizardDraftRepository { @@ -85,13 +149,12 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository public void Seed(WizardDraft draft) => store[draft.Id] = draft; - public Task GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct) + public Task GetActiveAsync(string platform, string ownerId, CancellationToken ct) { foreach (var d in store.Values) { - if (d.ChatId == chatId && - d.MessageThreadId == messageThreadId && - d.OwnerTelegramId == ownerTelegramId && + if (d.Platform == platform && + d.OwnerId == ownerId && d.ExpiresAt > DateTimeOffset.UtcNow) { return Task.FromResult(d); @@ -108,7 +171,8 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository Id = draft.Id, ChatId = draft.ChatId, MessageThreadId = draft.MessageThreadId, - OwnerTelegramId = draft.OwnerTelegramId, + OwnerId = draft.OwnerId, + Platform = draft.Platform, Step = draft.Step, PayloadJson = draft.PayloadJson, DraftMessageId = draft.DraftMessageId, @@ -136,11 +200,14 @@ internal sealed class FakeWizardDraftRepository : IWizardDraftRepository } /// -/// Records every call the wizard makes against the messenger. Default return -/// values (empty clubs, message-id 1) match what the wizard expects to see -/// in steady state. +/// Records every call the wizard makes against the messenger. Default +/// return values (empty clubs, message-id 99) match what the wizard +/// expects to see in steady state. The recorded tuple shapes match +/// the old ITelegramWizardMessenger recorders so existing test +/// assertions (edit.ChatId, edit.Text, …) keep working +/// after the refactor. /// -internal sealed class FakeWizardMessenger : ITelegramWizardMessenger +internal sealed class FakeWizardMessenger : IWizardMessenger { public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new(); @@ -148,37 +215,44 @@ internal sealed class FakeWizardMessenger : ITelegramWizardMessenger public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new(); + public List<(string OwnerId, IReadOnlyList Actions)> EditActions { get; } = new(); + public IReadOnlyList Clubs { get; set; } = Array.Empty(); - public Task EditMessageTextAsync( - long chatId, - int? messageThreadId, - long messageId, + public Task EditDraftMessageAsync( + WizardDraft draft, string text, - InlineKeyboardMarkup keyboard, + IReadOnlyList keyboard, CancellationToken ct) { - Edits.Add((chatId, messageThreadId, messageId, text)); - return Task.FromResult(messageId); + Edits.Add(( + long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0, + int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null, + long.TryParse(draft.DraftMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var msgId) ? msgId : 0, + text)); + EditActions.Add((draft.OwnerId, keyboard)); + return Task.FromResult(draft.DraftMessageId ?? "0"); } - public Task SendGroupMessageAsync( - long chatId, - int? messageThreadId, + public Task SendDraftMessageAsync( + WizardDraft draft, string text, - InlineKeyboardMarkup keyboard, + IReadOnlyList keyboard, CancellationToken ct) { - Sends.Add((chatId, messageThreadId, text)); - return Task.FromResult(99L); + Sends.Add(( + long.TryParse(draft.ChatId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chatId) ? chatId : 0, + int.TryParse(draft.MessageThreadId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var threadId) ? threadId : (int?)null, + text)); + return Task.FromResult("99"); } - public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct) + public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct) { - AnsweredCallbacks.Add(callbackId); + AnsweredCallbacks.Add(interactionId); return Task.CompletedTask; } - public Task> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct) + public Task> GetOwnerClubsAsync(string ownerId, CancellationToken ct) => Task.FromResult(Clubs); }