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 logger) : IPlatformMessenger { public async Task 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 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 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 ? $"✅ Голосование завершено.\nПобедил вариант {update.SelectedOption.DisplayOrder}: {update.SelectedOption.ProposedAt.FormatMoscow()} (МСК)." : $"❌ Голосование завершено.\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 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 { $"🎲 Подтвердите участие в «{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($" ❌ {FormatTelegramParticipant(participant)}"); } 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 => $""" 🎲 Подтвердите участие в игре 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} 📅 {notification.ScheduledAt.FormatMoscow()} (МСК) Ответьте кнопкой в групповом сообщении расписания. """, PlatformDirectSessionNotificationKind.OneHourReminder => $""" ⏰ Игра начнётся примерно через 1 час 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} 📅 {notification.ScheduledAt.FormatMoscow()} (МСК) 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} """, PlatformDirectSessionNotificationKind.JoinLink => $""" 🎮 Игра начинается через 5 минут 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} 🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)} """, PlatformDirectSessionNotificationKind.RescheduleApproved => $""" ✅ Сессия перенесена по итогам голосования 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} 📅 Новое время: {notification.ScheduledAt.FormatMoscow()} (МСК) """, PlatformDirectSessionNotificationKind.RescheduleRejected => $""" ❌ Перенос сессии отклонён по итогам голосования 📌 {System.Net.WebUtility.HtmlEncode(notification.Title)} 📅 Время остаётся прежним: {notification.ScheduledAt.FormatMoscow()} (МСК) Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)} """, _ => BuildFallbackDirectText(notification) }; private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) => $"{System.Net.WebUtility.HtmlEncode(notification.Title)}\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 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); }