Files
GmRelayBot/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
T
Toutsu 014b5edd31
PR Checks / test-and-build (pull_request) Successful in 15m52s
feat(bot): add online/offline wizard locations
Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages.

Bump version to 3.10.0.
2026-06-10 11:29:25 +03:00

359 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <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;
private readonly IPlatformMessenger? _platformMessenger;
private readonly NpgsqlDataSource? _dataSource;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
IWizardMessenger messenger,
ILogger<CreateSessionHandler> log,
IPlatformMessenger? platformMessenger = null,
NpgsqlDataSource? dataSource = null)
{
_drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
_platformMessenger = platformMessenger;
_dataSource = dataSource;
}
/// <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);
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<WizardAction>(),
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<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);
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<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
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<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: 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<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.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<WizardAction> RetryCancelActions() => new[]
{
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}