using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications;
///
/// Разговор с ботом: привязка чата к учётке и меню подписок.
///
/// Один и тот же код обслуживает оба транспорта — опрос и вебхук приносят одно и то же обновление,
/// и разводить логику по ним значило бы чинить каждую находку дважды.
///
/// Меню живёт одним сообщением: нажатие кнопки переписывает его на месте. Лента из десятка
/// одинаковых сообщений «ваши подписки» после каждого клика — это не настройка, а мусор в чате.
///
public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api)
{
/// Разделитель полей в 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,
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);
}
///
/// /start КОД — единственный вход: код выдаёт страница эфира авторизованному зрителю,
/// и только по нему бот узнаёт, кто пришёл. Без кода подписаться нельзя — иначе рассылка
/// уходила бы любому, кто нашёл бота.
///
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(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);
}
///
/// Одноразовое сообщение бота: программа передач, подтверждение привязки, отказ. Предыдущее
/// такое же снимается — три программы на разное время в чате не нужны никому.
///
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;
///
/// Меню подписок: строка на канал, в ней — кнопка на каждый вид оповещения с отметкой.
/// задан — правим уже отправленное сообщение, а не шлём новое.
///
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>();
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}"
);
}
///
/// Программа канала: что идёт сейчас и что дальше.
///
/// Подряд идущие серии одного шоу схлопываются в один блок — зритель спрашивает «что сегодня»,
/// а не «в каком порядке лежат файлы», и четыре строки «Симпсоны» подряд ответом не являются.
/// Времена — в часовом поясе канала: сетка задаётся в нём, и переводить их зрителю в UTC
/// значило бы показывать программу чужого телевизора.
///
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 { $"{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) =>
dbContext
.TelegramSubscribers.Include(s => s.Subscriptions)
.FirstOrDefaultAsync(s => s.ChatId == chatId, cancellationToken);
/// Тексты бота. Собраны в одном месте: их правят чаще, чем логику вокруг них.
private static class Texts
{
public const string NeedLink =
"Чтобы получать оповещения, откройте страницу эфира TeleWave и нажмите «Открыть чат с ботом» — "
+ "ссылка придёт с одноразовым кодом.";
public const string BadCode =
"Код не подошёл: он одноразовый и живёт 15 минут. Нажмите кнопку на странице эфира ещё раз.";
public const string Linked = "Готово, чат привязан к вашей учётной записи.";
public const string Menu =
"Оповещения\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 = "Программа ещё не собрана.";
}
}