Compare commits

...

4 Commits

Author SHA1 Message Date
Toutsu c874f7b797 fix: combine session image and text into single Telegram message
Deploy Telegram Bot / build-and-push (push) Successful in 4m2s
Deploy Telegram Bot / deploy (push) Successful in 10s
When creating a session with an image, send it as a single SendPhoto
with the schedule text as caption (+ reply markup), instead of two
separate messages. Falls back to two messages if caption exceeds
Telegram's 1024-char limit.

Also adds BatchMessageEditor helper that transparently handles
EditMessageText vs EditMessageCaption depending on whether the batch
message is a text or photo message. Updated all handlers and web
service to use this helper.

Version bump to 1.9.7.
2026-05-04 10:33:06 +03:00
Toutsu aefed5abd4 feat: improve telegram session posts
Deploy Telegram Bot / build-and-push (push) Successful in 4m28s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-05-04 09:52:07 +03:00
Toutsu 25c22b2ff5 fix: stabilize session table layout
Deploy Telegram Bot / build-and-push (push) Successful in 4m6s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-02 15:40:24 +03:00
Toutsu cb40c2438d docs: clarify mini app dashboard for GMs
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 21:01:30 +03:00
27 changed files with 540 additions and 161 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.9.3 VERSION: 1.9.7
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.9.3</Version> <Version>1.9.7</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+12 -8
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.9.3`. **Текущая версия:** `v1.9.6`.
--- ---
@@ -12,11 +12,12 @@
### 🤖 Telegram Бот ### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **🖼 Обложки расписаний**: К batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch. - **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram. - **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант. - **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
@@ -130,10 +131,13 @@ docker compose up -d
Время: 22.05.2024 19:00 Время: 22.05.2024 19:00
Мест: 4 Мест: 4
Ссылка: https://discord.gg/invite-link Ссылка: https://discord.gg/invite-link
Картинка: https://example.com/adventure-cover.jpg
``` ```
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Строка `Картинка:` тоже необязательна. Вместо ссылки можно прикрепить фото к сообщению `/newsession` и поместить команду в подпись к фото; бот возьмёт прикреплённое изображение и отправит его перед расписанием.
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях: Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
```text ```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. На странице группы 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 ```text
25.04.2026 19:30 25.04.2026 19:30
@@ -179,15 +183,15 @@ Owner или co-GM нажимает кнопку `⏰ Перенести` у н
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам. Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Telegram Mini App Dashboard ### Telegram Mini App Dashboard
Owner и co-GM могут открыть мобильный dashboard прямо из Telegram через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Страница `/miniapp` получает `Telegram.WebApp.initData`, отправляет его на серверный endpoint `/auth/telegram-webapp`, проходит HMAC-проверку токеном бота и выдаёт обычную cookie-сессию dashboard. Owner и co-GM могут открыть мобильный dashboard прямо из Telegram: через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Дополнительный пароль вводить не нужно: GM-Relay сам проверит вход через Telegram.
После входа Mini App использует те же страницы, что и Web Dashboard: список групп, карточки сессий, редактирование игры, повышение игрока из листа ожидания, применение шаблонов и bulk-операции batch. Доступ к чужим группам не появляется: все данные по-прежнему фильтруются через `AuthorizedSessionService` по роли owner/co-GM. Внутри открывается та же панель управления, только удобная для телефона. Можно смотреть свои группы, редактировать игры, управлять листом ожидания, запускать шаблоны и выполнять массовые действия с пачками игр. Чужие группы не появятся: dashboard показывает только те группы, где вы owner или co-GM.
Если `Telegram.WebApp.initData` недоступен или серверная проверка Mini App не прошла, `/miniapp` показывает диагностичное состояние и fallback-кнопку входа. Fallback больше не зависит от внешнего redirect: Telegram Login Widget вызывает callback на странице, отправляет payload на `/auth/telegram-login`, получает cookie в текущем WebView и сразу возвращает пользователя в dashboard. Если автоматический вход не сработал, Mini App покажет понятное сообщение и кнопку входа через Telegram. Нажмите её, подтвердите вход, и dashboard откроется в том же окне Telegram.
### Другие команды ### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе. - `/listsessions` — Показать список всех актуальных игр в этой группе. Для owner/co-GM команда также показывает кнопки отмены, переноса, повышения из листа ожидания и удаления.
- `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени. - `⏰` в панели `/listsessions` — Запустить голосование по 2-3 вариантам нового времени.
- `/deletesession` — Удалить сессию. - `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми. - `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату. - `/help` — Справка по формату.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.3 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.7
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -30,7 +30,7 @@ services:
- gmrelay - gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.3 image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.7
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -16,7 +16,7 @@ public sealed record CancelSessionCommand(
int MessageId); int MessageId);
// DTOs for AOT compilation // 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( public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -34,6 +34,7 @@ public sealed class CancelSessionHandler(
""" """
SELECT s.title AS Title, SELECT s.title AS Title,
s.batch_id AS BatchId, s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode, s.notification_mode AS NotificationMode,
EXISTS ( EXISTS (
SELECT 1 SELECT 1
@@ -105,13 +106,13 @@ public sealed class CancelSessionHandler(
try try
{ {
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId, chatId: command.ChatId,
messageId: command.MessageId, messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup,
replyMarkup: renderResult.Markup, ct);
cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
@@ -16,7 +16,7 @@ public sealed class CreateSessionHandler(
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) 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) foreach (var timeInput in parseResult.PastTimeInputs)
{ {
@@ -54,13 +54,14 @@ public sealed class CreateSessionHandler(
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, 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); cancellationToken: cancellationToken);
return; return;
} }
var title = parseResult.Title!; var title = parseResult.Title!;
var link = parseResult.Link!; var link = parseResult.Link!;
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id; var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}"); var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username; var gmUsername = message.From.Username;
@@ -184,13 +185,63 @@ public sealed class CreateSessionHandler(
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>()); var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await botClient.SendMessage( Message batchMessage;
chatId: chatId,
messageThreadId: messageThreadId, if (imageReference is not null && renderResult.Text.Length <= 1024)
text: renderResult.Text, {
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, // Картинка + расписание умещаются в одном Telegram-фото с подписью
replyMarkup: renderResult.Markup, try
cancellationToken: cancellationToken); {
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
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);
}
}
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
await connection.ExecuteAsync( await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
@@ -215,4 +266,20 @@ public sealed class CreateSessionHandler(
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken); 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();
}
} }
@@ -138,13 +138,13 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId, chatId: command.ChatId,
messageId: command.MessageId, messageId: command.MessageId,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: ct); ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания." ? "Основной состав заполнен. Вы добавлены в лист ожидания."
@@ -184,13 +184,13 @@ public sealed class LeaveSessionHandler(
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId, chatId: command.ChatId,
messageId: command.MessageId, messageId: command.MessageId,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: ct); ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания." ? "Вы удалены из листа ожидания."
@@ -5,6 +5,7 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult( internal sealed record NewSessionParseResult(
string? Title, string? Title,
string? Link, string? Link,
string? ImageUrl,
int? MaxPlayers, int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes, IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs, IReadOnlyList<string> PastTimeInputs,
@@ -27,6 +28,12 @@ internal static class NewSessionCommandParser
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:"; private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; 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 = private static readonly string[] SeatLimitPrefixes =
[ [
"\u041c\u0435\u0441\u0442:", "\u041c\u0435\u0441\u0442:",
@@ -49,6 +56,7 @@ internal static class NewSessionCommandParser
{ {
string? title = null; string? title = null;
string? link = null; string? link = null;
string? imageUrl = null;
int? maxPlayers = null; int? maxPlayers = null;
int? recurringCount = null; int? recurringCount = null;
var recurringIntervalDays = 7; var recurringIntervalDays = 7;
@@ -72,6 +80,14 @@ internal static class NewSessionCommandParser
continue; 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 => var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null) if (seatLimitPrefix is not null)
@@ -157,6 +173,7 @@ internal static class NewSessionCommandParser
return new NewSessionParseResult( return new NewSessionParseResult(
title, title,
link, link,
imageUrl,
maxPlayers, maxPlayers,
scheduledTimes, scheduledTimes,
pastTimeInputs, pastTimeInputs,
@@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand(
long ChatId, long ChatId,
int MessageId); 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); internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler( public sealed class PromoteWaitlistedPlayerHandler(
@@ -33,6 +33,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
""" """
SELECT s.title AS Title, SELECT s.title AS Title,
s.batch_id AS BatchId, s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
EXISTS ( EXISTS (
SELECT 1 SELECT 1
@@ -163,13 +164,13 @@ public sealed class PromoteWaitlistedPlayerHandler(
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId, chatId: command.ChatId,
messageId: command.MessageId, messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: ct); ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
} }
@@ -117,30 +117,16 @@ public sealed class DeleteSessionHandler(
return; return;
} }
var text = "📅 <b>Ближайшие игры:</b>\n\n"; var renderResult = SessionListMessageRenderer.Render(sessionsList);
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 += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {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;
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
command.ChatId, command.ChatId,
command.MessageId, command.MessageId,
text, renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard, replyMarkup: renderResult.Markup,
cancellationToken: ct); cancellationToken: ct);
} }
catch (Exception ex) catch (Exception ex)
@@ -3,11 +3,60 @@ using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions; 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 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<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\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 += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {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<InlineKeyboardButton[]>();
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( public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient botClient) ITelegramBotClient botClient)
@@ -53,27 +102,13 @@ public sealed class ListSessionsHandler(
return; return;
} }
var text = "📅 <b>Ближайшие игры:</b>\n\n"; var renderResult = SessionListMessageRenderer.Render(sessionsList);
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 += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {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;
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, chatId: message.Chat.Id,
text: text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard, replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
} }
@@ -378,13 +378,13 @@ public sealed class HandleRescheduleTimeInputHandler(
var renderResult = SessionBatchRenderer.Render( var renderResult = SessionBatchRenderer.Render(
proposal.Title, batchSessions, batchParticipants); proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId, chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value, messageId: proposal.BatchMessageId.Value,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: ct); ct);
} }
else else
{ {
@@ -306,13 +306,13 @@ public sealed class RescheduleVotingDeadlineService(
{ {
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId, chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value, messageId: proposal.BatchMessageId.Value,
text: renderResult.Text, text: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: ct); ct);
} }
else else
{ {
@@ -42,17 +42,26 @@ public sealed class UpdateRouter(
await HandleCallbackQueryAsync(query, ct); await HandleCallbackQueryAsync(query, ct);
break; break;
case { Message: { Text: { } text } message } when text.StartsWith('/'): case { Message: { } message }:
await HandleCommandAsync(message, text, ct); var commandText = GetCommandText(message);
break; 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; break;
} }
} }
internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct) private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{ {
if (query.Data is not { } data || query.Message is not { } message) if (query.Data is not { } data || query.Message is not { } message)
@@ -216,14 +225,15 @@ public sealed class UpdateRouter(
Время: 15.05.2026 19:30 Время: 15.05.2026 19:30
Мест: 4 Мест: 4
Ссылка: https://link Ссылка: https://link
Картинка: https://cover
Для регулярного расписания можно указать одну дату: Для регулярного расписания можно указать одну дату:
Игр: 4 Игр: 4
Интервал: 7 Интервал: 7
/listsessions список предстоящих сессий /listsessions список предстоящих сессий
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
/help эта справка /help эта справка
""", """,
cancellationToken: ct); cancellationToken: ct);
@@ -0,0 +1,51 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
/// <summary>
/// Handles editing batch messages that may be either text or photo messages.
/// When the batch was created with SendPhoto (image + caption), we need
/// EditMessageCaption instead of EditMessageText.
/// </summary>
public static class BatchMessageEditor
{
/// <summary>
/// Edits a batch message, automatically detecting whether it is a text or photo message.
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
/// </summary>
public static async Task EditBatchMessageAsync(
ITelegramBotClient bot,
long chatId,
int messageId,
string text,
InlineKeyboardMarkup? replyMarkup,
CancellationToken ct = default)
{
try
{
await bot.EditMessageText(
chatId: chatId,
messageId: messageId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
{
// The batch message is a photo — use EditMessageCaption instead.
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
await bot.EditMessageCaption(
chatId: chatId,
messageId: messageId,
caption: caption,
parseMode: ParseMode.Html,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
}
}
@@ -63,15 +63,6 @@ public static class SessionBatchRenderer
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}") 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());
} }
} }
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v1.9.3</div> <div class="nav-version">v1.9.6</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -214,7 +214,7 @@
</div> </div>
@* Desktop table *@ @* Desktop table *@
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;"> <div class="glass-card session-table-desktop session-table-desktop-card animate-slide-up">
<table class="gm-table"> <table class="gm-table">
<thead> <thead>
<tr> <tr>
@@ -237,19 +237,18 @@
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span> <span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td> </td>
<td> <td>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer" <a href="@session.JoinLink" target="_blank" rel="noopener noreferrer" class="session-join-link">
style="max-width: 150px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
Подключиться ↗ Подключиться ↗
</a> </a>
</td> </td>
<td> <td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> <div class="session-table-actions">
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;"> <a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
✏️ Изменить ✏️ Изменить
</a> </a>
@if (CanPromote(session)) @if (CanPromote(session))
{ {
<button type="button" class="btn-gm btn-gm-success" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)"> <button type="button" class="btn-gm btn-gm-success" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания") @(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button> </button>
} }
+2 -2
View File
@@ -1048,11 +1048,11 @@ public sealed class SessionService(
var renderResult = SessionBatchRenderer.Render(title, sessions, participants); var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
await bot.EditMessageText( await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: chatId, chatId: chatId,
messageId: messageId, messageId: messageId,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup); replyMarkup: renderResult.Markup);
} }
catch (Exception ex) catch (Exception ex)
+101 -51
View File
@@ -1,5 +1,5 @@
/* ============================================ /* ============================================
GM-Relay Design System v1.9.3 GM-Relay Design System v1.9.6
Dark RPG Dashboard Theme Dark RPG Dashboard Theme
============================================ */ ============================================ */
@@ -422,6 +422,46 @@ select option {
border-bottom: none; border-bottom: none;
} }
.session-table-desktop {
overflow-x: auto;
overflow-y: hidden;
}
.session-table-desktop-card {
padding: 0;
}
.session-table-desktop .gm-table {
min-width: 760px;
}
.session-table-desktop .gm-table th:last-child,
.session-table-desktop .gm-table td:last-child {
width: 8.5rem;
min-width: 8.5rem;
}
.session-join-link {
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-table-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.session-table-actions .btn-gm {
font-size: 0.8125rem;
padding: 0.375rem 0.75rem;
white-space: nowrap;
}
/* === Alerts === */ /* === Alerts === */
.gm-alert { .gm-alert {
padding: 0.875rem 1.125rem; padding: 0.875rem 1.125rem;
@@ -918,7 +958,64 @@ body.telegram-mini-app .content {
display: none; display: none;
} }
@media (max-width: 768px) { body.telegram-mini-app .session-table-desktop {
display: none;
}
body.telegram-mini-app .session-card-mobile {
display: block;
}
.session-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 0.75rem;
transition: all var(--transition-smooth);
}
.session-card:hover {
border-color: var(--border-glow);
}
.session-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.session-card-title {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary);
}
.session-card-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.session-card-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.session-card-actions {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
display: flex;
gap: 0.5rem;
}
@media (max-width: 1024px) {
.session-table-desktop { .session-table-desktop {
display: none; display: none;
} }
@@ -926,56 +1023,9 @@ body.telegram-mini-app .content {
.session-card-mobile { .session-card-mobile {
display: block; display: block;
} }
}
.session-card { @media (max-width: 768px) {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 0.75rem;
transition: all var(--transition-smooth);
}
.session-card:hover {
border-color: var(--border-glow);
}
.session-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.session-card-title {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary);
}
.session-card-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.session-card-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.session-card-actions {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
display: flex;
gap: 0.5rem;
}
.card-grid { .card-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1,4 +1,5 @@
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
@@ -33,6 +34,43 @@ public sealed class NewSessionCommandParserTests
Assert.Empty(result.InvalidTimeInputs); 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] [Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided() public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{ {
@@ -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);
}
}
@@ -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);
}
}
@@ -42,17 +42,15 @@ public sealed class SessionBatchRendererTests
Assert.Contains("Лист ожидания (1)", text); Assert.Contains("Лист ожидания (1)", text);
Assert.Contains("Charlie", text); Assert.Contains("Charlie", text);
Assert.Contains("Bob", text); Assert.Contains("Bob", text);
Assert.Equal(4, result.Markup.InlineKeyboard.Count()); Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Collection( Assert.Collection(
buttons.Select(button => button.CallbackData), buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"leave_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($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData));
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData), Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("cancel_session:", StringComparison.Ordinal) == true);
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData), Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("reschedule_session:", StringComparison.Ordinal) == true);
callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData)); Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("promote_waitlist:", StringComparison.Ordinal) == true);
} }
} }
@@ -25,6 +25,56 @@ public sealed class WebStylesTests
Assert.Contains(".telegram-mini-app .nav-toggle", appCss, StringComparison.Ordinal); Assert.Contains(".telegram-mini-app .nav-toggle", appCss, StringComparison.Ordinal);
} }
[Fact]
public async Task AppCss_ShouldKeepDesktopSessionActionsReadableWhenTableOverflows()
{
var appCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
Assert.Matches(
@"\.session-table-desktop\s*\{[\s\S]*overflow-x:\s*auto;",
appCss);
Assert.Matches(
@"\.session-table-desktop\s+\.gm-table\s*\{[\s\S]*min-width:\s*760px;",
appCss);
Assert.Matches(
@"\.session-table-actions\s+\.btn-gm\s*\{[\s\S]*white-space:\s*nowrap;",
appCss);
}
[Fact]
public async Task AppCss_ShouldUseCardSessionLayoutInsideTelegramMiniApp()
{
var appCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
Assert.Matches(
@"body\.telegram-mini-app\s+\.session-table-desktop\s*\{[\s\S]*display:\s*none;",
appCss);
Assert.Matches(
@"body\.telegram-mini-app\s+\.session-card-mobile\s*\{[\s\S]*display:\s*block;",
appCss);
}
[Fact]
public async Task AppCss_ShouldUseCardSessionLayoutWhenDesktopSidebarLeavesNarrowContent()
{
var appCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
Assert.Matches(
@"@media\s*\(max-width:\s*1024px\)\s*\{[\s\S]*\.session-table-desktop\s*\{[\s\S]*display:\s*none;[\s\S]*\.session-card-mobile\s*\{[\s\S]*display:\s*block;",
appCss);
}
[Fact]
public async Task GroupDetailsPage_ShouldUseSessionTableLayoutClasses()
{
var groupDetailsPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/GroupDetails.razor"));
Assert.Contains("session-table-desktop-card", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("session-table-actions", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("session-join-link", groupDetailsPage, StringComparison.Ordinal);
Assert.DoesNotContain("overflow: hidden", groupDetailsPage, StringComparison.Ordinal);
}
private static string FindRepositoryFile(string relativePath) private static string FindRepositoryFile(string relativePath)
{ {
var directory = new DirectoryInfo(AppContext.BaseDirectory); var directory = new DirectoryInfo(AppContext.BaseDirectory);