diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4b6f329..3d76fc9 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.9.5 + VERSION: 1.9.6 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 064759d..1223b97 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.9.5 + 1.9.6 net10.0 preview enable diff --git a/README.md b/README.md index d150dec..70be6db 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.9.5`. +**Текущая версия:** `v1.9.6`. --- @@ -12,11 +12,12 @@ ### 🤖 Telegram Бот - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). +- **🖼 Обложки расписаний**: К batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи. - **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. -- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram. +- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков. - **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант. - **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. @@ -130,10 +131,13 @@ docker compose up -d Время: 22.05.2024 19:00 Мест: 4 Ссылка: https://discord.gg/invite-link +Картинка: https://example.com/adventure-cover.jpg ``` Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. +Строка `Картинка:` тоже необязательна. Вместо ссылки можно прикрепить фото к сообщению `/newsession` и поместить команду в подпись к фото; бот возьмёт прикреплённое изображение и отправит его перед расписанием. + Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях: ```text @@ -154,7 +158,7 @@ docker compose up -d На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM. ### Перенос сессии голосованием -Owner или co-GM нажимает кнопку `⏰ Перенести` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном: +Owner или co-GM вызывает `/listsessions`, нажимает кнопку `⏰` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном: ```text 25.04.2026 19:30 @@ -186,8 +190,8 @@ Owner и co-GM могут открыть мобильный dashboard прямо Если автоматический вход не сработал, Mini App покажет понятное сообщение и кнопку входа через Telegram. Нажмите её, подтвердите вход, и dashboard откроется в том же окне Telegram. ### Другие команды -- `/listsessions` — Показать список всех актуальных игр в этой группе. -- `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени. +- `/listsessions` — Показать список всех актуальных игр в этой группе. Для owner/co-GM команда также показывает кнопки отмены, переноса, повышения из листа ожидания и удаления. +- `⏰` в панели `/listsessions` — Запустить голосование по 2-3 вариантам нового времени. - `/deletesession` — Удалить сессию. - `/exportcalendar` — Получить `.ics` файл с играми. - `/help` — Справка по формату. diff --git a/compose.yaml b/compose.yaml index ccf543b..d815769 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.5 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.6 restart: always depends_on: db: @@ -30,7 +30,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.5 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.6 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs index 3926010..55464c3 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -16,7 +16,7 @@ public sealed record CancelSessionCommand( int MessageId); // DTOs for AOT compilation -internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode); +internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, string NotificationMode); public sealed class CancelSessionHandler( NpgsqlDataSource dataSource, @@ -34,6 +34,7 @@ public sealed class CancelSessionHandler( """ SELECT s.title AS Title, s.batch_id AS BatchId, + s.batch_message_id AS BatchMessageId, s.notification_mode AS NotificationMode, EXISTS ( SELECT 1 @@ -107,7 +108,7 @@ public sealed class CancelSessionHandler( { await bot.EditMessageText( chatId: command.ChatId, - messageId: command.MessageId, + messageId: session.BatchMessageId ?? command.MessageId, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup, diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index a446e81..610a9a7 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -16,7 +16,7 @@ public sealed class CreateSessionHandler( { public async Task HandleAsync(Message message, CancellationToken cancellationToken) { - var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow); + var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow); foreach (var timeInput in parseResult.PastTimeInputs) { @@ -54,13 +54,14 @@ public sealed class CreateSessionHandler( { await botClient.SendMessage( chatId: message.Chat.Id, - text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7", + text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7", cancellationToken: cancellationToken); return; } var title = parseResult.Title!; var link = parseResult.Link!; + var imageReference = GetBatchImageReference(message, parseResult.ImageUrl); var gmId = message.From!.Id; var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}"); var gmUsername = message.From.Username; @@ -184,6 +185,24 @@ public sealed class CreateSessionHandler( var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty()); + if (imageReference is not null) + { + try + { + await botClient.SendPhoto( + chatId: chatId, + messageThreadId: messageThreadId, + photo: InputFile.FromString(imageReference), + caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId); + } + } + var batchMessage = await botClient.SendMessage( chatId: chatId, messageThreadId: messageThreadId, @@ -215,4 +234,20 @@ public sealed class CreateSessionHandler( await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken); } } + + internal static string? GetBatchImageReference(Message message, string? parsedImageUrl) + { + var attachedPhotoFileId = message.Photo? + .OrderByDescending(photo => photo.FileSize ?? 0) + .ThenByDescending(photo => photo.Width * photo.Height) + .FirstOrDefault() + ?.FileId; + + if (!string.IsNullOrWhiteSpace(attachedPhotoFileId)) + { + return attachedPhotoFileId; + } + + return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim(); + } } diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs index e503942..32a7f94 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs @@ -5,6 +5,7 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession; internal sealed record NewSessionParseResult( string? Title, string? Link, + string? ImageUrl, int? MaxPlayers, IReadOnlyList ScheduledTimes, IReadOnlyList PastTimeInputs, @@ -27,6 +28,12 @@ internal static class NewSessionCommandParser private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:"; private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; + private static readonly string[] ImagePrefixes = + [ + "\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:", + "\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:", + "\u041e\u0431\u043b\u043e\u0436\u043a\u0430:" + ]; private static readonly string[] SeatLimitPrefixes = [ "\u041c\u0435\u0441\u0442:", @@ -49,6 +56,7 @@ internal static class NewSessionCommandParser { string? title = null; string? link = null; + string? imageUrl = null; int? maxPlayers = null; int? recurringCount = null; var recurringIntervalDays = 7; @@ -72,6 +80,14 @@ internal static class NewSessionCommandParser continue; } + var imagePrefix = ImagePrefixes.FirstOrDefault(prefix => + line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (imagePrefix is not null) + { + imageUrl = line[imagePrefix.Length..].Trim(); + continue; + } + var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix => line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); if (seatLimitPrefix is not null) @@ -157,6 +173,7 @@ internal static class NewSessionCommandParser return new NewSessionParseResult( title, link, + imageUrl, maxPlayers, scheduledTimes, pastTimeInputs, diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs index 00632f1..3266d35 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs @@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand( long ChatId, int MessageId); -internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers); +internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, int? MaxPlayers); internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName); public sealed class PromoteWaitlistedPlayerHandler( @@ -33,6 +33,7 @@ public sealed class PromoteWaitlistedPlayerHandler( """ SELECT s.title AS Title, s.batch_id AS BatchId, + s.batch_message_id AS BatchMessageId, s.max_players AS MaxPlayers, EXISTS ( SELECT 1 @@ -165,7 +166,7 @@ public sealed class PromoteWaitlistedPlayerHandler( await bot.EditMessageText( chatId: command.ChatId, - messageId: command.MessageId, + messageId: session.BatchMessageId ?? command.MessageId, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup, diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs index 0e150fd..edddbf5 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -117,30 +117,16 @@ public sealed class DeleteSessionHandler( return; } - var text = "📅 Ближайшие игры:\n\n"; - foreach (var s in sessionsList) - { - var seats = s.MaxPlayers.HasValue - ? $"{s.PlayerCount}/{s.MaxPlayers.Value}" - : s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); - var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty; - text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; - } - - var canManage = sessionsList.First().CanManage; - var keyboard = canManage - ? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup( - sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") })) - : null; + var renderResult = SessionListMessageRenderer.Render(sessionsList); try { await bot.EditMessageText( command.ChatId, command.MessageId, - text, + renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - replyMarkup: keyboard, + replyMarkup: renderResult.Markup, cancellationToken: ct); } catch (Exception ex) diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs index 94a4544..8fc4175 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs @@ -3,11 +3,60 @@ using GmRelay.Shared.Domain; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.ListSessions; internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage); +internal static class SessionListMessageRenderer +{ + public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList sessions) + { + var text = "📅 Ближайшие игры:\n\n"; + foreach (var session in sessions) + { + var seats = session.MaxPlayers.HasValue + ? $"{session.PlayerCount}/{session.MaxPlayers.Value}" + : session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); + var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty; + text += $"🔹 {session.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n"; + } + + var canManage = sessions.Count > 0 && sessions.First().CanManage; + if (!canManage) + { + return (text, null); + } + + var buttons = new List(); + foreach (var session in sessions) + { + var dateTitle = session.ScheduledAt.FormatMoscowShort(); + buttons.Add( + [ + InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"), + InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}") + ]); + + if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount)) + { + buttons.Add( + [ + InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}") + ]); + } + + buttons.Add( + [ + InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}") + ]); + } + + return (text, new InlineKeyboardMarkup(buttons)); + } +} + public sealed class ListSessionsHandler( NpgsqlDataSource dataSource, ITelegramBotClient botClient) @@ -53,27 +102,13 @@ public sealed class ListSessionsHandler( return; } - var text = "📅 Ближайшие игры:\n\n"; - foreach (var s in sessionsList) - { - var seats = s.MaxPlayers.HasValue - ? $"{s.PlayerCount}/{s.MaxPlayers.Value}" - : s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); - var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty; - text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; - } - - var canManage = sessionsList.First().CanManage; - var keyboard = canManage - ? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup( - sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") })) - : null; + var renderResult = SessionListMessageRenderer.Render(sessionsList); await botClient.SendMessage( chatId: message.Chat.Id, - text: text, + text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - replyMarkup: keyboard, + replyMarkup: renderResult.Markup, cancellationToken: cancellationToken); } } diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 93621d6..28a7e14 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -42,17 +42,26 @@ public sealed class UpdateRouter( await HandleCallbackQueryAsync(query, ct); break; - case { Message: { Text: { } text } message } when text.StartsWith('/'): - await HandleCommandAsync(message, text, ct); - break; + case { Message: { } message }: + var commandText = GetCommandText(message); + if (commandText.StartsWith("/", StringComparison.Ordinal)) + { + await HandleCommandAsync(message, commandText, ct); + break; + } + + if (message.Text is not null) + { + await rescheduleTimeInputHandler.TryHandleAsync(message, ct); + } - // Non-command text messages — check for reschedule time input - case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'): - await rescheduleTimeInputHandler.TryHandleAsync(message, ct); break; } } + internal static string GetCommandText(Message message) + => (message.Text ?? message.Caption ?? string.Empty).TrimStart(); + private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct) { if (query.Data is not { } data || query.Message is not { } message) @@ -216,14 +225,15 @@ public sealed class UpdateRouter( Время: 15.05.2026 19:30 Мест: 4 Ссылка: https://link + Картинка: https://cover Для регулярного расписания можно указать одну дату: Игр: 4 Интервал: 7 /listsessions — список предстоящих сессий + Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания. Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». - Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования. /help — эта справка """, cancellationToken: ct); diff --git a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs index 70db7c2..6564a4f 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs @@ -63,15 +63,6 @@ public static class SessionBatchRenderer InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}") }); - buttons.Add(new[] - { - InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"), - InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}") - } - .Concat(SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, sessionPlayers.Count, waitlistedPlayers.Count) - ? [InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle} (ГМ)", $"promote_waitlist:{session.SessionId}")] - : []) - .ToArray()); } } diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 0841b6e..0322828 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 4eed4be..0ba6c59 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.9.5 + GM-Relay Design System v1.9.6 Dark RPG Dashboard Theme ============================================ */ diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs index 7133e1f..c4cc9f8 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs @@ -1,4 +1,5 @@ using GmRelay.Bot.Features.Sessions.CreateSession; +using Telegram.Bot.Types; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; @@ -33,6 +34,43 @@ public sealed class NewSessionCommandParserTests Assert.Empty(result.InvalidTimeInputs); } + [Fact] + public void Parse_ShouldExtractOptionalImageUrl() + { + var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); + var text = """ + /newsession + Название: Curse of Strahd + Время: 24.04.2026 19:30 + Ссылка: https://example.test/room + Картинка: https://example.test/strahd.jpg + """; + + var result = NewSessionCommandParser.Parse(text, nowUtc); + + Assert.True(result.IsValid); + Assert.Equal("https://example.test/strahd.jpg", result.ImageUrl); + } + + [Fact] + public void GetBatchImageReference_ShouldPreferAttachedPhotoOverParsedUrl() + { + var message = new Message + { + Photo = + [ + new PhotoSize { FileId = "small-photo", Width = 320, Height = 180, FileSize = 10 }, + new PhotoSize { FileId = "large-photo", Width = 1280, Height = 720, FileSize = 20 } + ] + }; + + var imageReference = CreateSessionHandler.GetBatchImageReference( + message, + "https://example.test/cover.jpg"); + + Assert.Equal("large-photo", imageReference); + } + [Fact] public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided() { diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/SessionListMessageRendererTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/SessionListMessageRendererTests.cs new file mode 100644 index 0000000..79ee6e6 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/SessionListMessageRendererTests.cs @@ -0,0 +1,58 @@ +using GmRelay.Bot.Features.Sessions.ListSessions; +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions; + +public sealed class SessionListMessageRendererTests +{ + [Fact] + public void Render_ShouldIncludeManagerActions_WhenUserCanManage() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] + { + new SessionListItemDto( + sessionId, + "Ravenloft", + new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc), + SessionStatus.Planned, + 4, + 3, + 1, + true) + }; + + var result = SessionListMessageRenderer.Render(sessions); + Assert.NotNull(result.Markup); + var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList(); + + Assert.Contains("Ravenloft", result.Text); + Assert.Collection( + buttons.Select(button => button.CallbackData), + callbackData => Assert.Equal($"cancel_session:{sessionId}", callbackData), + callbackData => Assert.Equal($"reschedule_session:{sessionId}", callbackData), + callbackData => Assert.Equal($"promote_waitlist:{sessionId}", callbackData), + callbackData => Assert.Equal($"delete_session:{sessionId}", callbackData)); + } + + [Fact] + public void Render_ShouldHideManagerActions_WhenUserCannotManage() + { + var sessions = new[] + { + new SessionListItemDto( + Guid.NewGuid(), + "Ravenloft", + new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc), + SessionStatus.Planned, + 4, + 3, + 1, + false) + }; + + var result = SessionListMessageRenderer.Render(sessions); + + Assert.Null(result.Markup); + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/UpdateRouterTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/UpdateRouterTests.cs new file mode 100644 index 0000000..9997248 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/UpdateRouterTests.cs @@ -0,0 +1,23 @@ +using GmRelay.Bot.Infrastructure.Telegram; +using Telegram.Bot.Types; + +namespace GmRelay.Bot.Tests.Infrastructure.Telegram; + +public sealed class UpdateRouterTests +{ + [Fact] + public void GetCommandText_ShouldUseCaption_WhenMessageHasNoText() + { + var message = new Message + { + Caption = """ + /newsession + Название: Curse of Strahd + """ + }; + + var commandText = UpdateRouter.GetCommandText(message); + + Assert.StartsWith("/newsession", commandText); + } +} diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs index 04bd7c1..918c27b 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs @@ -42,17 +42,15 @@ public sealed class SessionBatchRendererTests Assert.Contains("Лист ожидания (1)", text); Assert.Contains("Charlie", text); Assert.Contains("Bob", text); - Assert.Equal(4, result.Markup.InlineKeyboard.Count()); + Assert.Equal(2, result.Markup.InlineKeyboard.Count()); Assert.Collection( buttons.Select(button => button.CallbackData), callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData), - callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData), - callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData), - callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData), - callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData), - callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData), - callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData)); + callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData)); + Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("cancel_session:", StringComparison.Ordinal) == true); + Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("reschedule_session:", StringComparison.Ordinal) == true); + Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("promote_waitlist:", StringComparison.Ordinal) == true); } }