8f0f2ef7e7
Moves the game-creation wizard state machine, view builder, and platform-neutral contracts (callback data, step names, storage exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared. Telegram continues to work through a new TelegramWizardMessenger implementing IWizardMessenger and a WizardInteractionMapper that converts Update → WizardInteraction. Wires the new platform column on wizard_drafts (V032 migration) and switches chat/owner/thread/message ids to TEXT so the same table can hold Discord snowflakes later. - GameCreationWizard: now in Shared, takes IWizardMessenger + IWizardDraftRepository, dispatches on WizardInteraction. - New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs (returns string ids so Telegram longs and Discord snowflakes both fit). - New WizardStepViewBuilder in Shared returns (text, IReadOnlyList<WizardAction>); TelegramWizardMessenger renders actions into InlineKeyboardMarkup via a new Bot-side ToInlineKeyboard helper. - New WizardInteractionMapper in Bot (5-case test) converts Telegram Update to WizardInteraction. - WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/ DraftMessageId switched to string. V032 migrates existing rows and rebuilds the owner lookup index on (platform, owner_id). - All existing wizard / create-session tests updated to the new contract (HandleInteractionAsync + WizardInteraction). Wizard callback-data format preserved. - dotnet build clean, dotnet format --verify-no-changes clean, all 101 wizard tests pass.
261 lines
10 KiB
C#
261 lines
10 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using GmRelay.Shared.Domain;
|
||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||
using GmRelay.Shared.Platform;
|
||
using Microsoft.Extensions.Logging;
|
||
using Telegram.Bot.Types;
|
||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||
|
||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||
|
||
/// <summary>
|
||
/// Telegram-side entry point for the wizard-driven session creation
|
||
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
|
||
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
|
||
/// platform glue (mapping <c>Message</c> to draft fields, rendering
|
||
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
|
||
/// </summary>
|
||
public sealed class CreateSessionHandler
|
||
{
|
||
private const int MaxRetries = 3;
|
||
private const string PlatformName = "Telegram";
|
||
|
||
private readonly IWizardDraftRepository _drafts;
|
||
private readonly SharedCreateSessionHandler _shared;
|
||
private readonly IWizardMessenger _messenger;
|
||
private readonly ILogger<CreateSessionHandler> _log;
|
||
|
||
public CreateSessionHandler(
|
||
IWizardDraftRepository drafts,
|
||
SharedCreateSessionHandler shared,
|
||
IWizardMessenger messenger,
|
||
ILogger<CreateSessionHandler> log)
|
||
{
|
||
_drafts = drafts;
|
||
_shared = shared;
|
||
_messenger = messenger;
|
||
_log = log;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Entry point for <c>/newsession</c>. If a non-expired draft
|
||
/// already exists for this owner, returns <c>null</c> so the caller
|
||
/// can render a "Continue / Start over / Cancel" menu.
|
||
/// </summary>
|
||
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
|
||
{
|
||
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||
if (existing is not null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var draft = new WizardDraft
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
|
||
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
|
||
OwnerId = ownerId,
|
||
Platform = PlatformName,
|
||
Step = WizardStepNames.Type,
|
||
CreatedAt = DateTimeOffset.UtcNow,
|
||
UpdatedAt = DateTimeOffset.UtcNow,
|
||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||
};
|
||
await _drafts.UpsertAsync(draft, ct);
|
||
|
||
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||
draft.DraftMessageId = msgId;
|
||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||
await _drafts.UpsertAsync(draft, ct);
|
||
return draft;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resume an existing draft — returns the draft row so the caller
|
||
/// can re-render the resume/reset menu.
|
||
/// </summary>
|
||
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
|
||
{
|
||
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Finalize: build shared command(s), call the shared handler, edit
|
||
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
|
||
/// times before deleting the draft.
|
||
/// </summary>
|
||
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
|
||
{
|
||
var payload = LoadPayload(draft);
|
||
if (!IsComplete(payload, out var missing))
|
||
{
|
||
await _messenger.EditDraftMessageAsync(
|
||
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
|
||
return;
|
||
}
|
||
|
||
var commands = BuildCommands(draft, payload);
|
||
try
|
||
{
|
||
foreach (var cmd in commands)
|
||
{
|
||
await _shared.HandleAsync(cmd, ct);
|
||
}
|
||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||
await _messenger.EditDraftMessageAsync(
|
||
draft,
|
||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||
Array.Empty<WizardAction>(),
|
||
ct);
|
||
await _drafts.DeleteAsync(draft.Id, ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
|
||
payload.RetryCount += 1;
|
||
SavePayload(draft, payload);
|
||
if (payload.RetryCount >= MaxRetries)
|
||
{
|
||
await _messenger.EditDraftMessageAsync(
|
||
draft,
|
||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||
Array.Empty<WizardAction>(),
|
||
ct);
|
||
await _drafts.DeleteAsync(draft.Id, ct);
|
||
return;
|
||
}
|
||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||
await _drafts.UpsertAsync(draft, ct);
|
||
await _messenger.EditDraftMessageAsync(
|
||
draft,
|
||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||
RetryCancelActions(),
|
||
ct);
|
||
}
|
||
}
|
||
|
||
// ── Build shared commands ────────────────────────────────────────
|
||
// The shared handler creates one session per scheduled time in a
|
||
// single transaction and assigns the same batch_id to all of them.
|
||
// A wizard pool therefore produces ONE command with N times; a
|
||
// single-game wizard produces ONE command with one time.
|
||
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
||
{
|
||
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||
{
|
||
return new List<CreateSessionCommand>
|
||
{
|
||
BuildCommand(
|
||
draft,
|
||
p,
|
||
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||
MaxPlayersForPool(pool),
|
||
isOneShot: false),
|
||
};
|
||
}
|
||
return new List<CreateSessionCommand>
|
||
{
|
||
BuildCommand(
|
||
draft,
|
||
p,
|
||
new[] { p.Single?.ScheduledAt ?? default },
|
||
p.Single?.MaxPlayers ?? 0,
|
||
isOneShot: true),
|
||
};
|
||
}
|
||
|
||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||
|
||
private static CreateSessionCommand BuildCommand(
|
||
WizardDraft draft,
|
||
WizardPayload p,
|
||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||
int maxPlayers,
|
||
bool isOneShot)
|
||
{
|
||
var user = new PlatformUser(
|
||
PlatformKind.Telegram,
|
||
draft.OwnerId,
|
||
DisplayName: string.Empty,
|
||
ExternalUsername: null);
|
||
var group = new PlatformGroup(
|
||
PlatformKind.Telegram,
|
||
draft.ChatId,
|
||
DisplayName: string.Empty,
|
||
ExternalChannelId: null,
|
||
ExternalThreadId: draft.MessageThreadId);
|
||
return new CreateSessionCommand(
|
||
User: user,
|
||
Group: group,
|
||
Title: p.Title ?? string.Empty,
|
||
Link: string.Empty,
|
||
ScheduledTimes: scheduledTimes,
|
||
MaxPlayers: maxPlayers,
|
||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||
System: ParseSystem(p.System),
|
||
Description: p.Description,
|
||
Format: null,
|
||
DurationMinutes: p.DurationMinutes,
|
||
IsOneShot: isOneShot);
|
||
}
|
||
|
||
private static GameSystem? ParseSystem(string? code)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(code)) return null;
|
||
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
|
||
}
|
||
|
||
// ── Validation ───────────────────────────────────────────────────
|
||
private static bool IsComplete(WizardPayload p, out string missing)
|
||
{
|
||
var missingFields = new List<string>();
|
||
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
||
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
||
if (p.Visibility is null) missingFields.Add("видимость");
|
||
|
||
if (p.Type == WizardCreationType.Single)
|
||
{
|
||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
||
}
|
||
else
|
||
{
|
||
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
|
||
}
|
||
missing = string.Join(", ", missingFields);
|
||
return missingFields.Count == 0;
|
||
}
|
||
|
||
// ── Payload I/O ──────────────────────────────────────────────────
|
||
private static WizardPayload LoadPayload(WizardDraft draft)
|
||
{
|
||
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
||
return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||
}
|
||
|
||
private static void SavePayload(WizardDraft draft, WizardPayload p)
|
||
{
|
||
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
|
||
}
|
||
|
||
// ── Keyboards ────────────────────────────────────────────────────
|
||
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||
{
|
||
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||
};
|
||
}
|