15040eb954
In the session creation wizard (Telegram + Discord), the Capacity step only exposed waitlist on/off buttons. The 'no waitlist' button silently advanced to the next step without setting MaxPlayers, so users who tried to create a session with no player cap were blocked with 'Не заполнены поля: лимит мест'. The DB contract and CreateSessionCommand already supported null MaxPlayers (int?, ck_sessions_max_players check in V006), and the web form already exposes 'Без лимита' as an empty InputNumber — only the wizard flow was broken. Changes: - Add '♾ Без лимита' choice button to Capacity in shared WizardStepViewBuilder.BuildCapacity (Telegram) and to RenderCapacity / RenderPoolSlotCapacity in DiscordWizardStep (Discord). - Add 'no_limit' branch to GameCreationWizard.ApplyCapacityChoice that sets MaxPlayers to null and advances to Visibility. - Change GameCreationWizard.SetMaxPlayers signature from int to int? so the 'no limit' branch compiles. - Change CreateSessionCommand builder in both Telegram and Discord submitters to take int? maxPlayers and drop the '?? 0' that would have turned null into 0 (violating the DB CHECK and the 'no limit' contract). - In Discord BuildConfirmDescription, render '👥 Без лимита, waitlist вкл/выкл' when MaxPlayers is null (the previous code silently omitted the line). - Expose BuildCommand as internal in both submitters and add InternalsVisibleTo('GmRelay.Bot.Tests') to the DiscordBot assembly for unit-test access. Tests (9 new): - WizardStepRenderTests.CapacityStep_HasWaitlistButtons — asserts the 'Без лимита' button is present. - GameCreationWizardStepTransitionsTests.NoLimitCapacityButton_… — asserts the choice advances to Visibility and leaves MaxPlayers null in the JSON draft. - GameCreationWizardStepTransitionsTests.ChoiceCallback_AdvancesToExpectedStep — new Theory row for Capacity/no_limit. - CreateSessionHandlerBuildCommandTests (new) — null/value propagation through the Telegram submitter's BuildCommand. - DiscordWizardStepCapacityRenderTests (new) — 'Без лимита' button is rendered for both Capacity and PoolSlotCapacity, with the expected custom-id shape. - DiscordWizardSubmitterBuildCommandTests (new) — null/value propagation through the Discord submitter's BuildCommand. Closes #123
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 = DateTime.UtcNow,
|
||
UpdatedAt = DateTime.UtcNow,
|
||
ExpiresAt = DateTime.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 = DateTime.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 = DateTime.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,
|
||
isOneShot: true),
|
||
};
|
||
}
|
||
|
||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||
|
||
internal 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),
|
||
};
|
||
}
|