diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e01e3c3..ac36678 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.4.1 + VERSION: 1.5.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index c60d6d3..07c997d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.4.1 + 1.5.0 net10.0 preview enable diff --git a/README.md b/README.md index cc704cc..0f3644e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.4.1`. +**Текущая версия:** `v1.5.0`. --- @@ -16,6 +16,7 @@ - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания. +- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. - **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**. @@ -23,6 +24,7 @@ - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. - **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц. +- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. - **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков. - **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC. @@ -126,11 +128,14 @@ docker compose up -d ### Bulk-операции в Web Dashboard На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может: - обновить общий `title` и `link` сразу у всех сессий batch; +- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления; - перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях; - клонировать batch на следующую неделю или следующий календарный месяц. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков. +Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам. + ### Другие команды - `/listsessions` — Показать список всех актуальных игр в этой группе. - `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. diff --git a/compose.yaml b/compose.yaml index 5e807a8..7ca3304 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.4.1 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.5.0 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.4.1 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.5.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs index 72b6948..a47e98e 100644 --- a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs @@ -1,4 +1,5 @@ using Dapper; +using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using Npgsql; using Telegram.Bot; @@ -13,7 +14,8 @@ internal sealed record SessionInfo( string Title, DateTime ScheduledAt, Guid GroupId, - long TelegramChatId); + long TelegramChatId, + string NotificationMode); internal sealed record ParticipantInfo( long TelegramId, @@ -29,6 +31,7 @@ internal sealed record ParticipantInfo( public sealed class SendConfirmationHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, + DirectSessionNotificationSender directSender, ILogger logger) { public async Task HandleAsync(Guid sessionId, CancellationToken ct) @@ -39,7 +42,8 @@ public sealed class SendConfirmationHandler( var session = await connection.QuerySingleOrDefaultAsync( """ SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND s.status = @Planned @@ -115,6 +119,26 @@ public sealed class SendConfirmationHandler( MessageId = message.MessageId }); + var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + var directText = $""" + 🎲 Подтвердите участие в игре + + 📌 {System.Net.WebUtility.HtmlEncode(session.Title)} + 📅 {session.ScheduledAt.FormatMoscow()} (МСК) + + Ответьте кнопкой в групповом сообщении расписания. + """; + + await directSender.SendAsync( + participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)), + directText, + "confirmation", + sessionId, + ct); + } + logger.LogInformation( "Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}", sessionId, session.Title, message.MessageId); diff --git a/src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs b/src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs new file mode 100644 index 0000000..a547790 --- /dev/null +++ b/src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs @@ -0,0 +1,41 @@ +using Telegram.Bot; +using Telegram.Bot.Types.Enums; + +namespace GmRelay.Bot.Features.Notifications; + +public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName); + +public sealed class DirectSessionNotificationSender( + ITelegramBotClient bot, + ILogger logger) +{ + public async Task SendAsync( + IEnumerable recipients, + string htmlText, + string notificationKind, + Guid sessionId, + CancellationToken ct) + { + foreach (var recipient in recipients) + { + try + { + await bot.SendMessage( + chatId: recipient.TelegramId, + text: htmlText, + parseMode: ParseMode.Html, + cancellationToken: ct); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to send {NotificationKind} DM for session {SessionId} to player {TelegramId} ({DisplayName})", + notificationKind, + sessionId, + recipient.TelegramId, + recipient.DisplayName); + } + } + } +} diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs index 1db244c..2e5eb46 100644 --- a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs +++ b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -1,4 +1,5 @@ using Dapper; +using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using Npgsql; using Telegram.Bot; @@ -12,7 +13,8 @@ internal sealed record JoinLinkSession( string Title, string JoinLink, DateTime ScheduledAt, - long TelegramChatId); + long TelegramChatId, + string NotificationMode); internal sealed record ConfirmedPlayer( long TelegramId, @@ -28,6 +30,7 @@ internal sealed record ConfirmedPlayer( public sealed class SendJoinLinkHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, + DirectSessionNotificationSender directSender, ILogger logger) { public async Task HandleAsync(Guid sessionId, CancellationToken ct) @@ -38,7 +41,8 @@ public sealed class SendJoinLinkHandler( var session = await connection.QuerySingleOrDefaultAsync( """ SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId @@ -102,6 +106,24 @@ public sealed class SendJoinLinkHandler( """, new { SessionId = sessionId, MessageId = message.MessageId }); + var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + var directText = $""" + 🎮 Игра начинается через 5 минут + + 📌 {System.Net.WebUtility.HtmlEncode(session.Title)} + 🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)} + """; + + await directSender.SendAsync( + players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)), + directText, + "join-link", + sessionId, + ct); + } + logger.LogInformation( "Join link sent for session {SessionId} ({Title}), message_id={MessageId}", sessionId, session.Title, message.MessageId); diff --git a/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs new file mode 100644 index 0000000..6255d19 --- /dev/null +++ b/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs @@ -0,0 +1,97 @@ +using Dapper; +using GmRelay.Bot.Features.Notifications; +using GmRelay.Shared.Domain; +using Npgsql; + +namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder; + +internal sealed record OneHourReminderSession( + Guid Id, + string Title, + string JoinLink, + DateTime ScheduledAt, + string NotificationMode); + +public sealed class SendOneHourReminderHandler( + NpgsqlDataSource dataSource, + DirectSessionNotificationSender directSender, + ILogger logger) +{ + public async Task HandleAsync(Guid sessionId, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var session = await connection.QuerySingleOrDefaultAsync( + """ + SELECT id, + title, + join_link AS JoinLink, + scheduled_at AS ScheduledAt, + notification_mode AS NotificationMode + FROM sessions + WHERE id = @SessionId + AND status IN (@Confirmed, @ConfirmationSent) + AND one_hour_reminder_processed_at IS NULL + """, + new + { + SessionId = sessionId, + Confirmed = SessionStatus.Confirmed, + ConfirmationSent = SessionStatus.ConfirmationSent + }); + + if (session is null) + { + logger.LogWarning("Session {SessionId} not eligible for one-hour reminder", sessionId); + return; + } + + var recipients = (await connection.QueryAsync( + """ + SELECT p.telegram_id AS TelegramId, + p.display_name AS DisplayName + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + AND sp.rsvp_status != @Declined + """, + new + { + SessionId = sessionId, + Active = ParticipantRegistrationStatus.Active, + Declined = RsvpStatus.Declined + })).ToList(); + + var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); + if (mode.ShouldSendDirectMessages() && recipients.Count > 0) + { + var text = $""" + ⏰ Игра начнётся примерно через 1 час + + 📌 {System.Net.WebUtility.HtmlEncode(session.Title)} + 📅 {session.ScheduledAt.FormatMoscow()} (МСК) + 🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)} + """; + + await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct); + } + + await connection.ExecuteAsync( + """ + UPDATE sessions + SET one_hour_reminder_processed_at = now(), + updated_at = now() + WHERE id = @SessionId + AND one_hour_reminder_processed_at IS NULL + """, + new { SessionId = sessionId }); + + logger.LogInformation( + "One-hour reminder processed for session {SessionId} ({Title}) with mode {NotificationMode}", + sessionId, + session.Title, + session.NotificationMode); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 937a7fc..77bbed8 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -1,4 +1,5 @@ using Dapper; +using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; @@ -15,11 +16,12 @@ public sealed record CancelSessionCommand( int MessageId); // DTOs for AOT compilation -internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId); +internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId, string NotificationMode); public sealed class CancelSessionHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, + DirectSessionNotificationSender directSender, ILogger logger) { public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct) @@ -29,7 +31,7 @@ public sealed class CancelSessionHandler( // 1. Проверяем, что запрос делает ГМ данной сессии var session = await connection.QuerySingleOrDefaultAsync( - @"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId + @"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId, s.notification_mode as NotificationMode FROM sessions s JOIN game_groups g ON s.group_id = g.id WHERE s.id = @SessionId", @@ -73,6 +75,19 @@ public sealed class CancelSessionHandler( ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC", new { BatchId = session.BatchId }, transaction); + var directRecipients = (await connection.QueryAsync( + """ + SELECT p.telegram_id AS TelegramId, + p.display_name AS DisplayName + FROM session_participants sp + JOIN players p ON sp.player_id = p.id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + """, + new { command.SessionId, Active = ParticipantRegistrationStatus.Active }, + transaction)).ToList(); + await transaction.CommitAsync(ct); // 4. Перерисовываем сообщение @@ -92,6 +107,17 @@ public sealed class CancelSessionHandler( // Опционально: написать отдельное сообщение в чат await bot.SendMessage(command.ChatId, $"❌ Внимание! Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); + + var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + await directSender.SendAsync( + directRecipients, + $"❌ Сессия отменена\n\n📌 {System.Net.WebUtility.HtmlEncode(session.Title)}", + "session-cancelled", + command.SessionId, + ct); + } } catch (Exception ex) { diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index b8c2c25..6ea9c55 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -1,4 +1,5 @@ using Dapper; +using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; @@ -12,9 +13,13 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession; internal sealed record AwaitingProposalDto( Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt, - Guid BatchId, int? BatchMessageId, long TelegramChatId); + Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode); -internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername); +internal sealed record VoteParticipantDto( + Guid PlayerId, + string DisplayName, + string? TelegramUsername, + long TelegramId = 0); // ── Handler ────────────────────────────────────────────────────────── @@ -26,6 +31,7 @@ internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, str public sealed class HandleRescheduleTimeInputHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, + DirectSessionNotificationSender directSender, ILogger logger) { /// @@ -48,7 +54,8 @@ 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 + g.telegram_chat_id AS TelegramChatId, + s.notification_mode AS NotificationMode FROM reschedule_proposals rp JOIN sessions s ON s.id = rp.session_id JOIN game_groups g ON g.id = s.group_id @@ -86,7 +93,10 @@ public sealed class HandleRescheduleTimeInputHandler( // 3. Load participants (non-GM) signed up for this session var participants = (await connection.QueryAsync( """ - SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + p.telegram_id AS TelegramId FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId @@ -135,6 +145,29 @@ public sealed class HandleRescheduleTimeInputHandler( replyMarkup: keyboard, cancellationToken: ct); + var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + var directText = $""" + 🔄 Голосование за перенос сессии + + 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} + 📅 Текущее время: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) + 📅 Новое время: {newTime.FormatMoscow()} (МСК) + + Проголосуйте кнопкой в групповом сообщении. + """; + + await directSender.SendAsync( + participants.Select(p => new DirectNotificationRecipient( + p.TelegramId, + p.DisplayName)), + directText, + "reschedule-vote", + proposal.SessionId, + ct); + } + // Store vote message ID await connection.ExecuteAsync( "UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id", @@ -156,7 +189,11 @@ public sealed class HandleRescheduleTimeInputHandler( await connection.ExecuteAsync( """ - UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now() + UPDATE sessions + SET scheduled_at = @NewTime, + status = @Status, + one_hour_reminder_processed_at = NULL, + updated_at = now() WHERE id = @SessionId """, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned }, diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index aaccb6e..968d37c 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -1,4 +1,5 @@ using Dapper; +using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; @@ -25,11 +26,13 @@ internal sealed record VoteProposalDto( string SessionStatus, long TelegramChatId, int? ConfirmationMessageId, - int? BatchMessageId); + int? BatchMessageId, + string NotificationMode); public sealed class HandleRescheduleVoteHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, + DirectSessionNotificationSender directSender, ILogger logger) { public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) @@ -48,7 +51,8 @@ public sealed class HandleRescheduleVoteHandler( s.status AS SessionStatus, s.confirmation_message_id AS ConfirmationMessageId, s.batch_message_id AS BatchMessageId, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.notification_mode AS NotificationMode FROM reschedule_proposals rp JOIN sessions s ON s.id = rp.session_id JOIN game_groups g ON g.id = s.group_id @@ -105,7 +109,8 @@ public sealed class HandleRescheduleVoteHandler( """ SELECT p.id AS PlayerId, p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername + p.telegram_username AS TelegramUsername, + p.telegram_id AS TelegramId FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId @@ -130,6 +135,8 @@ public sealed class HandleRescheduleVoteHandler( if (decision.Outcome == RescheduleVoteOutcome.Rejected) { + var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction); + await connection.ExecuteAsync( "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id", new { Id = command.ProposalId }, @@ -156,12 +163,25 @@ public sealed class HandleRescheduleVoteHandler( } await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct); + + var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + await directSender.SendAsync( + directRecipients, + $"❌ Перенос сессии отклонён\n\n📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)}\n📅 Время остаётся прежним: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК)", + "reschedule-rejected", + proposal.SessionId, + ct); + } + logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId); return; } if (decision.ShouldRescheduleSession) { + var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction); var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); await connection.ExecuteAsync( @@ -171,6 +191,7 @@ public sealed class HandleRescheduleVoteHandler( status = @Status, confirmation_message_id = NULL, link_message_id = NULL, + one_hour_reminder_processed_at = NULL, updated_at = now() WHERE id = @SessionId """, @@ -214,6 +235,17 @@ public sealed class HandleRescheduleVoteHandler( await TryUpdateBatchMessage(proposal, ct); + var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + await directSender.SendAsync( + directRecipients, + $"✅ Сессия перенесена\n\n📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)}\n📅 Новое время: {proposal.ProposedAt.FormatMoscow()} (МСК)", + "reschedule-approved", + proposal.SessionId, + ct); + } + logger.LogInformation( "Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})", proposal.SessionId, @@ -257,6 +289,25 @@ public sealed class HandleRescheduleVoteHandler( await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct); } + private static async Task> LoadDirectRecipients( + Npgsql.NpgsqlConnection connection, + Guid sessionId, + Npgsql.NpgsqlTransaction transaction) + { + return (await connection.QueryAsync( + """ + SELECT p.telegram_id AS TelegramId, + p.display_name AS DisplayName + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, + transaction)).ToList(); + } + private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) { try diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs index aa53866..e3c2a67 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs @@ -2,6 +2,7 @@ using Dapper; using GmRelay.Shared.Domain; using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Reminders.SendJoinLink; +using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using Npgsql; namespace GmRelay.Bot.Infrastructure.Scheduling; @@ -17,11 +18,13 @@ namespace GmRelay.Bot.Infrastructure.Scheduling; public sealed class SessionSchedulerService( NpgsqlDataSource dataSource, SendConfirmationHandler confirmationHandler, + SendOneHourReminderHandler oneHourReminderHandler, SendJoinLinkHandler joinLinkHandler, ILogger logger) : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24); + private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1); private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5); protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -36,6 +39,7 @@ public sealed class SessionSchedulerService( try { await ProcessConfirmationTriggers(stoppingToken); + await ProcessOneHourReminderTriggers(stoppingToken); await ProcessJoinLinkTriggers(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -52,6 +56,42 @@ public sealed class SessionSchedulerService( logger.LogInformation("Session scheduler stopped"); } + /// + /// T-1h trigger: process direct reminders according to the session notification mode. + /// + private async Task ProcessOneHourReminderTriggers(CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var sessionIds = await connection.QueryAsync( + """ + SELECT id + FROM sessions + WHERE status IN (@Confirmed, @ConfirmationSent) + AND scheduled_at - @LeadTime <= now() + AND one_hour_reminder_processed_at IS NULL + """, + new + { + Confirmed = SessionStatus.Confirmed, + ConfirmationSent = SessionStatus.ConfirmationSent, + LeadTime = OneHourReminderLeadTime + }); + + foreach (var sessionId in sessionIds) + { + try + { + await oneHourReminderHandler.HandleAsync(sessionId, ct); + logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId); + } + } + } + /// /// T-24h trigger: find sessions that need confirmation requests sent. /// Condition: status='Planned' AND scheduled_at minus 24h is in the past. diff --git a/src/GmRelay.Bot/Migrations/V007__add_session_notification_mode.sql b/src/GmRelay.Bot/Migrations/V007__add_session_notification_mode.sql new file mode 100644 index 0000000..5f8dce4 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V007__add_session_notification_mode.sql @@ -0,0 +1,8 @@ +ALTER TABLE sessions + ADD COLUMN notification_mode VARCHAR(50) NOT NULL DEFAULT 'GroupAndDirect' + CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')), + ADD COLUMN one_hour_reminder_processed_at TIMESTAMPTZ; + +CREATE INDEX ix_sessions_one_hour_reminders ON sessions (scheduled_at) + WHERE status IN ('Confirmed', 'ConfirmationSent') + AND one_hour_reminder_processed_at IS NULL; diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index e6125c7..71920e7 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -1,6 +1,8 @@ using GmRelay.Bot.Features.Confirmation.HandleRsvp; using GmRelay.Bot.Features.Confirmation.SendConfirmation; +using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Reminders.SendJoinLink; +using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Infrastructure.Database; @@ -50,8 +52,10 @@ builder.Services.AddSingleton(); // ── Feature handlers (explicit registration — AOT safe) ────────────── builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Domain/SessionNotificationMode.cs b/src/GmRelay.Shared/Domain/SessionNotificationMode.cs new file mode 100644 index 0000000..45df52f --- /dev/null +++ b/src/GmRelay.Shared/Domain/SessionNotificationMode.cs @@ -0,0 +1,33 @@ +namespace GmRelay.Shared.Domain; + +public enum SessionNotificationMode +{ + GroupAndDirect, + GroupOnly +} + +public static class SessionNotificationModeExtensions +{ + public const string GroupAndDirectValue = nameof(SessionNotificationMode.GroupAndDirect); + public const string GroupOnlyValue = nameof(SessionNotificationMode.GroupOnly); + + public static bool ShouldSendDirectMessages(this SessionNotificationMode mode) => + mode == SessionNotificationMode.GroupAndDirect; + + public static string ToDatabaseValue(this SessionNotificationMode mode) => + mode switch + { + SessionNotificationMode.GroupAndDirect => GroupAndDirectValue, + SessionNotificationMode.GroupOnly => GroupOnlyValue, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown notification mode.") + }; + + public static SessionNotificationMode FromDatabaseValue(string? value) => + value switch + { + null or "" => SessionNotificationMode.GroupAndDirect, + GroupAndDirectValue => SessionNotificationMode.GroupAndDirect, + GroupOnlyValue => SessionNotificationMode.GroupOnly, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown notification mode.") + }; +} diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 15d06ef..5708ed3 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -77,9 +77,16 @@ +
+ + +
@@ -277,7 +284,11 @@ try { await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink); - successMessage = "Общие title/link обновлены для всей пачки."; + await SessionService.UpdateBatchNotificationModeForGmAsync( + batch.BatchId, + telegramId, + SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode)); + successMessage = "Настройки batch обновлены."; await LoadSessions(); } catch (SessionAccessDeniedException) @@ -373,6 +384,7 @@ BatchId = group.Key, Title = firstSession.Title, JoinLink = firstSession.JoinLink, + NotificationMode = firstSession.NotificationMode, FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(), LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(), IntervalDays = InferIntervalDays(orderedSessions), @@ -447,6 +459,7 @@ public Guid BatchId { get; init; } public string Title { get; set; } = ""; public string JoinLink { get; set; } = ""; + public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue; public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now; public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now; public int IntervalDays { get; set; } = 7; diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index e2d1ba6..43d3a89 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -1,3 +1,5 @@ +using GmRelay.Shared.Domain; + namespace GmRelay.Web.Services; public sealed class AuthorizedSessionService(ISessionStore sessionStore) @@ -70,6 +72,17 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim()); } + public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode) + { + var batch = await GetBatchForGmAsync(batchId, gmId); + if (batch is null) + { + throw new SessionAccessDeniedException(batchId, gmId); + } + + await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode); + } + public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays) { if (intervalDays <= 0) diff --git a/src/GmRelay.Web/Services/BatchSchedulePlanner.cs b/src/GmRelay.Web/Services/BatchSchedulePlanner.cs index a35e87b..04652ef 100644 --- a/src/GmRelay.Web/Services/BatchSchedulePlanner.cs +++ b/src/GmRelay.Web/Services/BatchSchedulePlanner.cs @@ -1,3 +1,5 @@ +using GmRelay.Shared.Domain; + namespace GmRelay.Web.Services; public enum BatchCloneInterval @@ -13,7 +15,8 @@ public sealed record WebSessionBatch( string JoinLink, DateTime FirstScheduledAt, DateTime LastScheduledAt, - int SessionCount); + int SessionCount, + string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); public static class BatchSchedulePlanner { diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index ebc4a50..df7b95f 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -1,3 +1,5 @@ +using GmRelay.Shared.Domain; + namespace GmRelay.Web.Services; public interface ISessionStore @@ -10,6 +12,7 @@ public interface ISessionStore Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers); Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId); Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink); + Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode); Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays); Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval); } diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 2975cd4..7cc018a 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -19,9 +19,11 @@ public sealed record WebSession( long TelegramChatId, int? MaxPlayers, int ActivePlayerCount, - int WaitlistedPlayerCount); + int WaitlistedPlayerCount, + string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); +internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName); internal sealed record WebBatchInfo( Guid BatchId, Guid GroupId, @@ -29,7 +31,8 @@ internal sealed record WebBatchInfo( string JoinLink, long TelegramChatId, int? BatchMessageId, - int? ThreadId); + int? ThreadId, + string NotificationMode); internal sealed record WebBatchSessionRow( Guid Id, @@ -41,7 +44,8 @@ internal sealed record WebBatchSessionRow( int? MaxPlayers, int? BatchMessageId, long TelegramChatId, - int? ThreadId); + int? ThreadId, + string NotificationMode); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -73,7 +77,8 @@ public sealed class SessionService( g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, - COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( @@ -109,7 +114,8 @@ public sealed class SessionService( g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, - COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( @@ -146,7 +152,8 @@ public sealed class SessionService( (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, MIN(s.scheduled_at) AS FirstScheduledAt, MAX(s.scheduled_at) AS LastScheduledAt, - COUNT(*)::int AS SessionCount + COUNT(*)::int AS SessionCount, + (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode FROM sessions s WHERE s.batch_id = @BatchId GROUP BY s.batch_id, s.group_id @@ -165,7 +172,8 @@ public sealed class SessionService( g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, 0 AS ActivePlayerCount, - 0 AS WaitlistedPlayerCount + 0 AS WaitlistedPlayerCount, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @Id AND s.group_id = @GroupId", @@ -183,6 +191,10 @@ public sealed class SessionService( scheduled_at = @ScheduledAt, join_link = @JoinLink, max_players = @MaxPlayers, + one_hour_reminder_processed_at = CASE + WHEN scheduled_at <> @ScheduledAt THEN NULL + ELSE one_hour_reminder_processed_at + END, updated_at = now() WHERE id = @Id AND group_id = @GroupId", new @@ -217,6 +229,13 @@ public sealed class SessionService( await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId); + await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId); + } + if (oldSession.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title); @@ -234,7 +253,8 @@ public sealed class SessionService( g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, 0 AS ActivePlayerCount, - 0 AS WaitlistedPlayerCount + 0 AS WaitlistedPlayerCount, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND s.group_id = @GroupId @@ -363,6 +383,41 @@ public sealed class SessionService( } } + public async Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); + if (batch is null) + { + throw new SessionAccessDeniedException(batchId, 0); + } + + var updatedRows = await conn.ExecuteAsync( + """ + UPDATE sessions + SET notification_mode = @NotificationMode, + updated_at = now() + WHERE batch_id = @BatchId + AND group_id = @GroupId + """, + new + { + BatchId = batchId, + GroupId = groupId, + NotificationMode = notificationMode.ToDatabaseValue() + }, + transaction); + + if (updatedRows == 0) + { + throw new SessionAccessDeniedException(batchId, 0); + } + + await transaction.CommitAsync(); + } + public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) { await using var conn = await dataSource.OpenConnectionAsync(); @@ -379,7 +434,8 @@ public sealed class SessionService( s.max_players AS MaxPlayers, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, - s.thread_id AS ThreadId + s.thread_id AS ThreadId, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId @@ -406,6 +462,7 @@ public sealed class SessionService( """ UPDATE sessions SET scheduled_at = @ScheduledAt, + one_hour_reminder_processed_at = NULL, updated_at = now() WHERE id = @SessionId """, @@ -431,6 +488,13 @@ public sealed class SessionService( $"↔️ Шаг: {intervalDays} дн."; await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + + var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId); + await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId); + } } public async Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) @@ -449,7 +513,8 @@ public sealed class SessionService( s.max_players AS MaxPlayers, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, - s.thread_id AS ThreadId + s.thread_id AS ThreadId, + s.notification_mode AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId @@ -477,8 +542,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) - VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers) + 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) RETURNING id """, new @@ -490,7 +555,8 @@ public sealed class SessionService( ScheduledAt = scheduledAt, Status = SessionStatus.Planned, ThreadId = threadId, - sourceSession.MaxPlayers + sourceSession.MaxPlayers, + sourceSession.NotificationMode }, transaction); @@ -518,7 +584,71 @@ public sealed class SessionService( batchJoinLink, renderedSessions.Min(session => session.ScheduledAt), renderedSessions.Max(session => session.ScheduledAt), - renderedSessions.Count); + renderedSessions.Count, + sourceSessions[0].NotificationMode); + } + + private async Task> LoadSessionDirectRecipientsAsync( + Npgsql.NpgsqlConnection conn, + Guid sessionId) + { + return (await conn.QueryAsync( + """ + SELECT p.telegram_id AS TelegramId, + p.display_name AS DisplayName + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); + } + + private async Task> LoadBatchDirectRecipientsAsync( + Npgsql.NpgsqlConnection conn, + Guid batchId) + { + return (await conn.QueryAsync( + """ + SELECT DISTINCT p.telegram_id AS TelegramId, + p.display_name AS DisplayName + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + JOIN sessions s ON s.id = sp.session_id + WHERE s.batch_id = @BatchId + AND sp.is_gm = false + AND sp.registration_status = @Active + """, + new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList(); + } + + private async Task SendDirectNotificationsAsync( + IEnumerable recipients, + string htmlText, + string notificationKind, + Guid entityId) + { + foreach (var recipient in recipients) + { + try + { + await bot.SendMessage( + chatId: recipient.TelegramId, + text: htmlText, + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})", + notificationKind, + entityId, + recipient.TelegramId, + recipient.DisplayName); + } + } } private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title) @@ -572,7 +702,8 @@ public sealed class SessionService( (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, g.telegram_chat_id AS TelegramChatId, (array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId, - (array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId + (array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId, + (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 1e4ac21..cc6e3fb 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.4.1 + GM-Relay Design System v1.5.0 Dark RPG Dashboard Theme ============================================ */ diff --git a/tests/GmRelay.Bot.Tests/Domain/SessionNotificationModeTests.cs b/tests/GmRelay.Bot.Tests/Domain/SessionNotificationModeTests.cs new file mode 100644 index 0000000..7d27c42 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Domain/SessionNotificationModeTests.cs @@ -0,0 +1,16 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Tests.Domain; + +public sealed class SessionNotificationModeTests +{ + [Theory] + [InlineData(SessionNotificationMode.GroupAndDirect, true)] + [InlineData(SessionNotificationMode.GroupOnly, false)] + public void ShouldSendDirectMessages_ReturnsExpectedDecision( + SessionNotificationMode mode, + bool expected) + { + Assert.Equal(expected, mode.ShouldSendDirectMessages()); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 0769290..806fdff 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -1,4 +1,5 @@ using GmRelay.Web.Services; +using GmRelay.Shared.Domain; namespace GmRelay.Bot.Tests.Web; @@ -210,6 +211,53 @@ public sealed class AuthorizedSessionServiceTests Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink); } + [Fact] + public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm() + { + var batchId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", 2002L) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ]); + var service = new AuthorizedSessionService(store); + + var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly); + + await Assert.ThrowsAsync(action); + Assert.False(store.UpdateBatchNotificationModeCalled); + } + + [Fact] + public async Task UpdateBatchNotificationModeForGmAsync_UpdatesOwnedBatch() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) + ]); + var service = new AuthorizedSessionService(store); + + await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly); + + Assert.True(store.UpdateBatchNotificationModeCalled); + Assert.Equal(batchId, store.LastUpdatedNotificationBatchId); + Assert.Equal(groupId, store.LastUpdatedNotificationGroupId); + Assert.Equal(SessionNotificationMode.GroupOnly, store.LastUpdatedNotificationMode); + } + [Fact] public async Task RescheduleBatchForGmAsync_RejectsNonPositiveInterval() { @@ -295,6 +343,7 @@ public sealed class AuthorizedSessionServiceTests public bool UpdateCalled { get; private set; } public bool PromoteCalled { get; private set; } public bool UpdateBatchDetailsCalled { get; private set; } + public bool UpdateBatchNotificationModeCalled { get; private set; } public bool RescheduleBatchCalled { get; private set; } public bool CloneBatchCalled { get; private set; } public Guid? LastUpdatedSessionId { get; private set; } @@ -309,6 +358,9 @@ public sealed class AuthorizedSessionServiceTests public Guid? LastUpdatedBatchGroupId { get; private set; } public string? LastUpdatedBatchTitle { get; private set; } public string? LastUpdatedBatchJoinLink { get; private set; } + public Guid? LastUpdatedNotificationBatchId { get; private set; } + public Guid? LastUpdatedNotificationGroupId { get; private set; } + public SessionNotificationMode? LastUpdatedNotificationMode { get; private set; } public Guid? LastRescheduledBatchId { get; private set; } public Guid? LastRescheduledBatchGroupId { get; private set; } public DateTime? LastRescheduledFirstScheduledAt { get; private set; } @@ -388,6 +440,15 @@ public sealed class AuthorizedSessionServiceTests return Task.CompletedTask; } + public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) + { + UpdateBatchNotificationModeCalled = true; + LastUpdatedNotificationBatchId = batchId; + LastUpdatedNotificationGroupId = groupId; + LastUpdatedNotificationMode = notificationMode; + return Task.CompletedTask; + } + public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) { RescheduleBatchCalled = true;