014b5edd31
PR Checks / test-and-build (pull_request) Successful in 15m52s
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.
359 lines
15 KiB
C#
359 lines
15 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 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),
|
||
};
|
||
}
|