using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Dapper; 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 Npgsql; 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; private readonly IPlatformMessenger? _platformMessenger; private readonly NpgsqlDataSource? _dataSource; public CreateSessionHandler( IWizardDraftRepository drafts, SharedCreateSessionHandler shared, IWizardMessenger messenger, ILogger log, IPlatformMessenger? platformMessenger = null, NpgsqlDataSource? dataSource = null) { _drafts = drafts; _shared = shared; _messenger = messenger; _log = log; _platformMessenger = platformMessenger; _dataSource = dataSource; } /// /// 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 = 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; } /// /// 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); var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>(); try { foreach (var cmd in commands) { var result = await _shared.HandleAsync(cmd, ct); if (!result.Success) { await _messenger.EditDraftMessageAsync( draft, result.ErrorMessage ?? "❌ Не удалось создать сессию.", Array.Empty(), ct); return; } created.Add((cmd, result)); } } 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 = DateTime.UtcNow; await _drafts.UpsertAsync(draft, ct); await _messenger.EditDraftMessageAsync( draft, $"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.", RetryCancelActions(), ct); return; } var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count); try { foreach (var item in created) { await PublishCreatedSessionAsync(item.Command, item.Result, ct); } } catch (Exception ex) { _log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id); await _messenger.EditDraftMessageAsync( draft, $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}", Array.Empty(), ct); await _drafts.DeleteAsync(draft.Id, ct); return; } await _messenger.EditDraftMessageAsync( draft, $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}", Array.Empty(), ct); await _drafts.DeleteAsync(draft.Id, ct); } private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct) { if (_platformMessenger is null || _dataSource is null) { throw new InvalidOperationException("Session publication dependencies are not configured."); } if (result.View is null || result.BatchId is null) { throw new InvalidOperationException("Created session result does not contain publication data."); } var group = command.Group; var topicCreatedByBot = false; if (string.IsNullOrWhiteSpace(group.ExternalThreadId)) { var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct); group = group with { ExternalThreadId = thread.ExternalThreadId }; topicCreatedByBot = true; } var scheduleMessage = await _platformMessenger.SendScheduleAsync( new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference), ct); await using var connection = await _dataSource.OpenConnectionAsync(ct); await connection.ExecuteAsync( """ UPDATE sessions SET thread_id = @ThreadId, batch_message_id = @BatchMessageId, topic_created_by_bot = @TopicCreatedByBot, updated_at = now() WHERE batch_id = @BatchId """, new { result.BatchId, ThreadId = ParseNullableInt(group.ExternalThreadId), BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId), TopicCreatedByBot = topicCreatedByBot }); } private static int ParseInt(string value) => int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture); private static int? ParseNullableInt(string? value) => string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture); // ── 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, 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 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: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty, ScheduledTimes: scheduledTimes, MaxPlayers: maxPlayers, ImageReference: p.ImageFileId ?? p.ImageUrl, System: ParseSystem(p.System), Description: p.Description, Format: p.Format?.ToString(), DurationMinutes: p.DurationMinutes, IsOneShot: isOneShot, LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null); } 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.Format is null) missingFields.Add("формат"); if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка"); if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес"); if (p.Visibility is null) missingFields.Add("видимость"); if (p.Type == WizardCreationType.Single) { if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время"); // MaxPlayers = null is a valid "♾ Без лимита" choice // (see GameCreationWizard.ApplyCapacityChoice "no_limit"). } 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), }; }