456 lines
19 KiB
C#
456 lines
19 KiB
C#
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);
|
||
}
|