Compare commits

...

5 Commits

Author SHA1 Message Date
root 1bcd88db32 ci: bump deploy workflow version to 1.10.0 2026-05-06 09:14:29 +00:00
root 63e613c061 trigger: ci 2026-05-06 09:12:57 +00:00
Toutsu dbf59c544a docs(adr): добавить ADR 002 — platform-neutral batch rendering 2026-05-06 12:07:10 +03:00
root 14b9bf15f2 refactor(#22): разделить SessionBatchRenderer на neutral view и Telegram renderer
- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Обновлены все вызовы (7 handler-ов + Web SessionService + smoke tests)
- Новые тесты на builder и Telegram renderer
2026-05-06 08:28:25 +00:00
Toutsu 5dee2d87f5 test: cover Telegram landing promise smoke
Deploy Telegram Bot / build-and-push (push) Successful in 5m32s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-05-05 13:06:09 +03:00
32 changed files with 1243 additions and 160 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.9.8 VERSION: 1.10.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+250
View File
@@ -0,0 +1,250 @@
# Issue #19: Выровнять /newsession с batch-сценарием лендинга
> **Goal:** Сделать `/newsession` атомарным: либо весь batch создаётся целиком, либо ничего — с понятным сообщением об ошибках. Убрать создание частичных сессий при наличии любых ошибок ввода.
**Architecture:** Изменить `NewSessionParseResult.IsValid` так, чтобы он учитывал ЛЮБЫЕ parse-ошибки (некорректные даты, лимиты, повторы, прошедшие даты). Обновить `CreateSessionHandler`, чтобы при `!IsValid` отправлялось одно детальное сообщение с перечислением ошибок и help-шаблоном, и creation прерывался до транзакции БД.
**Tech Stack:** C#, .NET, Dapper, Telegram.Bot, xUnit
---
### Task 1: Добавить `HasErrors` и ужесточить `IsValid`
**Objective:** Запретить частичное создание: `IsValid == false`, если есть хоть одна ошибка парсинга.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs`
- Test: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs`
**Step 1: RED — написать failing test**
Добавить в конец `NewSessionCommandParserTests.cs`:
```csharp
[Fact]
public void Parse_ShouldBeInvalid_WhenAnyErrorsPresent()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 25.04.2026 19:30
Время: 31.04.2026 19:30
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.HasErrors);
Assert.False(result.IsValid);
}
```
**Step 2: Run test and verify RED**
```bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n
```
Expected: `HasErrors` property not found → compile error or FAIL.
**Step 3: GREEN — minimal implementation**
В `NewSessionCommandParser.cs`, в `NewSessionParseResult`, заменить `IsValid` на:
```csharp
public bool HasErrors =>
PastTimeInputs.Count > 0 ||
InvalidTimeInputs.Count > 0 ||
InvalidSeatLimitInputs.Count > 0 ||
InvalidRecurringInputs.Count > 0;
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
!HasErrors;
```
**Step 4: Run test and verify GREEN**
```bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~NewSessionCommandParserTests" -v n
```
Expected: 7 passed (включая новый).
**Step 5: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs
git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs
git commit -m "feat(#19): add HasErrors to prevent partial batch creation"
```
---
### Task 2: Обновить существующий тест на Past/Invalid times
**Objective:** Старый тест ожидал `IsValid == true` при partial invalid — теперь это баг, нужно обновить assertion.
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs:114`
**Step 1: Write failing expectation first**
Заменить в методе `Parse_ShouldCollectPastAndInvalidTimes` (примерно строка 114):
```csharp
// Было:
Assert.True(result.IsValid);
// Стало:
Assert.False(result.IsValid);
```
**Step 2: Run test and verify RED**
```bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n
```
Expected: FAIL — expected False, actual True.
**Step 3: Fix already done in Task 1**
Task 1 уже изменил `IsValid`. Перезапустить тест.
**Step 4: Run test and verify GREEN**
```bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "Parse_ShouldCollectPastAndInvalidTimes" -v n
```
Expected: PASS.
**Step 5: Commit**
```bash
git add tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs
git commit -m "test(#19): update assertion - partial invalid means invalid"
```
---
### Task 3: Обновить `CreateSessionHandler` для атомарной валидации и единого сообщения об ошибках
**Objective:** Убрать разбросанные warning-сообщения. При любых ошибках — одно сообщение с перечнем ошибок + help-шаблон. Не создавать сессии.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
**Step 1: RED — напишем интеграционный поведенческий тест (опционально, landing test уже покрывает happy path)**
Просто запустим landing promise smoke test и убедимся, что он ещё green (happy path не сломан).
```bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "TelegramLandingPromisesSmokeTests" -v n
```
Expected: PASS.
**Step 2: GREEN — minimal change in handler**
В `CreateSessionHandler.cs`, метод `HandleAsync`, заменить начало (строки 19–59) на:
```csharp
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
var errorMessages = new List<string>();
foreach (var timeInput in parseResult.PastTimeInputs)
errorMessages.Add($"⚠️ Дата {timeInput} находится в прошлом и будет пропущена.");
foreach (var timeInput in parseResult.InvalidTimeInputs)
errorMessages.Add($"⚠️ Некорректный формат времени '{timeInput}'. Пропущено.");
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
errorMessages.Add($"⚠️ Некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.");
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
errorMessages.Add($"⚠️ Некорректный повтор расписания '{recurringInput}'. Укажите число игр 1–52 и шаг 1–365 дней.");
if (!parseResult.IsValid)
{
var helpText = """
Не удалось распознать формат. Пожалуйста, используйте шаблон:
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для повтора можно указать одну дату и строки:
Игр: 4
Интервал: 7
""";
if (errorMessages.Count > 0)
{
helpText = string.Join('\n', errorMessages) + "\n\n" + helpText;
}
await botClient.SendMessage(
chatId: message.Chat.Id,
text: helpText,
cancellationToken: cancellationToken);
return;
}
```
**Step 3: Build and verify**
```bash
cd /repo && dotnet build src/GmRelay.Bot
```
Expected: Build succeeded.
**Step 4: Run all relevant tests**
```bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests --filter "FullyQualifiedName~CreateSession|FullyQualifiedName~Landing" -v n
```
Expected: All passed.
**Step 5: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "feat(#19): atomic validation with detailed error message in /newsession"
```
---
### Task 4: Проверить полный test suite на регрессии
**Objective:** Убедиться, что изменения не сломали существующие тесты.
**Files:**
- Test: все тесты проекта `GmRelay.Bot.Tests`
**Step 1: Run full test suite**
```bash
cd /repo && dotnet test tests/GmRelay.Bot.Tests -v n
```
Expected: All passed.
**Step 2: Commit (if any test baseline updated)**
Если всё зелёное — commit message:
```bash
git commit --allow-empty -m "test(#19): verify full suite green after atomic validation"
```
---
## Verification Checklist
- [ ] `NewSessionCommandParser.Parse` возвращает `IsValid = false` при любых ошибках (past/invalid times, seat limits, recurring).
- [ ] `HasErrors` true ↔ есть хотя бы одна ошибка.
- [ ] `CreateSessionHandler` не открывает БД-транзакцию, если `!IsValid`.
- [ ] Пользователь получает одно сообщение с перечислением ошибок + help.
- [ ] Landing promise smoke test проходит (happy path не сломан).
- [ ] Полный test suite зелёный.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.9.7</Version> <Version>1.10.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+13 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.9.8`. **Текущая версия:** `v1.9.9`.
--- ---
@@ -212,5 +212,17 @@ Owner и co-GM могут открыть мобильный dashboard прямо
--- ---
## 🧪 Тестирование
Основной набор проверок запускается командой:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"
```
Начиная с `v1.9.9`, тестовый набор включает функциональный smoke-сценарий обещаний лендинга для Telegram: batch-сессии на несколько дат, inline-кнопки записи/выхода, лимиты мест, waitlist, автоповышение игрока, голосование за перенос, direct-notification mode и перерисовку Telegram batch-поста после dashboard-изменений. Smoke работает через fake Telegram messenger и не требует внешнего Telegram API.
---
## 📜 Лицензия ## 📜 Лицензия
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется. Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.7 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.0
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.7 image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -0,0 +1,65 @@
# ADR 002: Platform-Neutral Batch Rendering
## Status
**Accepted** — implemented in v1.10.0 (PR #42).
## Context
`SessionBatchRenderer` жил в `GmRelay.Shared` и напрямую зависел от `Telegram.Bot` (`InlineKeyboardMarkup`, `ParseMode.Html`). Это создавало проблемы:
1. **Shared не был platform-neutral.** Любой платформенный проект (Discord, Slack, WebSocket) тащил Telegram-зависимость.
2. **Дублирование логики.** `GmRelay.Web` использовал тот же рендерер через прямую зависимость от `Shared`, но Web — это не Telegram-клиент.
3. **Невозможно написать unit-тесты без Telegram-объектов.** Smoke-тесты создавали InlineKeyboardMarkup даже для проверки чисто доменной логики.
## Decision
Разделить рендеринг на две стадии:
1. **View Builder (platform-neutral)** — собирает view model из доменных DTO.
2. **Platform Renderer (platform-specific)** — превращает view model в платформенное представление.
```
Domain DTOs
SessionBatchViewBuilder (Shared)
SessionBatchViewModel (platform-neutral)
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
└──► DiscordSessionBatchRenderer ──► (issue #26)
```
### Изменённые компоненты
| Компонент | Было | Стало |
|---|---|---|
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
| `DiscordSessionBatchRenderer` | — | `GmRelay.Shared.Rendering` (stub) |
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
## Consequences
### Positive
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
- Можно добавить `DiscordSessionBatchRenderer` без изменений в `Shared`.
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
### Negative
- **Временное дублирование.** `TelegramSessionBatchRenderer` и `BatchMessageEditor` скопированы в `Bot` и `Web`. Планируется вынести в `GmRelay.Shared.Telegram` при появлении третьего Telegram-потребителя.
- **Дополнительная стадия.** Теперь два вызова вместо одного: `Build` + `Render`. Этоtrade-off за чистоту абстракции.
## Related
- Issue #22 — этот рефакторинг.
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -102,7 +103,8 @@ public sealed class CancelSessionHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
try try
{ {
@@ -4,6 +4,7 @@ using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -183,7 +184,8 @@ public sealed class CreateSessionHandler(
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>()); var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
Message batchMessage; Message batchMessage;
@@ -4,6 +4,7 @@ using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -136,7 +137,8 @@ public sealed class JoinSessionHandler(
transactionCommitted = true; transactionCommitted = true;
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await BatchMessageEditor.EditBatchMessageAsync(
bot, bot,
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -182,7 +183,8 @@ public sealed class LeaveSessionHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
transactionCommitted = true; transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await BatchMessageEditor.EditBatchMessageAsync(
bot, bot,
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -162,7 +163,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
transactionCommitted = true; transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await BatchMessageEditor.EditBatchMessageAsync(
bot, bot,
@@ -6,6 +6,7 @@ using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -375,8 +376,8 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
var renderResult = SessionBatchRenderer.Render( var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
proposal.Title, batchSessions, batchParticipants); var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await BatchMessageEditor.EditBatchMessageAsync(
bot, bot,
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -304,7 +305,8 @@ public sealed class RescheduleVotingDeadlineService(
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await BatchMessageEditor.EditBatchMessageAsync(
bot, bot,
@@ -0,0 +1,51 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <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);
}
}
}
@@ -0,0 +1,57 @@
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public static class TelegramSessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
{
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in view.Sessions)
{
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var actionRow = session.AvailableActions
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
.ToArray();
if (actionRow.Length > 0)
buttons.Add(actionRow);
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
}
-4
View File
@@ -7,8 +7,4 @@
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,13 @@
namespace GmRelay.Shared.Rendering;
/// <summary>
/// Заглушка для Discord-рендерера.
/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26).
/// </summary>
public static class DiscordSessionBatchRenderer
{
public static object Render(SessionBatchViewModel view)
{
throw new NotImplementedException("Discord renderer will be implemented in issue #26.");
}
}
@@ -0,0 +1,4 @@
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -1,81 +0,0 @@
using GmRelay.Shared.Domain;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
public static class SessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(
string title,
IReadOnlyList<SessionBatchDto> sessions,
IReadOnlyList<ParticipantBatchDto> participants)
{
var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in activeSessions)
{
var sessionPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
.ToList();
var waitlistedPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.ToList();
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({sessionPlayers.Count}):\n";
if (sessionPlayers.Count > 0)
{
messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (waitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n";
messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(new[]
{
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"),
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}")
});
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
{
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
{
return $"⏳ В лист ожидания {dateTitle}";
}
return $"✋ На {dateTitle}";
}
}
@@ -0,0 +1,59 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Rendering;
public static class SessionBatchViewBuilder
{
public static SessionBatchViewModel Build(
string title,
IReadOnlyList<SessionBatchDto> sessions,
IReadOnlyList<ParticipantBatchDto> participants)
{
var orderedSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
var sessionItems = new List<SessionViewItem>();
foreach (var session in orderedSessions)
{
var activePlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
.ToList();
var waitlistedPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.Select(p => new PlayerViewItem(p.DisplayName, p.TelegramUsername, p.RegistrationStatus))
.ToList();
var actions = new List<AvailableAction>();
if (!SessionStatus.IsCancelled(session.Status))
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
var joinLabel = GetJoinButtonText(session, activePlayers.Count, dateTitle);
actions.Add(new AvailableAction("join_session", joinLabel, session.SessionId));
actions.Add(new AvailableAction("leave_session", $"🚪 Выйти {dateTitle}", session.SessionId));
}
sessionItems.Add(new SessionViewItem(
session.SessionId,
session.ScheduledAt,
session.Status,
session.MaxPlayers,
activePlayers.Count,
activePlayers,
waitlistedPlayers,
actions));
}
return new SessionBatchViewModel(title, sessionItems);
}
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
{
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
{
return $"⏳ В лист ожидания {dateTitle}";
}
return $"✋ На {dateTitle}";
}
}
@@ -0,0 +1,27 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchViewModel(
string Title,
IReadOnlyList<SessionViewItem> Sessions);
public sealed record SessionViewItem(
Guid SessionId,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
IReadOnlyList<AvailableAction> AvailableActions);
public sealed record PlayerViewItem(
string DisplayName,
string? TelegramUsername,
string RegistrationStatus);
public sealed record AvailableAction(
string ActionKey,
string Label,
Guid SessionId);
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v1.9.6</div> <div class="nav-version">v1.9.9</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -2,7 +2,7 @@ using Telegram.Bot;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering; namespace GmRelay.Web.Services;
/// <summary> /// <summary>
/// Handles editing batch messages that may be either text or photo messages. /// Handles editing batch messages that may be either text or photo messages.
+7 -3
View File
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using GmRelay.Web.Services;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
@@ -883,7 +884,8 @@ public sealed class SessionService(
await transaction.CommitAsync(); await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>()); var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage( var batchMessage = await bot.SendMessage(
chatId: chatId, chatId: chatId,
messageThreadId: threadId, messageThreadId: threadId,
@@ -1091,7 +1093,8 @@ public sealed class SessionService(
await transaction.CommitAsync(); await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>()); var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage( var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId, chatId: group.TelegramChatId,
text: renderResult.Text, text: renderResult.Text,
@@ -1198,7 +1201,8 @@ public sealed class SessionService(
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC", ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = batchId })).ToList(); new { BatchId = batchId })).ToList();
var renderResult = SessionBatchRenderer.Render(title, sessions, participants); var view = SessionBatchViewBuilder.Build(title, sessions, participants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync( await BatchMessageEditor.EditBatchMessageAsync(
bot, bot,
@@ -0,0 +1,56 @@
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Web.Services;
public static class TelegramSessionBatchRenderer
{
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
{
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
$"<b>Расписание:</b>\n\n";
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in view.Sessions)
{
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
else
{
messageText += " <i>Пока никто не записался</i>\n";
}
if (session.WaitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else
{
messageText += "\n";
var actionRow = session.AvailableActions
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
.ToArray();
if (actionRow.Length > 0)
buttons.Add(actionRow);
}
}
return (messageText, new InlineKeyboardMarkup(buttons));
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/* ============================================ /* ============================================
GM-Relay Design System v1.9.6 GM-Relay Design System v1.9.9
Dark RPG Dashboard Theme Dark RPG Dashboard Theme
============================================ */ ============================================ */
@@ -0,0 +1,392 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class TelegramLandingPromisesSmokeTests
{
[Fact]
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
var scenario = TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect);
var publishedCallbacks = CallbackData(scenario.LastMessage.Markup);
Assert.Contains("Landing Promise Smoke", scenario.LastMessage.Text);
Assert.Equal(6, publishedCallbacks.Count);
Assert.All(scenario.Sessions, session =>
{
Assert.Contains($"join_session:{session.Id}", publishedCallbacks);
Assert.Contains($"leave_session:{session.Id}", publishedCallbacks);
Assert.Contains(session.ScheduledAt.FormatMoscow(), scenario.LastMessage.Text);
});
var firstSessionId = scenario.Sessions[0].Id;
var alice = scenario.Join(firstSessionId, 1001, "Alice", "alice");
var bob = scenario.Join(firstSessionId, 1002, "Bob", "bob");
var carol = scenario.Join(firstSessionId, 1003, "Carol", "carol");
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, bob));
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, scenario.RegistrationStatus(firstSessionId, carol));
Assert.Contains("2/2", scenario.LastMessage.Text);
Assert.Contains("@alice", scenario.LastMessage.Text);
Assert.Contains("@bob", scenario.LastMessage.Text);
Assert.Contains("@carol", scenario.LastMessage.Text);
scenario.Leave(firstSessionId, alice);
Assert.False(scenario.HasParticipant(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol));
Assert.DoesNotContain("@alice", scenario.LastMessage.Text);
Assert.Contains("@carol", scenario.LastMessage.Text);
scenario.MarkRsvpConfirmed(firstSessionId, bob);
scenario.MarkRsvpConfirmed(firstSessionId, carol);
var option1Id = Guid.NewGuid();
var option2Id = Guid.NewGuid();
var options = new[]
{
new RescheduleOptionDto(option1Id, 1, new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero)),
new RescheduleOptionDto(option2Id, 2, new DateTimeOffset(2026, 5, 30, 15, 0, 0, TimeSpan.Zero))
};
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
scenario.Title,
scenario.Sessions[0].ScheduledAt,
deadline,
options,
voteParticipants,
[]);
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
Assert.Contains("Landing Promise Smoke", voteMessage);
Assert.Contains("0/2", voteMessage);
Assert.Contains($"reschedule_vote:{option1Id}", CallbackData(voteKeyboard));
Assert.Contains($"reschedule_vote:{option2Id}", CallbackData(voteKeyboard));
var votes = voteParticipants
.Select(participant => new RescheduleOptionVoteDto(
option2Id,
participant.PlayerId,
participant.DisplayName,
participant.TelegramUsername))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(
options.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId))).ToList());
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.Equal(option2Id, decision.SelectedOptionId);
scenario.ApplyReschedule(firstSessionId, options[1].ProposedAt);
Assert.Equal(options[1].ProposedAt.UtcDateTime, scenario.Sessions[0].ScheduledAt);
Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob));
Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol));
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), scenario.LastMessage.Text);
Assert.Collection(
scenario.Messenger.DirectMessages.Select(message => message.TelegramId).Order(),
telegramId => Assert.Equal(1002, telegramId),
telegramId => Assert.Equal(1003, telegramId));
Assert.All(scenario.Messenger.DirectMessages, message =>
{
Assert.Contains("Landing Promise Smoke", message.Text);
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), message.Text);
});
var editsBeforeDashboardUpdate = scenario.Messenger.Edits.Count;
scenario.UpdateBatchFromDashboard("Landing Promise Smoke - Dashboard Sync");
Assert.True(scenario.Messenger.Edits.Count > editsBeforeDashboardUpdate);
Assert.Contains("Landing Promise Smoke - Dashboard Sync", scenario.LastMessage.Text);
Assert.Contains("@bob", scenario.LastMessage.Text);
Assert.Contains("@carol", scenario.LastMessage.Text);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke",
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30",
"\u0418\u0433\u0440: 3",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7",
"\u041c\u0435\u0441\u0442: 2",
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table");
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard
.SelectMany(row => row)
.Select(button => button.CallbackData)
.OfType<string>()
.ToList();
private sealed class TelegramLandingSmokeScenario
{
private readonly List<SmokeSession> sessions;
private readonly List<SmokeParticipant> participants = [];
private readonly SessionNotificationMode notificationMode;
private TelegramLandingSmokeScenario(
string title,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int? maxPlayers,
SessionNotificationMode notificationMode)
{
Title = title;
this.notificationMode = notificationMode;
sessions = scheduledTimes
.Select(scheduledAt => new SmokeSession(
Guid.NewGuid(),
scheduledAt.UtcDateTime,
SessionStatus.Planned,
maxPlayers))
.ToList();
}
public string Title { get; private set; }
public FakeTelegramMessenger Messenger { get; } = new();
public IReadOnlyList<SmokeSession> Sessions => sessions;
public FakeTelegramMessage LastMessage => Messenger.LastMessage;
public static TelegramLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new TelegramLandingSmokeScenario(
parseResult.Title!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
notificationMode);
scenario.RenderBatch();
return scenario;
}
public Guid Join(Guid sessionId, long telegramId, string displayName, string? telegramUsername)
{
var session = sessions.Single(value => value.Id == sessionId);
var activeParticipants = participants.Count(participant =>
participant.SessionId == sessionId &&
participant.RegistrationStatus == ParticipantRegistrationStatus.Active);
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
session.MaxPlayers,
activeParticipants);
var playerId = Guid.NewGuid();
participants.Add(new SmokeParticipant(
sessionId,
playerId,
telegramId,
displayName,
telegramUsername,
registrationStatus,
GmRelay.Shared.Domain.RsvpStatus.Pending,
participants.Count));
RenderBatch();
return playerId;
}
public void Leave(Guid sessionId, Guid playerId)
{
var participant = participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId);
participants.Remove(participant);
var session = sessions.Single(value => value.Id == sessionId);
var activeParticipantsAfterLeave = participants.Count(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active);
var waitlistedParticipants = participants.Count(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted);
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
participant.RegistrationStatus,
session.MaxPlayers,
activeParticipantsAfterLeave,
waitlistedParticipants))
{
var promoted = participants
.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.OrderBy(value => value.JoinOrder)
.First();
promoted.RegistrationStatus = ParticipantRegistrationStatus.Active;
promoted.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
}
RenderBatch();
}
public bool HasParticipant(Guid sessionId, Guid playerId) =>
participants.Any(value => value.SessionId == sessionId && value.PlayerId == playerId);
public string RegistrationStatus(Guid sessionId, Guid playerId) =>
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RegistrationStatus;
public string RsvpStatus(Guid sessionId, Guid playerId) =>
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RsvpStatus;
public void MarkRsvpConfirmed(Guid sessionId, Guid playerId)
{
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Confirmed;
}
public IReadOnlyList<VoteParticipantDto> ActiveVoteParticipants(Guid sessionId) =>
participants
.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active)
.OrderBy(value => value.DisplayName)
.Select(value => new VoteParticipantDto(
value.PlayerId,
value.DisplayName,
value.TelegramUsername,
value.TelegramId))
.ToList();
public void ApplyReschedule(Guid sessionId, DateTimeOffset newScheduledAt)
{
sessions.Single(value => value.Id == sessionId).ScheduledAt = newScheduledAt.UtcDateTime;
foreach (var participant in participants.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
{
participant.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
}
RenderBatch();
if (!notificationMode.ShouldSendDirectMessages())
{
return;
}
var notification = $"""
Reschedule approved
{Title}
{newScheduledAt.UtcDateTime.FormatMoscow()}
""";
foreach (var participant in participants.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
{
Messenger.SendDirectMessage(participant.TelegramId, notification);
}
}
public void UpdateBatchFromDashboard(string title)
{
Title = title;
RenderBatch();
}
private void RenderBatch()
{
var view = SessionBatchViewBuilder.Build(
Title,
sessions
.Select(session => new SessionBatchDto(
session.Id,
session.ScheduledAt,
session.Status,
session.MaxPlayers))
.ToList(),
participants
.Select(participant => new ParticipantBatchDto(
participant.SessionId,
participant.DisplayName,
participant.TelegramUsername,
participant.RegistrationStatus))
.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
if (Messenger.HasPublishedMessage)
{
Messenger.EditBatchMessage(renderResult.Text, renderResult.Markup);
}
else
{
Messenger.PublishBatchMessage(renderResult.Text, renderResult.Markup);
}
}
}
private sealed class FakeTelegramMessenger
{
private const int BatchMessageId = 7001;
public List<FakeTelegramMessage> Sends { get; } = [];
public List<FakeTelegramMessage> Edits { get; } = [];
public List<FakeDirectMessage> DirectMessages { get; } = [];
public bool HasPublishedMessage => Sends.Count > 0;
public FakeTelegramMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1];
public void PublishBatchMessage(string text, InlineKeyboardMarkup markup) =>
Sends.Add(new FakeTelegramMessage(BatchMessageId, text, markup));
public void EditBatchMessage(string text, InlineKeyboardMarkup markup) =>
Edits.Add(new FakeTelegramMessage(BatchMessageId, text, markup));
public void SendDirectMessage(long telegramId, string text) =>
DirectMessages.Add(new FakeDirectMessage(telegramId, text));
}
private sealed record FakeTelegramMessage(
int MessageId,
string Text,
InlineKeyboardMarkup Markup);
private sealed record FakeDirectMessage(long TelegramId, string Text);
private sealed record SmokeSession(
Guid Id,
DateTime ScheduledAt,
string Status,
int? MaxPlayers)
{
public DateTime ScheduledAt { get; set; } = ScheduledAt;
}
private sealed record SmokeParticipant(
Guid SessionId,
Guid PlayerId,
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RegistrationStatus,
string RsvpStatus,
int JoinOrder)
{
public string RegistrationStatus { get; set; } = RegistrationStatus;
public string RsvpStatus { get; set; } = RsvpStatus;
}
}
@@ -1,56 +0,0 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchRendererTests
{
[Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var result = SessionBatchRenderer.Render("Campaign", sessions, participants);
var text = result.Text;
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
Assert.Contains("Campaign", text);
Assert.True(firstIndex < secondIndex);
Assert.True(secondIndex < thirdIndex);
Assert.Contains("Места: 0/2", text);
Assert.Contains("Места: 1/4", text);
Assert.Contains("@alice", text);
Assert.Contains("Лист ожидания (1)", text);
Assert.Contains("Charlie", text);
Assert.Contains("Bob", text);
Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Collection(
buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData));
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("cancel_session:", StringComparison.Ordinal) == true);
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("reschedule_session:", StringComparison.Ordinal) == true);
Assert.DoesNotContain(buttons, button => button.CallbackData?.StartsWith("promote_waitlist:", StringComparison.Ordinal) == true);
}
}
@@ -0,0 +1,113 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchViewBuilderTests
{
[Fact]
public void Build_ShouldOrderSessionsByDate()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var result = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
Assert.Equal("Campaign", result.Title);
Assert.Equal(3, result.Sessions.Count);
Assert.Equal(firstSessionId, result.Sessions[0].SessionId);
Assert.Equal(secondSessionId, result.Sessions[1].SessionId);
Assert.Equal(cancelledSessionId, result.Sessions[2].SessionId);
}
[Fact]
public void Build_ShouldCalculatePlayerCounts()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
var participants = new[]
{
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted)
};
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var session = result.Sessions[0];
Assert.Equal(2, session.ActivePlayerCount);
Assert.Equal(4, session.MaxPlayers);
Assert.Equal(2, session.ActivePlayers.Count);
Assert.Equal(1, session.WaitlistedPlayers.Count);
}
[Fact]
public void Build_ShouldIncludeActionsForActiveSessions()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var actions = result.Sessions[0].AvailableActions;
Assert.Equal(2, actions.Count);
Assert.Equal("join_session", actions[0].ActionKey);
Assert.Equal("leave_session", actions[1].ActionKey);
Assert.Equal(sessionId, actions[0].SessionId);
Assert.Equal(sessionId, actions[1].SessionId);
}
[Fact]
public void Build_ShouldNotIncludeActionsForCancelledSessions()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
Assert.Empty(result.Sessions[0].AvailableActions);
}
[Fact]
public void Build_ShouldMarkWaitlistActionWhenFull()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
Assert.Contains("ожидания", joinAction.Label);
}
[Fact]
public void Build_ShouldIncludePlayerUsernames()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null) };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
var player = result.Sessions[0].ActivePlayers[0];
Assert.Equal("Alice", player.DisplayName);
Assert.Equal("alice", player.TelegramUsername);
}
}
@@ -0,0 +1,76 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class TelegramSessionBatchRendererTests
{
[Fact]
public void Render_ShouldProduceCorrectHtmlAndButtons()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("Campaign", text);
Assert.Contains("@alice", text);
Assert.Contains("Charlie", text);
Assert.Contains("Bob", text);
Assert.Contains("Сессия отменена", text);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
Assert.Contains(buttons, b => b.CallbackData == $"join_session:{firstSessionId}");
Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{firstSessionId}");
Assert.Contains(buttons, b => b.CallbackData == $"join_session:{secondSessionId}");
Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{secondSessionId}");
Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("cancel") == true);
Assert.DoesNotContain(buttons, b => b.CallbackData?.StartsWith("reschedule") == true);
}
[Fact]
public void Render_ShouldSkipButtonsForCancelledSessions()
{
var cancelledSessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
Assert.Empty(markup.InlineKeyboard);
}
[Fact]
public void Render_ShouldShowWaitlistButtonWhenFull()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
var joinButton = buttons.First(b => b.CallbackData?.StartsWith("join_session:") == true);
Assert.Contains("ожидания", joinButton.Text);
}
}
@@ -655,6 +655,10 @@ public sealed class AuthorizedSessionServiceTests
public string? LastAddedCoGmUsername { get; private set; } public string? LastAddedCoGmUsername { get; private set; }
public Guid? LastRemovedCoGmGroupId { get; private set; } public Guid? LastRemovedCoGmGroupId { get; private set; }
public long? LastRemovedCoGmTelegramId { get; private set; } public long? LastRemovedCoGmTelegramId { get; private set; }
public bool RemovePlayerCalled { get; private set; }
public Guid? LastRemovedPlayerSessionId { get; private set; }
public Guid? LastRemovedPlayerGroupId { get; private set; }
public Guid? LastRemovedPlayerParticipantId { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) => public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList()); Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
@@ -870,6 +874,18 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) =>
Task.FromResult(new List<WebParticipant>());
public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
{
RemovePlayerCalled = true;
LastRemovedPlayerSessionId = sessionId;
LastRemovedPlayerGroupId = groupId;
LastRemovedPlayerParticipantId = participantId;
return Task.CompletedTask;
}
private bool IsManager(Guid groupId, long telegramId) => private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) || IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId); managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
@@ -1,3 +1,5 @@
using System.Xml.Linq;
namespace GmRelay.Bot.Tests.Web; namespace GmRelay.Bot.Tests.Web;
public sealed class CampaignTemplatesNavigationTests public sealed class CampaignTemplatesNavigationTests
@@ -11,6 +13,20 @@ public sealed class CampaignTemplatesNavigationTests
Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal); Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal);
} }
[Fact]
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
var props = XDocument.Load(FindRepositoryFile("Directory.Build.props"));
var version = props.Root?
.Element("PropertyGroup")?
.Element("Version")?
.Value;
Assert.False(string.IsNullOrWhiteSpace(version));
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows() public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
{ {