From 7cb5b03cc2c9863d6865389f713b6392da415d8a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 10 Jun 2026 12:23:48 +0300 Subject: [PATCH] fix(bot): skip join-link reminders without links Prevent offline sessions with empty join links from entering the 5-minute join-link notification flow, omit blank link lines from direct reminders, and add offline persistence/reminder regression coverage. --- .../Telegram/TelegramPlatformMessenger.cs | 48 +++++++++---- .../SendJoinLink/SendJoinLinkHandler.cs | 1 + .../Scheduling/ISessionTriggerStore.cs | 1 + .../CreateSessionHandlerIntegrationTests.cs | 45 ++++++++++++ .../Scheduling/DbSessionTriggerStoreTests.cs | 68 +++++++++++++++++++ .../TelegramPlatformMessengerTests.cs | 27 ++++++++ 6 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/DbSessionTriggerStoreTests.cs diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs index 38757c1..fb38de9 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs @@ -405,19 +405,8 @@ public sealed class TelegramPlatformMessenger( Ответьте кнопкой в групповом сообщении расписания. """, - PlatformDirectSessionNotificationKind.OneHourReminder => $""" - ⏰ Игра начнётся примерно через 1 час - - 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} - 📅 {notification.ScheduledAt.FormatMoscow()} (МСК) - 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} - """, - PlatformDirectSessionNotificationKind.JoinLink => $""" - 🎮 Игра начинается через 5 минут - - 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} - 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} - """, + PlatformDirectSessionNotificationKind.OneHourReminder => BuildOneHourReminderDirectText(notification), + PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification), PlatformDirectSessionNotificationKind.RescheduleApproved => $""" ✅ Сессия перенесена по итогам голосования @@ -434,6 +423,39 @@ public sealed class TelegramPlatformMessenger( _ => BuildFallbackDirectText(notification) }; + private static string BuildOneHourReminderDirectText(PlatformDirectSessionNotification notification) + { + var lines = new List + { + "⏰ Игра начнётся примерно через 1 час", + string.Empty, + $"📌 {System.Net.WebUtility.HtmlEncode(notification.Title)}", + $"📅 {notification.ScheduledAt.FormatMoscow()} (МСК)" + }; + AppendJoinLinkLine(lines, notification.JoinLink); + return string.Join("\n", lines); + } + + private static string BuildJoinLinkDirectText(PlatformDirectSessionNotification notification) + { + var lines = new List + { + "🎮 Игра начинается через 5 минут", + string.Empty, + $"📌 {System.Net.WebUtility.HtmlEncode(notification.Title)}" + }; + AppendJoinLinkLine(lines, notification.JoinLink); + return string.Join("\n", lines); + } + + private static void AppendJoinLinkLine(List lines, string? joinLink) + { + if (!string.IsNullOrWhiteSpace(joinLink)) + { + lines.Add($"🔗 {System.Net.WebUtility.HtmlEncode(joinLink)}"); + } + } + private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) => $"{System.Net.WebUtility.HtmlEncode(notification.Title)}\n{notification.ScheduledAt.FormatMoscow()} (МСК)"; diff --git a/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs index 5f550a0..40a7466 100644 --- a/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs +++ b/src/GmRelay.Shared/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -57,6 +57,7 @@ public sealed class SendJoinLinkHandler( JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND s.status = @Confirmed + AND btrim(s.join_link) <> '' AND ( (g.platform = 'Telegram' AND s.link_message_id IS NULL) OR ( diff --git a/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs b/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs index 99e34b0..6471d3c 100644 --- a/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs +++ b/src/GmRelay.Shared/Infrastructure/Scheduling/ISessionTriggerStore.cs @@ -81,6 +81,7 @@ public sealed class DbSessionTriggerStore( JOIN game_groups g ON g.id = s.group_id WHERE g.platform = @Platform AND s.status = @Confirmed + AND btrim(s.join_link) <> '' AND s.scheduled_at - @LeadTime <= @Now AND ( (g.platform = 'Telegram' AND s.link_message_id IS NULL) diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs index cca5300..fc857c5 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerIntegrationTests.cs @@ -144,4 +144,49 @@ public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPos Assert.Equal("Online room notes", reader.GetString(2)); Assert.False(await reader.ReadAsync()); } + + [Fact] + public async Task HandleAsync_OfflineSession_PersistsFormatAndLocationAddress() + { + var connectionString = await fixture.CreateMigratedDatabaseAsync(); + await using var dataSource = NpgsqlDataSource.Create(connectionString); + var sut = new CreateSessionHandler(dataSource); + + var result = await sut.HandleAsync( + new CreateSessionCommand( + new PlatformUser(PlatformKind.Telegram, "333333333", "Offline GM", "offline_gm"), + new PlatformGroup(PlatformKind.Telegram, "444444444", "Offline Group"), + "Offline Adventure", + string.Empty, + [DateTimeOffset.UtcNow.AddDays(1)], + 4, + null, + GameSystem.Dnd5e, + "Offline integration regression test", + "Offline", + 240, + true, + "Москва, ул. Кубиков, 12"), + CancellationToken.None); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(result.BatchId); + + await using var connection = await dataSource.OpenConnectionAsync(); + await using var command = new NpgsqlCommand( + """ + SELECT join_link, format, location_address + FROM sessions + WHERE batch_id = @batch_id + """, + connection); + command.Parameters.AddWithValue("batch_id", result.BatchId.Value); + + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + Assert.Equal(string.Empty, reader.GetString(0)); + Assert.Equal("Offline", reader.GetString(1)); + Assert.Equal("Москва, ул. Кубиков, 12", reader.GetString(2)); + Assert.False(await reader.ReadAsync()); + } } diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/DbSessionTriggerStoreTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/DbSessionTriggerStoreTests.cs new file mode 100644 index 0000000..be0a815 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Scheduling/DbSessionTriggerStoreTests.cs @@ -0,0 +1,68 @@ +using GmRelay.Bot.Tests.Features.Sessions.CreateSession; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Infrastructure.Scheduling; +using GmRelay.Shared.Platform; +using Npgsql; + +namespace GmRelay.Bot.Tests.Infrastructure.Scheduling; + +[Collection(CreateSessionHandlerPostgresCollection.Name)] +public sealed class DbSessionTriggerStoreTests(CreateSessionHandlerPostgresFixture fixture) +{ + [Fact] + public async Task GetSessionsNeedingJoinLinkAsync_IgnoresConfirmedSessionsWithoutJoinLink() + { + var connectionString = await fixture.CreateMigratedDatabaseAsync(); + await using var dataSource = NpgsqlDataSource.Create(connectionString); + await using var connection = await dataSource.OpenConnectionAsync(); + + var groupId = await InsertTelegramGroupAsync(connection); + var dueAt = DateTimeOffset.UtcNow.AddMinutes(4).UtcDateTime; + var onlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, "https://vtt.example/game", "Online"); + var offlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, string.Empty, "Offline"); + + var sut = new DbSessionTriggerStore(dataSource, new PlatformSchedulerOptions(PlatformKind.Telegram)); + + var result = await sut.GetSessionsNeedingJoinLinkAsync(DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.Contains(onlineSessionId, result); + Assert.DoesNotContain(offlineSessionId, result); + } + + private static async Task InsertTelegramGroupAsync(NpgsqlConnection connection) + { + await using var command = new NpgsqlCommand( + """ + INSERT INTO game_groups (name, platform, external_group_id) + VALUES ('Trigger Test Group', 'Telegram', @ExternalGroupId) + RETURNING id + """, + connection); + command.Parameters.AddWithValue("ExternalGroupId", Guid.NewGuid().ToString("N")); + + return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Group insert failed.")); + } + + private static async Task InsertSessionAsync( + NpgsqlConnection connection, + Guid groupId, + DateTime scheduledAt, + string joinLink, + string format) + { + await using var command = new NpgsqlCommand( + """ + INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, format) + VALUES (@GroupId, 'Trigger Test Session', @JoinLink, @ScheduledAt, @Status, @Format) + RETURNING id + """, + connection); + command.Parameters.AddWithValue("GroupId", groupId); + command.Parameters.AddWithValue("JoinLink", joinLink); + command.Parameters.AddWithValue("ScheduledAt", scheduledAt); + command.Parameters.AddWithValue("Status", SessionStatus.Confirmed); + command.Parameters.AddWithValue("Format", format); + + return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Session insert failed.")); + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs index 7652435..0dbdf80 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerTests.cs @@ -2,6 +2,7 @@ using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Microsoft.Extensions.Logging.Abstractions; +using System.Reflection; namespace GmRelay.Bot.Tests.Infrastructure.Telegram; @@ -36,9 +37,35 @@ public sealed class TelegramPlatformMessengerTests Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message); } + [Fact] + public void BuildDirectNotificationText_OneHourReminderWithoutJoinLink_ShouldNotRenderBlankLinkLine() + { + var notification = new PlatformDirectSessionNotification( + PlatformDirectSessionNotificationKind.OneHourReminder, + new PlatformUser(PlatformKind.Telegram, "123", "Player", "player"), + Guid.NewGuid(), + "Offline Game", + DateTime.UtcNow, + JoinLink: string.Empty); + + var text = InvokeBuildDirectNotificationText(notification); + + Assert.DoesNotContain("🔗", text); + } + private static TelegramPlatformMessenger CreateMessenger() => new(null!, NullLogger.Instance); + private static string InvokeBuildDirectNotificationText(PlatformDirectSessionNotification notification) + { + var method = typeof(TelegramPlatformMessenger).GetMethod( + "BuildDirectNotificationText", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + return Assert.IsType(method.Invoke(null, new object[] { notification })); + } + private static SessionBatchViewModel CreateView() => new("Test batch", []); }