Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c874f7b797 | |||
| aefed5abd4 | |||
| 25c22b2ff5 | |||
| cb40c2438d |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.9.3
|
VERSION: 1.9.7
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+38
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+58
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user