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
@@ -14,6 +14,7 @@ public sealed record CancelSessionCommand(
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int? MessageThreadId,
int MessageId);
// DTOs for AOT compilation
@@ -29,7 +30,7 @@ public sealed class CancelSessionHandler(
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
"""
@@ -117,9 +118,14 @@ public sealed class CancelSessionHandler(
ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
// Опционально: написать отдельное сообщение в чат
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
await bot.SendMessage(
chatId: command.ChatId,
messageThreadId: command.MessageThreadId,
text: $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
@@ -144,14 +144,31 @@ public sealed class CreateSessionHandler(
transaction);
}
int? messageThreadId = null;
if (message.Chat.IsForum)
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
if (topicDestination.ShouldCreateForumTopic)
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
try
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
TelegramTopicRouting.MissingForumTopicRightsMessage,
cancellationToken: cancellationToken);
return;
}
}
var batchId = Guid.NewGuid();
@@ -161,8 +178,8 @@ public sealed class CreateSessionHandler(
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
RETURNING id;
""",
new
@@ -173,6 +190,7 @@ public sealed class CreateSessionHandler(
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},