diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ff9e9e4..c31e0bc 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.13.0 + VERSION: 1.14.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index efc3923..385424b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.13.0 + 1.14.0 net10.0 preview enable diff --git a/README.md b/README.md index 955faa4..f9ada98 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.10.2`. +**Текущая версия:** `v1.14.0`. --- @@ -16,7 +16,7 @@ - **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. -- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. +- **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает. - **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков. - **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном. - **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. diff --git a/compose.yaml b/compose.yaml index a21fd8c..3f17b13 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.13.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.14.0 restart: always depends_on: db: @@ -30,7 +30,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.13.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.14.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs index 7f231c5..987fd91 100644 --- a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs @@ -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); } diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs index 1a4f404..59a664d 100644 --- a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs @@ -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); diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs index 261b041..593ce60 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs +++ b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -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); diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 9d3e428..16af575 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -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( """ @@ -117,9 +118,14 @@ public sealed class CancelSessionHandler( ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct); - + // Опционально: написать отдельное сообщение в чат - await bot.SendMessage(command.ChatId, $"❌ Внимание! Сессия \"{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: $"❌ Внимание! Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + cancellationToken: ct); var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); if (mode.ShouldSendDirectMessages()) diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index a55e2b5..b49ed5a 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -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( """ - 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 }, diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs index edddbf5..46ed206 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -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( - "SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId", - new { BatchId = session.BatchId }, transaction); + var remainingInTopic = session.ThreadId.HasValue + ? await connection.ExecuteScalarAsync( + """ + 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; } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index e255761..e1569e6 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -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Используйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00", 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📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs index c6238ff..fd813fc 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs @@ -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}» и дедлайн голосования. diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index 3c856cd..012f4a1 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -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); diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs new file mode 100644 index 0000000..b72528a --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs @@ -0,0 +1,40 @@ +namespace GmRelay.Bot.Infrastructure.Telegram; + +public sealed record TelegramTopicDestination( + int? MessageThreadId, + bool ShouldCreateForumTopic, + bool TopicCreatedByBot); + +public static class TelegramTopicRouting +{ + public const string MissingForumTopicRightsMessage = + "Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите команду."; + + public static TelegramTopicDestination ResolveNewScheduleDestination( + bool chatIsForum, + int? incomingMessageThreadId) + { + if (!chatIsForum) + { + return new TelegramTopicDestination(null, ShouldCreateForumTopic: false, TopicCreatedByBot: false); + } + + if (incomingMessageThreadId.HasValue) + { + return new TelegramTopicDestination( + incomingMessageThreadId, + ShouldCreateForumTopic: false, + TopicCreatedByBot: false); + } + + return new TelegramTopicDestination(null, ShouldCreateForumTopic: true, TopicCreatedByBot: true); + } + + public static bool ShouldDeleteForumTopic(bool topicCreatedByBot, int remainingSessionsInTopic) => + topicCreatedByBot && remainingSessionsInTopic == 0; + + public static bool IsMissingForumTopicRightsError(string apiError) => + apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) || + apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) || + apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 28a7e14..0538a55 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -80,7 +80,7 @@ public sealed class UpdateRouter( CallbackQueryId: query.Id, ChatId: message.Chat.Id, MessageId: message.MessageId); - + await joinSessionHandler.HandleAsync(command, ct); return; } @@ -105,6 +105,7 @@ public sealed class UpdateRouter( TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, + MessageThreadId: message.MessageThreadId, MessageId: message.MessageId); await cancelSessionHandler.HandleAsync(command, ct); @@ -144,6 +145,7 @@ public sealed class UpdateRouter( TelegramUserId: query.From.Id, CallbackQueryId: query.Id, ChatId: message.Chat.Id, + MessageThreadId: message.MessageThreadId, MessageId: message.MessageId); await initiateRescheduleHandler.HandleAsync(command, ct); diff --git a/src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql b/src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql new file mode 100644 index 0000000..1acd26e --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql @@ -0,0 +1,6 @@ +ALTER TABLE sessions + ADD COLUMN topic_created_by_bot BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE sessions +SET topic_created_by_bot = TRUE +WHERE thread_id IS NOT NULL; diff --git a/src/GmRelay.Shared/Domain/MoscowTime.cs b/src/GmRelay.Shared/Domain/MoscowTime.cs index 823f6bf..01c630b 100644 --- a/src/GmRelay.Shared/Domain/MoscowTime.cs +++ b/src/GmRelay.Shared/Domain/MoscowTime.cs @@ -21,7 +21,7 @@ public static class MoscowTime public static bool TryParseMoscow(string text, out DateTimeOffset utcTime) { - if (DateTime.TryParseExact(text, new[] { "dd.MM.yyyy HH:mm", "dd.MM.yyyy H:mm", "d.MM.yyyy HH:mm" }, + if (DateTime.TryParseExact(text, new[] { "dd.MM.yyyy HH:mm", "dd.MM.yyyy H:mm", "d.MM.yyyy HH:mm" }, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var localDt)) { utcTime = new DateTimeOffset(localDt, MoscowOffset).ToUniversalTime(); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 0afd0c0..b32e0d7 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + @@ -79,4 +79,4 @@ private void ToggleMenu() => isOpen = !isOpen; private void CloseMenu() => isOpen = false; -} \ No newline at end of file +} diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 1524c67..ba98850 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -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" + $"👥 Мест: {(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}"; - 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, - $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + chatId: session.TelegramChatId, + messageThreadId: session.ThreadId, + text: $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{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, - $"🚪 {System.Net.WebUtility.HtmlEncode(participant.DisplayName)} удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + chatId: session.TelegramChatId, + messageThreadId: session.ThreadId, + text: $"🚪 {System.Net.WebUtility.HtmlEncode(participant.DisplayName)} удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); if (promoted is not null) { await bot.SendMessage( - session.TelegramChatId, - $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + chatId: session.TelegramChatId, + messageThreadId: session.ThreadId, + text: $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{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( $"🗓 Новое начало: {firstScheduledAt.FormatMoscow()} (МСК)\n" + $"↔️ Шаг: {intervalDays} дн."; - 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( """ - 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 }, diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs new file mode 100644 index 0000000..0e33432 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -0,0 +1,76 @@ +namespace GmRelay.Bot.Tests.Infrastructure.Telegram; + +public sealed class TelegramTopicIntegrationSmokeTests +{ + [Fact] + public async Task BotAndWebCode_ShouldPersistAndUseTopicOwnership() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql"); + var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); + var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs"); + + Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal); + Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal); + Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal); + Assert.Contains("topic_created_by_bot", createHandler, StringComparison.Ordinal); + Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal); + Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal); + Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal); + Assert.Contains("remainingInTopic", deleteHandler, StringComparison.Ordinal); + } + + [Fact] + public async Task GroupNotifications_ShouldSendToStoredForumTopic() + { + var confirmationHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs"); + var joinLinkHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs"); + var rsvpHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs"); + var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs"); + var initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs"); + var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs"); + var rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs"); + + Assert.Contains("int? ThreadId", confirmationHandler, StringComparison.Ordinal); + Assert.Contains("s.thread_id AS ThreadId", confirmationHandler, StringComparison.Ordinal); + Assert.Contains("messageThreadId: session.ThreadId", confirmationHandler, StringComparison.Ordinal); + + Assert.Contains("int? ThreadId", joinLinkHandler, StringComparison.Ordinal); + Assert.Contains("s.thread_id AS ThreadId", joinLinkHandler, StringComparison.Ordinal); + Assert.Contains("messageThreadId: session.ThreadId", joinLinkHandler, StringComparison.Ordinal); + + Assert.Contains("int? ThreadId", rsvpHandler, StringComparison.Ordinal); + Assert.Contains("s.thread_id AS ThreadId", rsvpHandler, StringComparison.Ordinal); + Assert.Contains("messageThreadId: session.ThreadId", rsvpHandler, StringComparison.Ordinal); + + Assert.Contains("int? MessageThreadId", cancelHandler, StringComparison.Ordinal); + Assert.Contains("messageThreadId: command.MessageThreadId", cancelHandler, StringComparison.Ordinal); + + Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal); + Assert.Contains("messageThreadId: command.MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal); + + Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal); + Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal); + Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleInputHandler, StringComparison.Ordinal); + + Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); + Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); + Assert.Contains("messageThreadId: proposal.ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicRoutingTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicRoutingTests.cs new file mode 100644 index 0000000..7949a70 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicRoutingTests.cs @@ -0,0 +1,80 @@ +using GmRelay.Bot.Infrastructure.Telegram; + +namespace GmRelay.Bot.Tests.Infrastructure.Telegram; + +public sealed class TelegramTopicRoutingTests +{ + [Fact] + public void ResolveNewScheduleDestination_UsesIncomingTopic_WhenForumCommandWasSentInsideTopic() + { + var destination = TelegramTopicRouting.ResolveNewScheduleDestination( + chatIsForum: true, + incomingMessageThreadId: 42); + + Assert.Equal(42, destination.MessageThreadId); + Assert.False(destination.ShouldCreateForumTopic); + Assert.False(destination.TopicCreatedByBot); + } + + [Fact] + public void ResolveNewScheduleDestination_CreatesBotOwnedTopic_WhenForumCommandWasSentInRoot() + { + var destination = TelegramTopicRouting.ResolveNewScheduleDestination( + chatIsForum: true, + incomingMessageThreadId: null); + + Assert.Null(destination.MessageThreadId); + Assert.True(destination.ShouldCreateForumTopic); + Assert.True(destination.TopicCreatedByBot); + } + + [Fact] + public void ResolveNewScheduleDestination_UsesPlainChat_WhenChatIsNotForum() + { + var destination = TelegramTopicRouting.ResolveNewScheduleDestination( + chatIsForum: false, + incomingMessageThreadId: 42); + + Assert.Null(destination.MessageThreadId); + Assert.False(destination.ShouldCreateForumTopic); + Assert.False(destination.TopicCreatedByBot); + } + + [Fact] + public void MissingForumTopicRightsMessage_NamesRequiredAdminRight() + { + Assert.Contains("admin", TelegramTopicRouting.MissingForumTopicRightsMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Manage Topics", TelegramTopicRouting.MissingForumTopicRightsMessage, StringComparison.Ordinal); + } + + [Theory] + [InlineData("Bad Request: not enough rights to create forum topic")] + [InlineData("Bad Request: CHAT_ADMIN_REQUIRED")] + [InlineData("Forbidden: bot is not an administrator")] + public void IsMissingForumTopicRightsError_MatchesAdminPermissionErrors(string apiError) + { + Assert.True(TelegramTopicRouting.IsMissingForumTopicRightsError(apiError)); + } + + [Fact] + public void IsMissingForumTopicRightsError_IgnoresUnrelatedApiErrors() + { + Assert.False(TelegramTopicRouting.IsMissingForumTopicRightsError("Bad Request: topic name is invalid")); + } + + [Theory] + [InlineData(true, 0, true)] + [InlineData(true, 1, false)] + [InlineData(false, 0, false)] + public void ShouldDeleteForumTopic_DeletesOnlyBotOwnedEmptyTopic( + bool topicCreatedByBot, + int remainingSessionsInTopic, + bool expected) + { + var shouldDelete = TelegramTopicRouting.ShouldDeleteForumTopic( + topicCreatedByBot, + remainingSessionsInTopic); + + Assert.Equal(expected, shouldDelete); + } +}