using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Notifications; namespace TeleWave.Application.Notifications; /// /// Разговор с ботом: привязка чата к учётке и меню подписок. /// /// Один и тот же код обслуживает оба транспорта — опрос и вебхук приносят одно и то же обновление, /// и разводить логику по ним значило бы чинить каждую находку дважды. /// /// Меню живёт одним сообщением: нажатие кнопки переписывает его на месте. Лента из десятка /// одинаковых сообщений «ваши подписки» после каждого клика — это не настройка, а мусор в чате. /// public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api) { /// Разделитель полей в callback: действие, канал и (для подписки) вид оповещения. private const char Separator = ':'; /// Переключить подписку: sub:канал:вид. private const string ToggleAction = "sub"; /// Показать программу канала: epg:канал. private const string ScheduleAction = "epg"; /// Сколько программ показывать: экран телефона, а не полные сутки. private const int ScheduleBlocks = 8; public async Task HandleAsync( TelegramSettings settings, TelegramUpdate update, DateTimeOffset now, CancellationToken cancellationToken ) { if (update.ChatId is not { } chatId) return; if (update.CallbackData is { Length: > 0 } data) { await HandleCallbackAsync(settings, update, chatId, data, now, cancellationToken); return; } var text = update.Text?.Trim() ?? string.Empty; if (text.StartsWith("/start", StringComparison.OrdinalIgnoreCase)) { await HandleStartAsync(settings, chatId, text, update.From, now, cancellationToken); return; } if (text.StartsWith("/stop", StringComparison.OrdinalIgnoreCase)) { await HandleStopAsync(settings, chatId, cancellationToken); return; } // Любое другое сообщение — это «я не знаю, что нажать»: показываем меню, если чат известен. var subscriber = await FindAsync(chatId, cancellationToken); if (subscriber is null) { await NoticeAsync(settings, null, chatId, Texts.NeedLink, cancellationToken); return; } await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } /// /// /start КОД — единственный вход: код выдаёт страница эфира авторизованному зрителю, /// и только по нему бот узнаёт, кто пришёл. Без кода подписаться нельзя — иначе рассылка /// уходила бы любому, кто нашёл бота. /// private async Task HandleStartAsync( TelegramSettings settings, long chatId, string text, string? from, DateTimeOffset now, CancellationToken cancellationToken ) { var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); var code = parts.Length > 1 ? parts[1].Trim() : null; var subscriber = await FindAsync(chatId, cancellationToken); if (code is null) { // Уже привязанный чат просто открывает меню: заставлять его снова ходить за кодом незачем. if (subscriber is null) { await NoticeAsync(settings, null, chatId, Texts.NeedLink, cancellationToken); return; } subscriber.Resume(); subscriber.Touch(now); await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return; } var link = await dbContext.TelegramLinkCodes.FirstOrDefaultAsync( c => c.Code == code, cancellationToken ); if (link is null || !link.IsUsable(now)) { await NoticeAsync(settings, subscriber, chatId, Texts.BadCode, cancellationToken); return; } link.MarkUsed(now); if (subscriber is null) { subscriber = TelegramSubscriber.Create(chatId, link.UserId, from, now); dbContext.TelegramSubscribers.Add(subscriber); } else { subscriber.Rebind(link.UserId, from, now); } await NoticeAsync(settings, subscriber, chatId, Texts.Linked, cancellationToken); await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } private async Task HandleStopAsync( TelegramSettings settings, long chatId, CancellationToken cancellationToken ) { var subscriber = await FindAsync(chatId, cancellationToken); if (subscriber is null) return; // Подписки не трогаем: вернувшись по /start, зритель получит свои каналы, а не пустой список. subscriber.Stop(); await NoticeAsync(settings, subscriber, chatId, Texts.Stopped, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } private async Task HandleCallbackAsync( TelegramSettings settings, TelegramUpdate update, long chatId, string data, DateTimeOffset now, CancellationToken cancellationToken ) { var subscriber = await FindAsync(chatId, cancellationToken); if (subscriber is null) { await AnswerAsync(settings, update, Texts.NeedLink, cancellationToken); return; } var parts = data.Split(Separator); if (parts.Length < 2 || !Guid.TryParse(parts[1], out var channelId)) { await AnswerAsync(settings, update, null, cancellationToken); return; } var channel = await dbContext .Channels.AsNoTracking() .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); if (channel is null) { await AnswerAsync(settings, update, Texts.ChannelGone, cancellationToken); return; } // Расписание приходит отдельным сообщением, а меню остаётся на месте: иначе после // просмотра программы пришлось бы вызывать /start, чтобы вернуть кнопки. if (parts[0] == ScheduleAction) { subscriber.Touch(now); await dbContext.SaveChangesAsync(cancellationToken); await AnswerAsync(settings, update, null, cancellationToken); await SendScheduleAsync(settings, subscriber, channel, chatId, now, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return; } if ( parts.Length != 3 || parts[0] != ToggleAction || !Enum.TryParse(parts[2], out var kind) ) { await AnswerAsync(settings, update, null, cancellationToken); return; } var enabled = subscriber.Toggle(channelId, kind); subscriber.Touch(now); subscriber.Resume(); await dbContext.SaveChangesAsync(cancellationToken); await AnswerAsync( settings, update, enabled ? Texts.Subscribed : Texts.Unsubscribed, cancellationToken ); await ShowMenuAsync( settings, subscriber, chatId, update.CallbackMessageId, cancellationToken ); await dbContext.SaveChangesAsync(cancellationToken); } /// /// Одноразовое сообщение бота: программа передач, подтверждение привязки, отказ. Предыдущее /// такое же снимается — три программы на разное время в чате не нужны никому. /// private async Task NoticeAsync( TelegramSettings settings, TelegramSubscriber? subscriber, long chatId, string text, CancellationToken cancellationToken ) { if (subscriber?.NoticeMessageId is { } previous) await api.DeleteMessageAsync(settings, chatId, previous, cancellationToken); var messageId = await api.SendMessageAsync(settings, chatId, text, null, cancellationToken); // Незнакомому чату помнить нечего: подписчика ещё нет, и следующее сообщение будет первым. subscriber?.SetNoticeMessage(messageId); } private Task AnswerAsync( TelegramSettings settings, TelegramUpdate update, string? text, CancellationToken cancellationToken ) => update.CallbackId is { Length: > 0 } id ? api.AnswerCallbackAsync(settings, id, text, cancellationToken) : Task.CompletedTask; /// /// Меню подписок: строка на канал, в ней — кнопка на каждый вид оповещения с отметкой. /// задан — правим уже отправленное сообщение, а не шлём новое. /// private async Task ShowMenuAsync( TelegramSettings settings, TelegramSubscriber subscriber, long chatId, long? messageId, CancellationToken cancellationToken ) { var channels = await dbContext .Channels.AsNoTracking() .Where(c => c.IsEnabled) .OrderBy(c => c.Number ?? int.MaxValue) .ThenBy(c => c.Name) .Select(c => new { c.Id, c.Name }) .ToListAsync(cancellationToken); if (channels.Count == 0) { await NoticeAsync(settings, subscriber, chatId, Texts.NoChannels, cancellationToken); return; } var keyboard = new List>(); foreach (var channel in channels) { keyboard.Add([ new TelegramButton( $"📋 {channel.Name}", $"{ScheduleAction}{Separator}{channel.Id}" ), ]); keyboard.Add([ Button(subscriber, channel.Id, TelegramNotificationKind.ShowStart, "Начало шоу"), Button(subscriber, channel.Id, TelegramNotificationKind.UpNext, "Скоро в эфире"), ]); } // Нажали кнопку — правим то же сообщение. Пришли заново — старое меню снимаем: две панели // с разными галочками в одном чате означают, что одна из них врёт. if (messageId is { } id) { await api.EditMessageAsync( settings, chatId, id, Texts.Menu, keyboard, cancellationToken ); subscriber.SetMenuMessage(id); return; } if (subscriber.MenuMessageId is { } previous) await api.DeleteMessageAsync(settings, chatId, previous, cancellationToken); var sent = await api.SendMessageAsync( settings, chatId, Texts.Menu, keyboard, cancellationToken ); subscriber.SetMenuMessage(sent); } private static TelegramButton Button( TelegramSubscriber subscriber, Guid channelId, TelegramNotificationKind kind, string label ) { var on = subscriber.IsSubscribed(channelId, kind); return new TelegramButton( $"{(on ? "✅" : "▫️")} {label}", $"{ToggleAction}{Separator}{channelId}{Separator}{kind}" ); } /// /// Программа канала: что идёт сейчас и что дальше. /// /// Подряд идущие серии одного шоу схлопываются в один блок — зритель спрашивает «что сегодня», /// а не «в каком порядке лежат файлы», и четыре строки «Симпсоны» подряд ответом не являются. /// Времена — в часовом поясе канала: сетка задаётся в нём, и переводить их зрителю в UTC /// значило бы показывать программу чужого телевизора. /// private async Task SendScheduleAsync( TelegramSettings settings, TelegramSubscriber subscriber, Channel channel, long chatId, DateTimeOffset now, CancellationToken cancellationToken ) { var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes); // Берём с запасом: врезки и заставки в программу не идут, а серии схлопываются в блоки, // поэтому записей нужно заметно больше, чем строк на выходе. var entries = await dbContext .ScheduleEntries.AsNoTracking() .Where(e => e.ChannelId == channel.Id && e.Kind == ScheduleEntryKind.Program && e.ShowId != null && e.EndsAtUtc > now ) .OrderBy(e => e.StartsAtUtc) .Take(ScheduleBlocks * 12) .Select(e => new { e.StartsAtUtc, e.EndsAtUtc, ShowId = e.ShowId!.Value, }) .ToListAsync(cancellationToken); if (entries.Count == 0) { await NoticeAsync(settings, subscriber, chatId, Texts.NoSchedule, cancellationToken); return; } var names = await dbContext .Shows.AsNoTracking() .Where(s => entries.Select(e => e.ShowId).Contains(s.Id)) .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); var lines = new List { $"{TelegramText.Escape(channel.Name)}" }; Guid? previousShow = null; foreach (var entry in entries) { if (entry.ShowId == previousShow) continue; previousShow = entry.ShowId; if (lines.Count > ScheduleBlocks) break; var local = entry.StartsAtUtc.ToOffset(offset); var title = TelegramText.Escape(names.GetValueOrDefault(entry.ShowId) ?? "—"); // Идущее сейчас помечаем: без отметки первая строка читается как «начнётся в 20:15». var mark = entry.StartsAtUtc <= now ? "▶️" : "🕔"; lines.Add($"{mark} {local:HH:mm} — {title}"); } await api.SendMessageAsync( settings, chatId, string.Join('\n', lines), null, cancellationToken ); } private Task FindAsync(long chatId, CancellationToken cancellationToken) => dbContext .TelegramSubscribers.Include(s => s.Subscriptions) .FirstOrDefaultAsync(s => s.ChatId == chatId, cancellationToken); /// Тексты бота. Собраны в одном месте: их правят чаще, чем логику вокруг них. private static class Texts { public const string NeedLink = "Чтобы получать оповещения, откройте страницу эфира TeleWave и нажмите «Открыть чат с ботом» — " + "ссылка придёт с одноразовым кодом."; public const string BadCode = "Код не подошёл: он одноразовый и живёт 15 минут. Нажмите кнопку на странице эфира ещё раз."; public const string Linked = "Готово, чат привязан к вашей учётной записи."; public const string Menu = "Оповещения\nНажмите, чтобы включить или выключить. ✅ — включено.\n" // Кнопка-заголовок ничем не выдаёт, что она кнопка: про программу передач надо сказать // словами, иначе её найдут только промахнувшись мимо тумблера. + "Программа передач — нажмите на название канала.\n" + "Остановить всё — командой /stop."; public const string NoChannels = "Пока нет ни одного включённого канала."; public const string Stopped = "Оповещения остановлены. Вернуть — командой /start."; public const string Subscribed = "Включено"; public const string Unsubscribed = "Выключено"; public const string ChannelGone = "Канала больше нет"; public const string NoSchedule = "Программа ещё не собрана."; } }