From 956ec01583d949469cb91e7a3ade5b67709bb9c8 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 9 Jun 2026 16:16:36 +0300 Subject: [PATCH] fix(bot): publish wizard-created sessions After the shared create handler persists sessions, create a Telegram topic when needed, send the schedule/signup message, and store thread_id/batch_message_id/topic_created_by_bot for the batch. Add a Testcontainers regression test for the wizard SubmitDraftAsync happy path. Bump version to 3.9.9. --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 6 +- .../CreateSession/CreateSessionHandler.cs | 111 +++++++++++++++-- .../Components/Layout/NavMenu.razor | 2 +- ...ateSessionHandlerSubmitSingleDraftTests.cs | 114 +++++++++++++++--- 6 files changed, 208 insertions(+), 29 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8543e4c..27297b8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.9.8 + VERSION: 3.9.9 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index aaff13b..cd125aa 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.9.8 + 3.9.9 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index e27dd62..f1cd68a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.8 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.9 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.8 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.9 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.8 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.9 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index e3190f8..9a4f797 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -5,11 +5,13 @@ 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; @@ -31,17 +33,23 @@ public sealed class CreateSessionHandler 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) + ILogger log, + IPlatformMessenger? platformMessenger = null, + NpgsqlDataSource? dataSource = null) { _drafts = drafts; _shared = shared; _messenger = messenger; _log = log; + _platformMessenger = platformMessenger; + _dataSource = dataSource; } /// @@ -106,19 +114,24 @@ public sealed class CreateSessionHandler } var commands = BuildCommands(draft, payload); + var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>(); try { foreach (var cmd in commands) { - await _shared.HandleAsync(cmd, ct); + var result = await _shared.HandleAsync(cmd, ct); + if (!result.Success) + { + await _messenger.EditDraftMessageAsync( + draft, + result.ErrorMessage ?? "❌ Не удалось создать сессию.", + Array.Empty(), + ct); + return; + } + + created.Add((cmd, result)); } - 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) { @@ -142,9 +155,89 @@ public sealed class CreateSessionHandler $"💥 Ошибка: {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. diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index dd73424..1e26b46 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -82,7 +82,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs index af8e265..f0744a0 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/CreateSessionHandlerSubmitSingleDraftTests.cs @@ -1,19 +1,105 @@ -using System; -using Xunit; +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; -/// -/// Happy-path coverage for -/// on a single-game wizard payload. The success path calls the shared -/// CreateSessionHandler.HandleAsync, which needs a real -/// NpgsqlDataSource (it runs SQL against game_groups, players, -/// sessions, and related tables). The missing-fields and validation -/// branches are covered by the dedicated tests in this folder. -/// -public sealed class CreateSessionHandlerSubmitSingleDraftTests +[Collection(CreateSessionHandlerPostgresCollection.Name)] +public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture) { - [Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")] - public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() => - throw new NotImplementedException("See Skip reason above."); + [Fact] + public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs() + { + var connectionString = await fixture.CreateMigratedDatabaseAsync(); + await using var dataSource = NpgsqlDataSource.Create(connectionString); + var drafts = new FakeWizardDraftRepository(); + var wizardMessenger = new FakeWizardMessenger(); + var platformMessenger = new FakePlatformMessenger(); + + var sut = new CreateSessionHandler( + drafts, + new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler(dataSource), + wizardMessenger, + NullLogger.Instance, + platformMessenger, + dataSource); + + var payload = new WizardPayload + { + Type = WizardCreationType.Single, + Title = "Тест публикации", + System = "Dnd5e", + DurationMinutes = 240, + Visibility = WizardVisibility.Public, + Single = new WizardSingleInput + { + ScheduledAt = DateTimeOffset.UtcNow.AddDays(7), + MaxPlayers = null, + }, + }; + var draft = NewDraft(WizardStepNames.Confirm, payload, ownerId: 111111111); + draft.ChatId = "-1003916537960"; + draft.DraftMessageId = "7"; + drafts.Seed(draft); + + await sut.SubmitDraftAsync(draft, CancellationToken.None); + + Assert.Single(platformMessenger.CreatedThreads); + Assert.Equal("Тест публикации", platformMessenger.CreatedThreads[0].Title); + Assert.Single(platformMessenger.SentSchedules); + Assert.Equal("456", platformMessenger.SentSchedules[0].Group.ExternalThreadId); + Assert.Contains(draft.Id, drafts.DeletedIds); + Assert.Contains(wizardMessenger.Edits, edit => edit.Text.Contains("✅ Создано: 1 сессия", StringComparison.Ordinal)); + + await using var connection = await dataSource.OpenConnectionAsync(); + await using var command = new NpgsqlCommand( + """ + SELECT thread_id, batch_message_id, topic_created_by_bot + FROM sessions + ORDER BY created_at DESC + LIMIT 1 + """, + connection); + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + Assert.Equal(456, reader.GetInt32(0)); + Assert.Equal(789, reader.GetInt32(1)); + Assert.True(reader.GetBoolean(2)); + } +} + +internal sealed class FakePlatformMessenger : IPlatformMessenger +{ + public List<(PlatformGroup Group, string Title)> CreatedThreads { get; } = new(); + + public List SentSchedules { get; } = new(); + + public Task CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) + { + CreatedThreads.Add((group, title)); + return Task.FromResult(new PlatformMessageRef(group.Platform, group.ExternalGroupId, "456", string.Empty)); + } + + public Task SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) + { + SentSchedules.Add(message); + return Task.FromResult(new PlatformMessageRef( + message.Group.Platform, + message.Group.ExternalGroupId, + message.Group.ExternalThreadId, + "789")); + } + + public Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => Task.CompletedTask; + + public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) => Task.CompletedTask; + + public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) => Task.CompletedTask; + + public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) => Task.CompletedTask; + + public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) => Task.CompletedTask; }