diff --git a/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs b/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs index f3bc59a..237e9d0 100644 --- a/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs +++ b/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs @@ -37,20 +37,36 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api) ? marker : now - MaxCatchUp; + // Имена каналов — одним запросом на тик: их десятки, а оповещений за тик единицы, и ходить + // за названием по каждому значило бы столько же одинаковых запросов. + var channels = await dbContext + .Channels.AsNoTracking() + .ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken); + var sent = 0; - sent += await NotifyStartedAsync(settings, from, now, cancellationToken); - sent += await NotifyUpNextAsync(settings, from, now, cancellationToken); + sent += await NotifyStartedAsync(settings, channels, from, now, cancellationToken); + sent += await NotifyUpNextAsync(settings, channels, from, now, cancellationToken); settings.MarkNotifiedUntil(now); return sent; } + /// + /// Подпись канала под заголовком программы. Каналов у зрителя несколько, а «Сейчас в эфире» + /// без названия не отвечает на вопрос «где» — по такому сообщению непонятно, куда переключаться. + /// + private static string ChannelLine(IReadOnlyDictionary channels, Guid channelId) => + channels.GetValueOrDefault(channelId) is { } name + ? $" · {TelegramText.Escape(name)}" + : string.Empty; + /// /// Начавшиеся программы. Оповещаем о смене шоу, а не о каждой записи: четыре серии подряд — /// это одна программа в глазах зрителя, и четыре сообщения о ней читаются как спам. /// private async Task NotifyStartedAsync( TelegramSettings settings, + IReadOnlyDictionary channels, DateTimeOffset from, DateTimeOffset now, CancellationToken cancellationToken @@ -81,7 +97,7 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api) settings, entry.ChannelId, TelegramNotificationKind.ShowStart, - $"▶️ {TelegramText.Escape(title)}\nСейчас в эфире", + $"▶️ {TelegramText.Escape(title)}\nСейчас в эфире{ChannelLine(channels, entry.ChannelId)}", cancellationToken ); } @@ -92,6 +108,7 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api) /// Что начнётся через — предупреждаем один раз, в своё окно. private async Task NotifyUpNextAsync( TelegramSettings settings, + IReadOnlyDictionary channels, DateTimeOffset from, DateTimeOffset now, CancellationToken cancellationToken @@ -123,7 +140,7 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api) settings, entry.ChannelId, TelegramNotificationKind.UpNext, - $"🕔 Через {minutes} мин: {TelegramText.Escape(title)}", + $"🕔 {TelegramText.Escape(title)}\nЧерез {minutes} мин{ChannelLine(channels, entry.ChannelId)}", cancellationToken ); } diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs index 382235c..0edb2b3 100644 --- a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs @@ -136,9 +136,11 @@ public class TelegramNotifierTests .SendMessageAsync( Arg.Any(), 100, + // Канал назван в самом сообщении: их у зрителя несколько, и «сейчас в эфире» + // без названия не отвечает на вопрос, куда переключаться. // Предикат NSubstitute принимает аргумент как допускающий null — само сообщение // объявлено обязательным, потому и разыменовываем без проверки. - Arg.Is(s => s!.Contains("Футурама")), + Arg.Is(s => s!.Contains("Футурама") && s.Contains("Мультреалити")), null, Arg.Any() ); @@ -147,6 +149,50 @@ public class TelegramNotifierTests Assert.Equal(Now, settings.NotifiedUntil); } + [Fact] + public async Task Notifies_UpNext_TellsWhenAndWhere() + { + var fixture = new TestDb(); + var channel = Channel.Create("Мультреалити", "mult", Now); + var soon = Show.Create("Гриффины", ShowKind.Series); + + var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now); + subscriber.Toggle(channel.Id, TelegramNotificationKind.UpNext); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.Add(soon); + seed.TelegramSubscribers.Add(subscriber); + seed.ScheduleEntries.Add(Program(channel.Id, soon.Id, Now.AddMinutes(4), 25)); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var api = Substitute.For(); + var settings = Settings(); + // Окно предупреждения — свои пять минут: разобранное кончается на Now, а смотрим до Now+5. + settings.MarkNotifiedUntil(Now.AddMinutes(-5)); + + await using var db = fixture.New(); + var sent = await new TelegramNotifier(db, api).RunAsync( + settings, + Now, + CancellationToken.None + ); + + Assert.Equal(1, sent); + await api.Received(1) + .SendMessageAsync( + Arg.Any(), + 100, + Arg.Is(s => + s!.Contains("Гриффины") && s.Contains("4 мин") && s.Contains("Мультреалити") + ), + null, + Arg.Any() + ); + } + [Fact] public async Task Notifies_ReplacingItsPreviousAlert() { diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 02b7a66..1758f98 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -43,6 +43,9 @@ NAT и через прокси, и это единственный вариан в глазах зрителя, и четыре сообщения о ней читаются как спам. - **Скоро в эфире** — за пять минут до следующей программы. +Оба называют канал: подписок у зрителя несколько, и «Сейчас в эфире» без названия не отвечает +на вопрос «где» — по такому сообщению непонятно, куда переключаться. + Граница разобранного (`TelegramSettings.NotifiedUntil`) двигается на каждом тике, даже когда подписчиков нет: иначе первый же подписавшийся получил бы пачку сообщений про всё, что успело пройти. Догон ограничен пятнадцатью минутами — после долгого простоя бот не станет пересказывать