feat(#21): support selected telegram topics for schedules
PR Checks / test-and-build (pull_request) Failing after 3m18s

Route new schedules to an existing forum topic when /newsession is sent inside one, create bot-owned topics only from the forum root, and keep group notifications/dashboard updates threaded to the stored topic.

Persist topic ownership so deletion only removes empty bot-created topics, add topic routing tests and smoke coverage, and bump release metadata to 1.14.0.
This commit is contained in:
2026-05-12 12:01:10 +03:00
parent 007806a5d8
commit de9f56c97d
21 changed files with 334 additions and 50 deletions
@@ -56,7 +56,7 @@
</button>
</form>
<div class="nav-version">v1.13.0</div>
<div class="nav-version">v1.14.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -79,4 +79,4 @@
private void ToggleMenu() => isOpen = !isOpen;
private void CloseMenu() => isOpen = false;
}
}
+38 -17
View File
@@ -38,7 +38,8 @@ public sealed record WebSession(
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
int? ThreadId = null);
public sealed record WebParticipant(
Guid Id,
@@ -73,7 +74,8 @@ internal sealed record WebBatchSessionRow(
int? BatchMessageId,
long TelegramChatId,
int? ThreadId,
string NotificationMode);
string NotificationMode,
bool TopicCreatedByBot = false);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
public sealed class SessionService(
@@ -314,7 +316,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -351,7 +354,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -409,7 +413,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -463,7 +468,11 @@ public sealed class SessionService(
"\n" +
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
await bot.SendMessage(
chatId: oldSession.TelegramChatId,
messageThreadId: oldSession.ThreadId,
text: notification,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
if (mode.ShouldSendDirectMessages())
@@ -490,7 +499,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -567,8 +577,9 @@ public sealed class SessionService(
await transaction.CommitAsync();
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (session.BatchMessageId.HasValue)
@@ -612,7 +623,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -697,15 +709,17 @@ public sealed class SessionService(
await transaction.CommitAsync();
await bot.SendMessage(
session.TelegramChatId,
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: $"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (promoted is not null)
{
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (session.BatchMessageId.HasValue)
@@ -813,6 +827,7 @@ public sealed class SessionService(
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
@@ -865,7 +880,11 @@ public sealed class SessionService(
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
await bot.SendMessage(
chatId: firstSession.TelegramChatId,
messageThreadId: firstSession.ThreadId,
text: notification,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
if (mode.ShouldSendDirectMessages())
@@ -892,6 +911,7 @@ public sealed class SessionService(
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
@@ -920,8 +940,8 @@ public sealed class SessionService(
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
@@ -933,6 +953,7 @@ public sealed class SessionService(
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
ThreadId = threadId,
sourceSession.TopicCreatedByBot,
sourceSession.MaxPlayers,
sourceSession.NotificationMode
},