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:канал` показывает
+программу. Действие в первом поле, а не «угадаем по числу частей»: кнопок со временем станет
+больше, и разбор по длине строки сломался бы на первой же новой.
+
## Границы
- Бот не отвечает на произвольные вопросы и не показывает программу передач — только подписки.