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
This commit is contained in:
root
2026-05-06 07:57:23 +00:00
parent 5dee2d87f5
commit 14b9bf15f2
25 changed files with 741 additions and 157 deletions
@@ -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));
}
}