Enhance TelegramBotService with schedule handling and subscription toggling
Updated the TelegramBotService to support new actions for managing subscriptions and retrieving channel schedules. Introduced constants for action types and improved the callback data structure for better clarity. Added a method to send channel schedules, consolidating upcoming program information into a user-friendly format. Updated related tests to ensure proper functionality of the new features and adjusted documentation to reflect changes in button actions.
This commit is contained in:
@@ -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;
|
||||
/// </summary>
|
||||
public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api)
|
||||
{
|
||||
/// <summary>Разделитель в callback: идентификатор канала и вид оповещения.</summary>
|
||||
/// <summary>Разделитель полей в callback: действие, канал и (для подписки) вид оповещения.</summary>
|
||||
private const char Separator = ':';
|
||||
|
||||
/// <summary>Переключить подписку: <c>sub:канал:вид</c>.</summary>
|
||||
private const string ToggleAction = "sub";
|
||||
|
||||
/// <summary>Показать программу канала: <c>epg:канал</c>.</summary>
|
||||
private const string ScheduleAction = "epg";
|
||||
|
||||
/// <summary>Сколько программ показывать: экран телефона, а не полные сутки.</summary>
|
||||
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<TelegramNotificationKind>(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<TelegramNotificationKind>(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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Программа канала: что идёт сейчас и что дальше.
|
||||
///
|
||||
/// Подряд идущие серии одного шоу схлопываются в один блок — зритель спрашивает «что сегодня»,
|
||||
/// а не «в каком порядке лежат файлы», и четыре строки «Симпсоны» подряд ответом не являются.
|
||||
/// Времена — в часовом поясе канала: сетка задаётся в нём, и переводить их зрителю в UTC
|
||||
/// значило бы показывать программу чужого телевизора.
|
||||
/// </summary>
|
||||
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<string> { $"<b>{TelegramText.Escape(channel.Name)}</b>" };
|
||||
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<TelegramSubscriber?> 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 = "Программа ещё не собрана.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api)
|
||||
settings,
|
||||
entry.ChannelId,
|
||||
TelegramNotificationKind.ShowStart,
|
||||
$"▶️ <b>{Escape(title)}</b>\nСейчас в эфире",
|
||||
$"▶️ <b>{TelegramText.Escape(title)}</b>\nСейчас в эфире",
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api)
|
||||
settings,
|
||||
entry.ChannelId,
|
||||
TelegramNotificationKind.UpNext,
|
||||
$"🕔 Через {minutes} мин: <b>{Escape(title)}</b>",
|
||||
$"🕔 Через {minutes} мин: <b>{TelegramText.Escape(title)}</b>",
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -201,8 +201,4 @@ public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api)
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// <summary>Экранирование под HTML-разметку Telegram: в названиях шоу встречаются «&» и кавычки.</summary>
|
||||
private static string Escape(string value) =>
|
||||
value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TeleWave.Application.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Подготовка текста для Telegram. Сообщения уходят с HTML-разметкой (жирный заголовок программы),
|
||||
/// поэтому названия шоу нужно экранировать: «Tom & Jerry» иначе обрывает сообщение на месте
|
||||
/// амперсанда, а угловые скобки Telegram примет за свой тег.
|
||||
/// </summary>
|
||||
public static class TelegramText
|
||||
{
|
||||
public static string Escape(string value) =>
|
||||
value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
@@ -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<ITelegramApi>();
|
||||
string? sent = null;
|
||||
await api.SendMessageAsync(
|
||||
Arg.Any<TelegramSettings>(),
|
||||
Arg.Any<long>(),
|
||||
Arg.Do<string>(text => sent = text),
|
||||
Arg.Any<IReadOnlyList<IReadOnlyList<TelegramButton>>?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
@@ -51,11 +51,19 @@ NAT и через прокси, и это единственный вариан
|
||||
## Меню в чате
|
||||
|
||||
`/start` с кодом привязывает чат и открывает меню; `/start` без кода — открывает меню уже
|
||||
привязанному. Меню живёт **одним сообщением**: нажатие кнопки переписывает его на месте, иначе
|
||||
привязанному. Заголовок канала в меню — это кнопка «программа передач»: ближайшие восемь блоков
|
||||
в часовом поясе канала, идущее сейчас помечено. Подряд идущие серии одного шоу схлопываются
|
||||
в один блок — зритель спрашивает «что дальше», а не «в каком порядке лежат файлы». Программа
|
||||
приходит отдельным сообщением, меню остаётся на месте: иначе после просмотра пришлось бы звать
|
||||
`/start`, чтобы вернуть кнопки. Меню живёт **одним сообщением**: нажатие кнопки переписывает его на месте, иначе
|
||||
чат заполняется одинаковыми «ваши подписки» после каждого клика. Кнопка — тумблер: повторное
|
||||
нажатие снимает подписку. `/stop` останавливает рассылку, не стирая подписки: вернувшись, зритель
|
||||
получит свои каналы, а не пустой список.
|
||||
|
||||
Данные кнопок несут действие: `sub:канал:вид` переключает подписку, `epg:канал` показывает
|
||||
программу. Действие в первом поле, а не «угадаем по числу частей»: кнопок со временем станет
|
||||
больше, и разбор по длине строки сломался бы на первой же новой.
|
||||
|
||||
## Границы
|
||||
|
||||
- Бот не отвечает на произвольные вопросы и не показывает программу передач — только подписки.
|
||||
|
||||
Reference in New Issue
Block a user