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; /// /// 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 IWizardMessenger _messenger; private readonly ILogger _log; public CreateSessionHandler( IWizardDraftRepository drafts, SharedCreateSessionHandler shared, IWizardMessenger messenger, ILogger log) { _drafts = drafts; _shared = shared; _messenger = messenger; _log = log; } /// /// 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 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; } /// /// 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) { 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. /// 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(), 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(), 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(), 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 BuildCommands(WizardDraft draft, WizardPayload p) { if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0) { return new List { BuildCommand( draft, p, pool.Slots.Select(s => s.ScheduledAt).ToList(), MaxPlayersForPool(pool), isOneShot: false), }; } return new List { 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 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(code, ignoreCase: true, out var sys) ? sys : null; } // ── Validation ─────────────────────────────────────────────────── private static bool IsComplete(WizardPayload p, out string missing) { var missingFields = new List(); 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 RetryCancelActions() => new[] { new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary), new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger), }; }