feat(#21): support selected telegram topics for schedules
PR Checks / test-and-build (pull_request) Failing after 3m18s
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:
@@ -21,7 +21,8 @@ internal sealed record SessionContext(
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
long GmTelegramId,
|
||||
long TelegramChatId);
|
||||
long TelegramChatId,
|
||||
int? ThreadId);
|
||||
|
||||
internal sealed record ParticipantRsvp(
|
||||
long TelegramId,
|
||||
@@ -95,7 +96,8 @@ public sealed class HandleRsvpHandler(
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.thread_id AS ThreadId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
@@ -191,6 +193,7 @@ public sealed class HandleRsvpHandler(
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
messageThreadId: session.ThreadId,
|
||||
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ internal sealed record SessionInfo(
|
||||
DateTime ScheduledAt,
|
||||
Guid GroupId,
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record ParticipantInfo(
|
||||
@@ -43,6 +44,7 @@ public sealed class SendConfirmationHandler(
|
||||
"""
|
||||
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
@@ -99,6 +101,7 @@ public sealed class SendConfirmationHandler(
|
||||
// 4. Send to group
|
||||
var message = await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
messageThreadId: session.ThreadId,
|
||||
text: text,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
|
||||
@@ -14,6 +14,7 @@ internal sealed record JoinLinkSession(
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record ConfirmedPlayer(
|
||||
@@ -42,6 +43,7 @@ public sealed class SendJoinLinkHandler(
|
||||
"""
|
||||
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
@@ -94,6 +96,7 @@ public sealed class SendJoinLinkHandler(
|
||||
// 4. Send
|
||||
var message = await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
messageThreadId: session.ThreadId,
|
||||
text: text,
|
||||
cancellationToken: ct);
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
@@ -12,7 +13,13 @@ public sealed record DeleteSessionCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
|
||||
internal sealed record DeleteSessionInfoDto(
|
||||
string Title,
|
||||
Guid BatchId,
|
||||
Guid GroupId,
|
||||
bool CanManage,
|
||||
int? ThreadId,
|
||||
bool TopicCreatedByBot);
|
||||
|
||||
public sealed class DeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -29,7 +36,9 @@ public sealed class DeleteSessionHandler(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.group_id AS GroupId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
@@ -57,15 +66,23 @@ public sealed class DeleteSessionHandler(
|
||||
// 2. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
// 3. Check if any sessions are left in the batch
|
||||
var remainingInBatch = await connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId",
|
||||
new { BatchId = session.BatchId }, transaction);
|
||||
var remainingInTopic = session.ThreadId.HasValue
|
||||
? await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sessions
|
||||
WHERE group_id = @GroupId
|
||||
AND thread_id = @ThreadId
|
||||
""",
|
||||
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||
transaction)
|
||||
: 0;
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. If no sessions left and we have a forum topic, delete the topic
|
||||
if (remainingInBatch == 0 && session.ThreadId.HasValue)
|
||||
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
||||
if (session.ThreadId.HasValue &&
|
||||
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -113,7 +130,7 @@ public sealed class DeleteSessionHandler(
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
{
|
||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch {}
|
||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -14,7 +14,7 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
internal sealed record AwaitingProposalDto(
|
||||
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
||||
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
|
||||
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
|
||||
|
||||
internal sealed record VoteParticipantDto(
|
||||
Guid PlayerId,
|
||||
@@ -57,6 +57,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
@@ -84,6 +85,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: proposal.ThreadId,
|
||||
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
@@ -161,6 +163,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
|
||||
var voteMsg = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: proposal.ThreadId,
|
||||
text: voteText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
@@ -242,6 +245,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
||||
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: proposal.ThreadId,
|
||||
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed record InitiateRescheduleCommand(
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int? MessageThreadId,
|
||||
int MessageId);
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
@@ -96,6 +97,7 @@ public sealed class InitiateRescheduleHandler(
|
||||
|
||||
await bot.SendMessage(
|
||||
chatId: command.ChatId,
|
||||
messageThreadId: command.MessageThreadId,
|
||||
text: $"""
|
||||
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ internal sealed record DueRescheduleProposalDto(
|
||||
int? BatchMessageId,
|
||||
int? VoteMessageId,
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
public sealed class RescheduleVotingDeadlineService(
|
||||
@@ -93,6 +94,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
s.batch_id AS BatchId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
@@ -324,6 +326,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: proposal.TelegramChatId,
|
||||
messageThreadId: proposal.ThreadId,
|
||||
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
|
||||
Reference in New Issue
Block a user