diff --git a/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs b/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs index 159e336..096a1f0 100644 --- a/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs +++ b/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; using TeleWave.Domain.Notifications; namespace TeleWave.Application.Notifications; @@ -15,9 +16,18 @@ namespace TeleWave.Application.Notifications; /// public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api) { - /// Разделитель в callback: идентификатор канала и вид оповещения. + /// Разделитель полей в 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, @@ -156,23 +166,42 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api } var parts = data.Split(Separator); - if ( - parts.Length != 2 - || !Guid.TryParse(parts[0], out var channelId) - || !Enum.TryParse(parts[1], out var kind) - ) + if (parts.Length < 2 || !Guid.TryParse(parts[1], out var channelId)) { await AnswerAsync(settings, update, null, cancellationToken); return; } - var known = await dbContext.Channels.AnyAsync(c => c.Id == channelId, cancellationToken); - if (!known) + 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, channel, chatId, now, 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(); @@ -233,7 +262,10 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api foreach (var channel in channels) { keyboard.Add([ - new TelegramButton($"— {channel.Name} —", $"{channel.Id}{Separator}none"), + new TelegramButton( + $"📋 {channel.Name}", + $"{ScheduleAction}{Separator}{channel.Id}" + ), ]); keyboard.Add([ Button(subscriber, channel.Id, TelegramNotificationKind.ShowStart, "Начало шоу"), @@ -262,7 +294,87 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api ) { var on = subscriber.IsSubscribed(channelId, kind); - return new TelegramButton($"{(on ? "✅" : "▫️")} {label}", $"{channelId}{Separator}{kind}"); + return new TelegramButton( + $"{(on ? "✅" : "▫️")} {label}", + $"{ToggleAction}{Separator}{channelId}{Separator}{kind}" + ); + } + + /// + /// Программа канала: что идёт сейчас и что дальше. + /// + /// Подряд идущие серии одного шоу схлопываются в один блок — зритель спрашивает «что сегодня», + /// а не «в каком порядке лежат файлы», и четыре строки «Симпсоны» подряд ответом не являются. + /// Времена — в часовом поясе канала: сетка задаётся в нём, и переводить их зрителю в UTC + /// значило бы показывать программу чужого телевизора. + /// + private async Task SendScheduleAsync( + TelegramSettings settings, + 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 api.SendMessageAsync(settings, chatId, Texts.NoSchedule, null, 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) => @@ -295,5 +407,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api public const string Unsubscribed = "Выключено"; public const string ChannelGone = "Канала больше нет"; + + public const string NoSchedule = "Программа ещё не собрана."; } } diff --git a/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs b/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs index 36fbe0d..4595d46 100644 --- a/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs +++ b/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs @@ -81,7 +81,7 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api) settings, entry.ChannelId, TelegramNotificationKind.ShowStart, - $"▶️ {Escape(title)}\nСейчас в эфире", + $"▶️ {TelegramText.Escape(title)}\nСейчас в эфире", cancellationToken ); } @@ -123,7 +123,7 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api) settings, entry.ChannelId, TelegramNotificationKind.UpNext, - $"🕔 Через {minutes} мин: {Escape(title)}", + $"🕔 Через {minutes} мин: {TelegramText.Escape(title)}", cancellationToken ); } @@ -201,8 +201,4 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api) return sent; } - - /// Экранирование под HTML-разметку Telegram: в названиях шоу встречаются «&» и кавычки. - private static string Escape(string value) => - value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } diff --git a/backend/src/TeleWave.Application/Notifications/TelegramText.cs b/backend/src/TeleWave.Application/Notifications/TelegramText.cs new file mode 100644 index 0000000..f50833a --- /dev/null +++ b/backend/src/TeleWave.Application/Notifications/TelegramText.cs @@ -0,0 +1,12 @@ +namespace TeleWave.Application.Notifications; + +/// +/// Подготовка текста для Telegram. Сообщения уходят с HTML-разметкой (жирный заголовок программы), +/// поэтому названия шоу нужно экранировать: «Tom & Jerry» иначе обрывает сообщение на месте +/// амперсанда, а угловые скобки Telegram примет за свой тег. +/// +public static class TelegramText +{ + public static string Escape(string value) => + value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); +} diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs index e7862cb..8f23779 100644 --- a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs @@ -3,6 +3,7 @@ using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Notifications; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; using TeleWave.Domain.Notifications; using Xunit; @@ -41,6 +42,21 @@ public class TelegramBotTests private static TelegramUpdate Callback(long chatId, string data) => new(2, chatId, null, "viewer", "cb-1", data, 55); + private static ScheduleEntry Program( + Guid channelId, + Guid showId, + DateTimeOffset start, + int minutes + ) => + ScheduleEntry.FromSlot( + channelId, + Guid.NewGuid(), + ScheduleEntryKind.Program, + start, + start.AddMinutes(minutes), + new ScheduleEntryOrigin(showId, 0, null, null, null) + ); + [Fact] public async Task Start_WithoutCode_DoesNotSubscribeAnyone() { @@ -137,7 +153,7 @@ public class TelegramBotTests await seed.SaveChangesAsync(CancellationToken.None); } - var data = $"{channel.Id}:{TelegramNotificationKind.ShowStart}"; + var data = $"sub:{channel.Id}:{TelegramNotificationKind.ShowStart}"; await using (var db = fixture.New()) { @@ -169,6 +185,56 @@ public class TelegramBotTests Assert.Empty(verify.TelegramSubscriptions); } + [Fact] + public async Task Callback_Schedule_SendsUpcomingProgrammes() + { + // Кнопка-заголовок канала показывает программу. Подряд идущие серии одного шоу — это + // один блок: зритель спрашивает «что дальше», а не «в каком порядке лежат файлы». + var fixture = new TestDb(); + var channel = Channel.Create("Мультреалити", "mult", Now); + channel.UpdateSettings(channel.Name, isEnabled: true, null); + var first = Show.Create("Симпсоны", ShowKind.Series); + var second = Show.Create("Футурама", ShowKind.Series); + var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.AddRange(first, second); + seed.TelegramSubscribers.Add(subscriber); + seed.ScheduleEntries.AddRange( + Program(channel.Id, first.Id, Now.AddMinutes(-5), 25), + Program(channel.Id, first.Id, Now.AddMinutes(20), 25), + Program(channel.Id, second.Id, Now.AddMinutes(45), 25) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var api = Substitute.For(); + string? sent = null; + await api.SendMessageAsync( + Arg.Any(), + Arg.Any(), + Arg.Do(text => sent = text), + Arg.Any>?>(), + Arg.Any() + ); + + await using var db = fixture.New(); + await new TelegramBotService(db, api).HandleAsync( + Settings(), + Callback(100, $"epg:{channel.Id}"), + Now, + CancellationToken.None + ); + + Assert.NotNull(sent); + Assert.Contains("Симпсоны", sent, StringComparison.Ordinal); + Assert.Contains("Футурама", sent, StringComparison.Ordinal); + // Две подряд идущие серии Симпсонов дают одну строку, а не две. + Assert.Equal(1, sent!.Split("Симпсоны").Length - 1); + } + [Fact] public async Task Callback_FromUnknownChat_IsRefused() { @@ -178,7 +244,7 @@ public class TelegramBotTests await using var db = fixture.New(); await new TelegramBotService(db, api).HandleAsync( Settings(), - Callback(100, $"{Guid.NewGuid()}:{TelegramNotificationKind.ShowStart}"), + Callback(100, $"sub:{Guid.NewGuid()}:{TelegramNotificationKind.ShowStart}"), Now, CancellationToken.None ); diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index b5679e8..c5d4e47 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -51,11 +51,19 @@ NAT и через прокси, и это единственный вариан ## Меню в чате `/start` с кодом привязывает чат и открывает меню; `/start` без кода — открывает меню уже -привязанному. Меню живёт **одним сообщением**: нажатие кнопки переписывает его на месте, иначе +привязанному. Заголовок канала в меню — это кнопка «программа передач»: ближайшие восемь блоков +в часовом поясе канала, идущее сейчас помечено. Подряд идущие серии одного шоу схлопываются +в один блок — зритель спрашивает «что дальше», а не «в каком порядке лежат файлы». Программа +приходит отдельным сообщением, меню остаётся на месте: иначе после просмотра пришлось бы звать +`/start`, чтобы вернуть кнопки. Меню живёт **одним сообщением**: нажатие кнопки переписывает его на месте, иначе чат заполняется одинаковыми «ваши подписки» после каждого клика. Кнопка — тумблер: повторное нажатие снимает подписку. `/stop` останавливает рассылку, не стирая подписки: вернувшись, зритель получит свои каналы, а не пустой список. +Данные кнопок несут действие: `sub:канал:вид` переключает подписку, `epg:канал` показывает +программу. Действие в первом поле, а не «угадаем по числу частей»: кнопок со временем станет +больше, и разбор по длине строки сломался бы на первой же новой. + ## Границы - Бот не отвечает на произвольные вопросы и не показывает программу передач — только подписки.