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", []);
}