refactor(#22): platform-neutral batch rendering
Deploy Telegram Bot / build-and-push (push) Failing after 34s
Deploy Telegram Bot / deploy (push) Has been skipped

This commit is contained in:
root
2026-05-06 09:17:05 +00:00
27 changed files with 807 additions and 158 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.9.9
VERSION: 1.10.0
jobs:
# ЧАСТЬ 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>
<PropertyGroup>
<Version>1.9.9</Version>
<Version>1.10.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.9
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.0
restart: always
depends_on:
db:
@@ -30,7 +30,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.9
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.0
restart: always
depends_on:
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 Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -102,7 +103,8 @@ public sealed class CancelSessionHandler(
await transaction.CommitAsync(ct);
// 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
{
@@ -4,6 +4,7 @@ using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -183,7 +184,8 @@ public sealed class CreateSessionHandler(
await transaction.CommitAsync(cancellationToken);
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;
@@ -4,6 +4,7 @@ using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -136,7 +137,8 @@ public sealed class JoinSessionHandler(
transactionCommitted = true;
// 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(
bot,
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -182,7 +183,8 @@ public sealed class LeaveSessionHandler(
await transaction.CommitAsync(ct);
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(
bot,
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -162,7 +163,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
await transaction.CommitAsync(ct);
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(
bot,
@@ -6,6 +6,7 @@ using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -375,8 +376,8 @@ public sealed class HandleRescheduleTimeInputHandler(
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(
bot,
@@ -5,6 +5,7 @@ using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -304,7 +305,8 @@ public sealed class RescheduleVotingDeadlineService(
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(
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>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
</ItemGroup>
</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);
@@ -2,7 +2,7 @@ using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
namespace GmRelay.Web.Services;
/// <summary>
/// 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 Npgsql;
using Telegram.Bot;
using GmRelay.Web.Services;
namespace GmRelay.Web.Services;
@@ -883,7 +884,8 @@ public sealed class SessionService(
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(
chatId: chatId,
messageThreadId: threadId,
@@ -1091,7 +1093,8 @@ public sealed class SessionService(
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(
chatId: group.TelegramChatId,
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",
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(
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));
}
}
@@ -3,6 +3,7 @@ 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;
@@ -310,7 +311,7 @@ public sealed class TelegramLandingPromisesSmokeTests
private void RenderBatch()
{
var renderResult = SessionBatchRenderer.Render(
var view = SessionBatchViewBuilder.Build(
Title,
sessions
.Select(session => new SessionBatchDto(
@@ -326,6 +327,7 @@ public sealed class TelegramLandingPromisesSmokeTests
participant.TelegramUsername,
participant.RegistrationStatus))
.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
if (Messenger.HasPublishedMessage)
{
@@ -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);
}
}