Files
GmRelayBot/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs
T
Toutsu 2a707e4825
PR Checks / test-and-build (pull_request) Successful in 7m9s
feat(platform): route scheduler notifications through platform messenger
2026-05-21 12:30:35 +03:00

456 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramPlatformMessenger(
ITelegramBotClient bot,
ILogger<TelegramPlatformMessenger> logger) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var chatId = ParseLong(message.Group.ExternalGroupId);
var threadId = ParseNullableInt(message.Group.ExternalThreadId);
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
Message sentMessage;
if (!string.IsNullOrWhiteSpace(message.ImageReference) && renderResult.Text.Length <= 1024)
{
try
{
sentMessage = await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(message.ImageReference),
caption: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for group {ExternalGroupId}", message.Group.ExternalGroupId);
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
}
else
{
if (!string.IsNullOrWhiteSpace(message.ImageReference))
{
await TrySendScheduleImageOnly(chatId, threadId, message.View.Title, message.ImageReference, ct);
}
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
return new PlatformMessageRef(
PlatformKind.Telegram,
message.Group.ExternalGroupId,
message.Group.ExternalThreadId,
sentMessage.MessageId.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var existingMessage = message.ExistingMessage;
if (existingMessage is null)
{
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
}
EnsureTelegram(existingMessage.Platform);
if (!string.Equals(message.Group.ExternalGroupId, existingMessage.ExternalGroupId, StringComparison.Ordinal) ||
!string.Equals(message.Group.ExternalThreadId, existingMessage.ExternalThreadId, StringComparison.Ordinal))
{
throw new ArgumentException("Existing schedule message reference must match the schedule group.", nameof(message));
}
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
EnsureTelegram(group.Platform);
return bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(message.Recipient.ExternalUserId),
text: message.HtmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) =>
bot.AnswerCallbackQuery(
callbackQueryId: reply.InteractionId,
text: reply.Text,
showAlert: reply.ShowAlert,
cancellationToken: ct);
public async Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
EnsureTelegram(file.Group.Platform);
using var stream = new MemoryStream(file.Content);
await bot.SendDocument(
chatId: ParseLong(file.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(file.Group.ExternalThreadId),
document: InputFile.FromStream(stream, file.FileName),
caption: file.CaptionHtml,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(file.Actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct)
{
EnsureTelegram(request.Group.Platform);
var chatId = ParseLong(request.Group.ExternalGroupId);
var threadId = ParseNullableInt(request.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
{
var request = update.Request;
EnsureTelegram(request.Group.Platform);
var existingMessage = request.ExistingMessage
?? throw new ArgumentException("Existing confirmation message reference is required.", nameof(update));
EnsureTelegram(existingMessage.Platform);
await bot.EditMessageText(
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: update.DisableActions ? null : BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
PlatformJoinLinkNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Group.Platform);
var chatId = ParseLong(notification.Group.ExternalGroupId);
var threadId = ParseNullableInt(notification.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildJoinLinkText(notification),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public Task SendDirectSessionNotificationAsync(
PlatformDirectSessionNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(notification.Recipient.ExternalUserId),
text: BuildDirectNotificationText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
{
switch (notification.Kind)
{
case PlatformRsvpOutcomeKind.GroupAllConfirmed:
if (notification.Group is null)
{
throw new ArgumentException("Group notification requires a group.", nameof(notification));
}
EnsureTelegram(notification.Group.Platform);
await bot.SendMessage(
chatId: ParseLong(notification.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(notification.Group.ExternalThreadId),
text: $"🎉 Игра «{notification.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct);
break;
case PlatformRsvpOutcomeKind.GmAllConfirmed:
case PlatformRsvpOutcomeKind.GmPlayerDeclined:
foreach (var recipient in notification.Recipients)
{
EnsureTelegram(recipient.Platform);
await bot.SendMessage(
chatId: ParseLong(recipient.ExternalUserId),
text: BuildRsvpOutcomeDirectText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
break;
default:
throw new ArgumentOutOfRangeException(nameof(notification), notification.Kind, "Unknown RSVP outcome kind.");
}
}
public Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
{
EnsureTelegram(update.Group.Platform);
EnsureTelegram(update.ExistingMessage.Platform);
var resultText = update.SelectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {update.SelectedOption.DisplayOrder}: <b>{update.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(update.Decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
update.Title,
update.CurrentScheduledAt,
update.VotingDeadlineAt,
update.Options,
update.Participants,
update.Votes)}
{resultText}
""";
return bot.EditMessageText(
chatId: ParseLong(update.ExistingMessage.ExternalGroupId),
messageId: ParseInt(update.ExistingMessage.ExternalMessageId),
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
private async Task<Message> SendScheduleTextMessage(
long chatId,
int? threadId,
string text,
InlineKeyboardMarkup markup,
CancellationToken ct) =>
await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: text,
parseMode: ParseMode.Html,
replyMarkup: markup,
cancellationToken: ct);
private static string BuildConfirmationText(PlatformConfirmationRequest request)
{
var confirmed = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
var pending = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
var lines = new List<string>
{
$"🎲 Подтвердите участие в «{System.Net.WebUtility.HtmlEncode(request.Title)}»",
$"📅 {request.ScheduledAt.FormatMoscow()} (МСК)",
string.Empty
};
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatTelegramParticipant(participant)}");
}
foreach (var participant in declined)
{
lines.Add($" ❌ <s>{FormatTelegramParticipant(participant)}</s>");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatTelegramParticipant(participant)}");
}
lines.Add(string.Empty);
if (request.Participants.Count > 0 && confirmed.Count == request.Participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{request.Participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{request.Participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{request.Participants.Count})");
}
return string.Join("\n", lines);
}
private static string BuildJoinLinkText(PlatformJoinLinkNotification notification)
{
var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(FormatTelegramParticipant));
return $"""
🎮 Игра «{notification.Title}» начинается через 5 минут!
🔗 Ссылка на подключение:
{notification.JoinLink}
Участники: {mentions}
Хорошей игры! 🎲
""";
}
private static string BuildDirectNotificationText(PlatformDirectSessionNotification notification) =>
notification.Kind switch
{
PlatformDirectSessionNotificationKind.ConfirmationRequest => $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""",
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
⏰ <b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.JoinLink => $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Новое время: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
""",
PlatformDirectSessionNotificationKind.RescheduleRejected => $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Время остаётся прежним: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)}
""",
_ => BuildFallbackDirectText(notification)
};
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
private static string BuildRsvpOutcomeDirectText(PlatformRsvpOutcomeNotification notification) =>
notification.Kind switch
{
PlatformRsvpOutcomeKind.GmAllConfirmed =>
$"✅ Все подтвердили участие в «{System.Net.WebUtility.HtmlEncode(notification.Title)}» ({notification.ScheduledAt.FormatMoscow()} МСК).",
PlatformRsvpOutcomeKind.GmPlayerDeclined =>
$"🚨 Отмена! {System.Net.WebUtility.HtmlEncode(notification.ActorDisplayName ?? "Игрок")} не сможет прийти на игру «{System.Net.WebUtility.HtmlEncode(notification.Title)}».",
_ => System.Net.WebUtility.HtmlEncode(notification.Title)
};
private static InlineKeyboardMarkup BuildRsvpKeyboard(Guid sessionId) =>
new([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
]
]);
private static string FormatTelegramParticipant(PlatformSessionParticipant participant) =>
participant.User.ExternalUsername is not null
? $"@{participant.User.ExternalUsername}"
: System.Net.WebUtility.HtmlEncode(participant.User.DisplayName);
private async Task TrySendScheduleImageOnly(
long chatId,
int? threadId,
string title,
string imageReference,
CancellationToken ct)
{
try
{
await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for chat {ChatId}", chatId);
}
}
private static InlineKeyboardMarkup? BuildActionsMarkup(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return null;
}
return new InlineKeyboardMarkup(
actions.Select(action => new[]
{
Uri.TryCreate(action.Payload, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? InlineKeyboardButton.WithUrl(action.Label, action.Payload)
: InlineKeyboardButton.WithCallbackData(action.Label, action.Payload)
}));
}
private static void EnsureTelegram(PlatformKind platform)
{
if (platform != PlatformKind.Telegram)
{
throw new NotSupportedException($"Telegram messenger cannot send messages for platform {platform}.");
}
}
private static long ParseLong(string value) => long.Parse(value, CultureInfo.InvariantCulture);
private static int ParseInt(string value) => int.Parse(value, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, CultureInfo.InvariantCulture);
}