Updated the ITelegramApi interface to include a method for deleting messages and modified SendMessageAsync to return the message ID. Enhanced the TelegramBotService to manage message visibility by replacing previous messages with new ones, ensuring a cleaner user experience. Introduced properties in TelegramSubscriber to track the latest menu and notice message IDs. Updated related tests to verify the new message management behavior and adjusted documentation to reflect these changes.
455 lines
18 KiB
C#
455 lines
18 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using TeleWave.Application.Common.Interfaces;
|
|
using TeleWave.Domain.Broadcast;
|
|
using TeleWave.Domain.Notifications;
|
|
|
|
namespace TeleWave.Application.Notifications;
|
|
|
|
/// <summary>
|
|
/// Разговор с ботом: привязка чата к учётке и меню подписок.
|
|
///
|
|
/// Один и тот же код обслуживает оба транспорта — опрос и вебхук приносят одно и то же обновление,
|
|
/// и разводить логику по ним значило бы чинить каждую находку дважды.
|
|
///
|
|
/// Меню живёт одним сообщением: нажатие кнопки переписывает его на месте. Лента из десятка
|
|
/// одинаковых сообщений «ваши подписки» после каждого клика — это не настройка, а мусор в чате.
|
|
/// </summary>
|
|
public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api)
|
|
{
|
|
/// <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,
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>/start КОД</c> — единственный вход: код выдаёт страница эфира авторизованному зрителю,
|
|
/// и только по нему бот узнаёт, кто пришёл. Без кода подписаться нельзя — иначе рассылка
|
|
/// уходила бы любому, кто нашёл бота.
|
|
/// </summary>
|
|
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<TelegramNotificationKind>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Одноразовое сообщение бота: программа передач, подтверждение привязки, отказ. Предыдущее
|
|
/// такое же снимается — три программы на разное время в чате не нужны никому.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Меню подписок: строка на канал, в ней — кнопка на каждый вид оповещения с отметкой.
|
|
/// <paramref name="messageId"/> задан — правим уже отправленное сообщение, а не шлём новое.
|
|
/// </summary>
|
|
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<IReadOnlyList<TelegramButton>>();
|
|
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}"
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Программа канала: что идёт сейчас и что дальше.
|
|
///
|
|
/// Подряд идущие серии одного шоу схлопываются в один блок — зритель спрашивает «что сегодня»,
|
|
/// а не «в каком порядке лежат файлы», и четыре строки «Симпсоны» подряд ответом не являются.
|
|
/// Времена — в часовом поясе канала: сетка задаётся в нём, и переводить их зрителю в UTC
|
|
/// значило бы показывать программу чужого телевизора.
|
|
/// </summary>
|
|
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<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) =>
|
|
dbContext
|
|
.TelegramSubscribers.Include(s => s.Subscriptions)
|
|
.FirstOrDefaultAsync(s => s.ChatId == chatId, cancellationToken);
|
|
|
|
/// <summary>Тексты бота. Собраны в одном месте: их правят чаще, чем логику вокруг них.</summary>
|
|
private static class Texts
|
|
{
|
|
public const string NeedLink =
|
|
"Чтобы получать оповещения, откройте страницу эфира TeleWave и нажмите «Открыть чат с ботом» — "
|
|
+ "ссылка придёт с одноразовым кодом.";
|
|
|
|
public const string BadCode =
|
|
"Код не подошёл: он одноразовый и живёт 15 минут. Нажмите кнопку на странице эфира ещё раз.";
|
|
|
|
public const string Linked = "Готово, чат привязан к вашей учётной записи.";
|
|
|
|
public const string Menu =
|
|
"<b>Оповещения</b>\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 = "Программа ещё не собрана.";
|
|
}
|
|
}
|