Add Telegram bot integration and related features
ci / build-backend (push) Successful in 2m38s
ci / build-frontend (push) Successful in 1m16s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 3m48s

Implemented Telegram bot functionality, including settings management, subscriber tracking, and link generation for user interaction. Updated the backend to support new Telegram-related services and database entities. Enhanced the frontend to display Telegram options and allow users to open a chat with the bot. Localization strings were added for both English and Russian to support the new features. This integration aims to improve user engagement through Telegram notifications and interactions.
This commit is contained in:
Leonid Pershin
2026-07-31 07:58:18 +03:00
parent bd3ced3637
commit 2b03e43a83
53 changed files with 6566 additions and 2 deletions
@@ -0,0 +1,173 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Notifications;
using TeleWave.Application.Notifications.Link;
using TeleWave.Application.Notifications.Settings;
using TeleWave.Application.Notifications.Subscribers;
using TeleWave.Domain.Notifications;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
/// </summary>
public static class TelegramEndpoints
{
/// <summary>Заголовок, которым Telegram подтверждает, что вызов вебхука — его.</summary>
private const string SecretHeader = "X-Telegram-Bot-Api-Secret-Token";
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/telegram").RequireAuthorization("admin");
admin.MapGet("/settings", GetSettings).Produces<TelegramSettingsDto>();
admin.MapPut("/settings", UpdateSettings).Produces<TelegramSettingsDto>();
admin
.MapGet("/subscribers", ListSubscribers)
.Produces<IReadOnlyList<TelegramSubscriberDto>>();
// Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
// кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces<TelegramStatusDto>();
// Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
app.MapPost("/api/telegram/link", IssueLink)
.RequireAuthorization()
.Produces<TelegramLinkDto>();
// Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
// только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
return app;
}
private static async Task<IResult> Status(
IAppDbContext dbContext,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.TelegramSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
var link = settings is null ? null : TelegramSettingsService.BotLink(settings);
return Results.Ok(new TelegramStatusDto(link is not null, settings?.BotUsername));
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
TelegramSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTelegramSettingsCommand(
body.IsEnabled,
body.Transport,
body.BotToken,
body.WebhookUrl,
body.ProxyKind,
body.ProxyHost,
body.ProxyPort,
body.ProxyUsername,
body.ProxyPassword
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ListSubscribers(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> IssueLink(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
return result.ToHttpResult();
}
/// <summary>
/// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
/// разговор один.
///
/// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
/// кривое обновление зациклило бы вебхук.
/// </summary>
private static async Task<IResult> Webhook(
HttpRequest request,
IAppDbContext dbContext,
TelegramBotService bot,
ITelegramApi api,
ILoggerFactory loggers,
CancellationToken cancellationToken
)
{
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
if (
settings is null
|| !settings.IsOperational
|| settings.Transport != TelegramTransport.Webhook
)
return Results.NotFound();
var secret = request.Headers[SecretHeader].ToString();
if (string.IsNullOrWhiteSpace(settings.WebhookSecret) || secret != settings.WebhookSecret)
return Results.Unauthorized();
var update = await api.ReadUpdateAsync(request.Body, cancellationToken);
if (update is null)
return Results.Ok();
var logger = loggers.CreateLogger("Telegram.Webhook");
try
{
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
settings.MarkContact(DateTimeOffset.UtcNow);
settings.AdvanceUpdateId(update.UpdateId);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
}
return Results.Ok();
}
}
/// <summary>Что зрительской части нужно знать о боте: настроен ли и как он называется.</summary>
public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
/// <summary>Тело правки настроек: секреты необязательны — пусто значит «не менять».</summary>
public sealed record TelegramSettingsBody(
bool IsEnabled,
TelegramTransport Transport,
string? BotToken,
string? WebhookUrl,
TelegramProxyKind ProxyKind,
string? ProxyHost,
int? ProxyPort,
string? ProxyUsername,
string? ProxyPassword
);
+1
View File
@@ -133,6 +133,7 @@ app.MapTemplateEndpoints();
app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapTelegramEndpoints();
app.MapStreamingEndpoints();
app.MapIptvEndpoints();
app.MapMaintenanceEndpoints();
@@ -5,6 +5,7 @@ using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Notifications;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
@@ -32,6 +33,10 @@ public interface IAppDbContext
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTemplate> BumperTemplates { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<TelegramSettings> TelegramSettings { get; }
DbSet<TelegramSubscriber> TelegramSubscribers { get; }
DbSet<TelegramSubscription> TelegramSubscriptions { get; }
DbSet<TelegramLinkCode> TelegramLinkCodes { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
@@ -0,0 +1,78 @@
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Кнопка инлайн-клавиатуры: подпись и что вернётся в callback.</summary>
public sealed record TelegramButton(string Text, string CallbackData);
/// <summary>Обновление от Telegram — только то, что нам нужно от его модели.</summary>
public sealed record TelegramUpdate(
long UpdateId,
long? ChatId,
string? Text,
string? From,
/// <summary>Идентификатор нажатия: на него надо ответить, иначе кнопка «крутится» у зрителя.</summary>
string? CallbackId,
string? CallbackData,
long? CallbackMessageId
);
/// <summary>Кто мы для Telegram — ответ getMe, по нему проверяется токен.</summary>
public sealed record TelegramBotInfo(long Id, string Username);
/// <summary>
/// Порт к Bot API. Держим ровно те вызовы, что нужны боту расписания: узнать себя, получить
/// обновления, ответить сообщением с кнопками и переключить вебхук.
///
/// Настройки (токен, прокси) реализация читает сама на каждый вызов: админ меняет их в рантайме,
/// и держать клиента, собранного на старте, значило бы требовать перезапуск контейнера.
/// </summary>
public interface ITelegramApi
{
Task<TelegramBotInfo?> GetMeAsync(
TelegramSettings settings,
CancellationToken cancellationToken
);
Task<IReadOnlyList<TelegramUpdate>> GetUpdatesAsync(
TelegramSettings settings,
long offset,
CancellationToken cancellationToken
);
Task SendMessageAsync(
TelegramSettings settings,
long chatId,
string text,
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
CancellationToken cancellationToken
);
/// <summary>Переписывает уже отправленное сообщение — так меню подписок обновляется на месте.</summary>
Task EditMessageAsync(
TelegramSettings settings,
long chatId,
long messageId,
string text,
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
CancellationToken cancellationToken
);
/// <summary>Гасит «часики» на нажатой кнопке; текст — всплывающая подсказка.</summary>
Task AnswerCallbackAsync(
TelegramSettings settings,
string callbackId,
string? text,
CancellationToken cancellationToken
);
/// <summary>
/// Разбирает тело вебхука в то же обновление, что приходит опросом: разговор с ботом один,
/// и знать про две модели ему незачем. null — тело не разобралось.
/// </summary>
Task<TelegramUpdate?> ReadUpdateAsync(Stream body, CancellationToken cancellationToken);
Task SetWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
Task DeleteWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
}
@@ -12,6 +12,8 @@ using TeleWave.Application.Media;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Metadata;
using TeleWave.Application.Notifications;
using TeleWave.Application.Notifications.Settings;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Programming.Planning;
@@ -66,6 +68,9 @@ public static class DependencyInjection
services.AddScoped<GridScheduleGenerator>();
services.AddScoped<ChannelDebugCollector>();
services.AddScoped<LibraryDebugCollector>();
services.AddScoped<TelegramSettingsService>();
services.AddScoped<TelegramBotService>();
services.AddScoped<TelegramNotifier>();
return services;
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Notifications.Link;
/// <summary>Выдать текущему зрителю ссылку на чат с ботом и одноразовым кодом привязки.</summary>
public sealed record IssueTelegramLinkCommand : ICommand<Result<TelegramLinkDto>>;
@@ -0,0 +1,55 @@
using System.Security.Cryptography;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Notifications.Settings;
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications.Link;
/// <summary>
/// Выдаёт одноразовый код привязки и собирает из него ссылку на чат.
///
/// Код — единственный способ познакомить чат с учёткой: бот не спрашивает логин и не верит
/// на слово, а ссылка уходит только авторизованному зрителю. Старые неиспользованные коды
/// того же пользователя гасим: две живые ссылки на один аккаунт — это две двери вместо одной.
/// </summary>
public sealed class IssueTelegramLinkCommandHandler(
IAppDbContext dbContext,
TelegramSettingsService settingsService,
ICurrentUser currentUser
) : ICommandHandler<IssueTelegramLinkCommand, Result<TelegramLinkDto>>
{
public async Task<Result<TelegramLinkDto>> Handle(
IssueTelegramLinkCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TelegramLinkDto>(TelegramErrors.NotConfigured);
var settings = await settingsService.ReadAsync(cancellationToken);
if (settings is null || TelegramSettingsService.BotLink(settings) is not { } botLink)
return Result.Failure<TelegramLinkDto>(TelegramErrors.NotConfigured);
var now = DateTimeOffset.UtcNow;
var stale = await dbContext
.TelegramLinkCodes.Where(c => c.UserId == userId && c.UsedAt == null)
.ToListAsync(cancellationToken);
foreach (var code in stale)
code.MarkUsed(now);
// Base64url без padding: код едет в ссылке, и «=» с «+» там только мешают.
var value = Convert
.ToBase64String(RandomNumberGenerator.GetBytes(18))
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
var link = TelegramLinkCode.Issue(value, userId, now);
dbContext.TelegramLinkCodes.Add(link);
return Result.Success(new TelegramLinkDto($"{botLink}?start={value}", link.ExpiresAt));
}
}
@@ -0,0 +1,5 @@
namespace TeleWave.Application.Notifications.Link;
/// <param name="Url">Готовая ссылка t.me/бот?start=код — её и открывает кнопка на странице эфира.</param>
/// <param name="ExpiresAt">Когда код сгорит: ссылка живёт минуты, а не вечно.</param>
public sealed record TelegramLinkDto(string Url, DateTimeOffset ExpiresAt);
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Notifications.Settings;
public sealed record GetTelegramSettingsQuery : IQuery<Result<TelegramSettingsDto>>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Notifications.Settings;
public sealed class GetTelegramSettingsQueryHandler(TelegramSettingsService settings)
: IQueryHandler<GetTelegramSettingsQuery, Result<TelegramSettingsDto>>
{
public async Task<Result<TelegramSettingsDto>> Handle(
GetTelegramSettingsQuery query,
CancellationToken cancellationToken
)
{
// Читаем без создания строки: GET не должен писать в базу, а умолчания те же самые.
var stored = await settings.ReadAsync(cancellationToken);
var current = stored ?? Domain.Notifications.TelegramSettings.CreateDefault();
return Result.Success(await settings.ToDtoAsync(current, cancellationToken));
}
}
@@ -0,0 +1,25 @@
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications.Settings;
/// <summary>
/// Настройки бота для админки. Секретов здесь нет: токен и пароль прокси наружу не отдаются,
/// вместо них признак «задан» — иначе они утекали бы в каждый GET и в отладочные дампы.
/// </summary>
public sealed record TelegramSettingsDto(
bool IsEnabled,
bool HasToken,
string? BotUsername,
/// <summary>Ссылка на чат с ботом; null — бот не настроен, и кнопку зрителю показывать нечем.</summary>
string? BotLink,
TelegramTransport Transport,
string? WebhookUrl,
TelegramProxyKind ProxyKind,
string? ProxyHost,
int? ProxyPort,
string? ProxyUsername,
bool HasProxyPassword,
DateTimeOffset? LastContactAt,
string? LastError,
int Subscribers
);
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications.Settings;
/// <summary>
/// Доступ к единственной строке настроек бота и сборка её представления для админки.
///
/// Отдельный тип, а не метод хендлера: настройки читают все — оба транспорта, рассылка, выдача
/// ссылки зрителю, — и «создать, если строки ещё нет» должно жить в одном месте.
/// </summary>
public sealed class TelegramSettingsService(IAppDbContext dbContext)
{
/// <summary>Настройки для записи. Первое обращение заводит строку с умолчаниями.</summary>
public async Task<TelegramSettings> GetAsync(CancellationToken cancellationToken)
{
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
if (settings is not null)
return settings;
settings = TelegramSettings.CreateDefault();
dbContext.TelegramSettings.Add(settings);
return settings;
}
/// <summary>Настройки только для чтения — для фоновых служб, которым писать нечего.</summary>
public Task<TelegramSettings?> ReadAsync(CancellationToken cancellationToken) =>
dbContext.TelegramSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
public async Task<TelegramSettingsDto> ToDtoAsync(
TelegramSettings settings,
CancellationToken cancellationToken
)
{
var subscribers = await dbContext.TelegramSubscribers.CountAsync(
s => !s.IsStopped,
cancellationToken
);
return new TelegramSettingsDto(
settings.IsEnabled,
!string.IsNullOrWhiteSpace(settings.BotToken),
settings.BotUsername,
BotLink(settings),
settings.Transport,
settings.WebhookUrl,
settings.ProxyKind,
settings.ProxyHost,
settings.ProxyPort,
settings.ProxyUsername,
!string.IsNullOrWhiteSpace(settings.ProxyPassword),
settings.LastContactAt,
settings.LastError,
subscribers
);
}
/// <summary>
/// Ссылка на чат. Без имени бота её не собрать, а имя приходит от Telegram при проверке
/// токена — поэтому кнопка у зрителя появляется только после успешного соединения.
/// </summary>
public static string? BotLink(TelegramSettings settings) =>
string.IsNullOrWhiteSpace(settings.BotUsername) || !settings.IsEnabled
? null
: $"https://t.me/{settings.BotUsername}";
}
@@ -0,0 +1,21 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications.Settings;
/// <summary>
/// Правка настроек бота. <paramref name="BotToken"/> и <paramref name="ProxyPassword"/> пустые
/// означают «оставить как есть»: наружу они не отдаются, и форма вернуть их не может.
/// </summary>
public sealed record UpdateTelegramSettingsCommand(
bool IsEnabled,
TelegramTransport Transport,
string? BotToken,
string? WebhookUrl,
TelegramProxyKind ProxyKind,
string? ProxyHost,
int? ProxyPort,
string? ProxyUsername,
string? ProxyPassword
) : ICommand<Result<TelegramSettingsDto>>;
@@ -0,0 +1,113 @@
using System.Security.Cryptography;
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications.Settings;
/// <summary>
/// Сохраняет настройки бота и сразу проверяет их делом: спрашивает у Telegram, кто мы,
/// и переключает вебхук.
///
/// Проверка на сохранении, а не отдельной кнопкой, намеренно: неверный токен или недоступный
/// прокси иначе всплыли бы через сутки молчания бота, а не в момент правки. Имя бота приходит
/// из того же ответа — без него не собрать ссылку на чат для зрителя.
/// </summary>
public sealed class UpdateTelegramSettingsCommandHandler(
IAppDbContext dbContext,
TelegramSettingsService settingsService,
ITelegramApi api
) : ICommandHandler<UpdateTelegramSettingsCommand, Result<TelegramSettingsDto>>
{
public async Task<Result<TelegramSettingsDto>> Handle(
UpdateTelegramSettingsCommand command,
CancellationToken cancellationToken
)
{
if (
command.Transport == TelegramTransport.Webhook
&& string.IsNullOrWhiteSpace(command.WebhookUrl)
)
return Result.Failure<TelegramSettingsDto>(TelegramErrors.WebhookUrlRequired);
if (
command.ProxyKind != TelegramProxyKind.None
&& string.IsNullOrWhiteSpace(command.ProxyHost)
)
return Result.Failure<TelegramSettingsDto>(TelegramErrors.ProxyHostRequired);
var settings = await settingsService.GetAsync(cancellationToken);
settings.Update(
new TelegramSettingsUpdate(
command.IsEnabled,
command.Transport,
command.BotToken,
command.WebhookUrl,
command.ProxyKind,
command.ProxyHost,
command.ProxyPort,
command.ProxyUsername,
command.ProxyPassword
)
);
if (command.IsEnabled && string.IsNullOrWhiteSpace(settings.BotToken))
return Result.Failure<TelegramSettingsDto>(TelegramErrors.NotConfigured);
if (settings.IsOperational)
{
var check = await ConnectAsync(settings, cancellationToken);
if (check is { } error)
return Result.Failure<TelegramSettingsDto>(error);
}
return Result.Success(await settingsService.ToDtoAsync(settings, cancellationToken));
}
/// <summary>
/// Проверяет связь и приводит режим доставки в соответствие настройке. Вебхук снимается явно:
/// пока он стоит, Telegram не отдаёт обновления опросом, и переключение режима без этого
/// оставило бы бота немым.
/// </summary>
private async Task<Error?> ConnectAsync(
TelegramSettings settings,
CancellationToken cancellationToken
)
{
try
{
var me = await api.GetMeAsync(settings, cancellationToken);
if (me is null)
return TelegramErrors.NotConfigured;
settings.SetBotUsername(me.Username);
if (settings.Transport == TelegramTransport.Webhook)
{
// Секрет генерируем при первой установке: он подтверждает, что вызов пришёл
// от Telegram, а не от того, кто узнал адрес.
if (string.IsNullOrWhiteSpace(settings.WebhookSecret))
settings.SetWebhookSecret(
Convert.ToHexString(RandomNumberGenerator.GetBytes(24))
);
await api.SetWebhookAsync(settings, cancellationToken);
}
else
{
await api.DeleteWebhookAsync(settings, cancellationToken);
}
settings.MarkContact(DateTimeOffset.UtcNow);
return null;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
settings.MarkError(exception.Message);
// Настройки при этом сохраняются: админ должен видеть, что именно он ввёл, и поправить
// одно поле, а не набирать форму заново.
await dbContext.SaveChangesAsync(cancellationToken);
return TelegramErrors.Unreachable(exception.Message);
}
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Notifications.Subscribers;
public sealed record ListTelegramSubscribersQuery
: IQuery<Result<IReadOnlyList<TelegramSubscriberDto>>>;
@@ -0,0 +1,63 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Notifications.Subscribers;
/// <summary>
/// Кто подписан и на что. Нужен не ради красоты: рассылка уходит в чужие чаты, и админ должен
/// видеть список — иначе «бот кому-то что-то шлёт» проверяется только по логам Telegram.
/// </summary>
public sealed class ListTelegramSubscribersQueryHandler(
IAppDbContext dbContext,
IIdentityService identity
) : IQueryHandler<ListTelegramSubscribersQuery, Result<IReadOnlyList<TelegramSubscriberDto>>>
{
public async Task<Result<IReadOnlyList<TelegramSubscriberDto>>> Handle(
ListTelegramSubscribersQuery query,
CancellationToken cancellationToken
)
{
var subscribers = await dbContext
.TelegramSubscribers.AsNoTracking()
.Include(s => s.Subscriptions)
.OrderByDescending(s => s.LastSeenAt ?? s.CreatedAt)
.ToListAsync(cancellationToken);
var channelNames = await dbContext
.Channels.AsNoTracking()
.ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken);
// Имена пользователей — через порт: Identity живёт в инфраструктуре, и таблицы его
// прикладной слой не видит.
var userNames = new Dictionary<Guid, string>();
foreach (var userId in subscribers.Select(s => s.UserId).Distinct())
{
if (await identity.GetProfileAsync(userId, cancellationToken) is { } profile)
userNames[userId] = profile.UserName;
}
var result = subscribers
.Select(s => new TelegramSubscriberDto(
s.Id,
s.ChatId,
s.DisplayName,
userNames.GetValueOrDefault(s.UserId),
s.IsStopped,
s.CreatedAt,
s.LastSeenAt,
s.Subscriptions.GroupBy(x => x.ChannelId)
.Select(g => new TelegramSubscriptionDto(
g.Key,
// Канал могли удалить — подписки на него остаются висеть, и это надо видеть.
channelNames.GetValueOrDefault(g.Key) ?? "—",
g.Select(x => x.Kind.ToString()).OrderBy(x => x).ToList()
))
.ToList()
))
.ToList();
return Result.Success<IReadOnlyList<TelegramSubscriberDto>>(result);
}
}
@@ -0,0 +1,19 @@
namespace TeleWave.Application.Notifications.Subscribers;
/// <param name="Channels">На что подписан: имя канала и виды оповещений по нему.</param>
public sealed record TelegramSubscriberDto(
Guid Id,
long ChatId,
string? DisplayName,
string? UserName,
bool IsStopped,
DateTimeOffset CreatedAt,
DateTimeOffset? LastSeenAt,
IReadOnlyList<TelegramSubscriptionDto> Channels
);
public sealed record TelegramSubscriptionDto(
Guid ChannelId,
string ChannelName,
IReadOnlyList<string> Kinds
);
@@ -0,0 +1,299 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
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 = ':';
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 api.SendMessageAsync(settings, chatId, Texts.NeedLink, null, cancellationToken);
else
await ShowMenuAsync(settings, subscriber, chatId, null, 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 api.SendMessageAsync(
settings,
chatId,
Texts.NeedLink,
null,
cancellationToken
);
return;
}
subscriber.Resume();
subscriber.Touch(now);
await dbContext.SaveChangesAsync(cancellationToken);
await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken);
return;
}
var link = await dbContext.TelegramLinkCodes.FirstOrDefaultAsync(
c => c.Code == code,
cancellationToken
);
if (link is null || !link.IsUsable(now))
{
await api.SendMessageAsync(settings, chatId, Texts.BadCode, null, 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 dbContext.SaveChangesAsync(cancellationToken);
await api.SendMessageAsync(settings, chatId, Texts.Linked, null, cancellationToken);
await ShowMenuAsync(settings, subscriber, chatId, null, 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 dbContext.SaveChangesAsync(cancellationToken);
await api.SendMessageAsync(settings, chatId, Texts.Stopped, null, 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[0], out var channelId)
|| !Enum.TryParse<TelegramNotificationKind>(parts[1], out var kind)
)
{
await AnswerAsync(settings, update, null, cancellationToken);
return;
}
var known = await dbContext.Channels.AnyAsync(c => c.Id == channelId, cancellationToken);
if (!known)
{
await AnswerAsync(settings, update, Texts.ChannelGone, 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
);
}
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 api.SendMessageAsync(settings, chatId, Texts.NoChannels, null, cancellationToken);
return;
}
var keyboard = new List<IReadOnlyList<TelegramButton>>();
foreach (var channel in channels)
{
keyboard.Add([
new TelegramButton($"— {channel.Name} —", $"{channel.Id}{Separator}none"),
]);
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
);
else
await api.SendMessageAsync(settings, chatId, Texts.Menu, keyboard, cancellationToken);
}
private static TelegramButton Button(
TelegramSubscriber subscriber,
Guid channelId,
TelegramNotificationKind kind,
string label
)
{
var on = subscriber.IsSubscribed(channelId, kind);
return new TelegramButton($"{(on ? "" : "")} {label}", $"{channelId}{Separator}{kind}");
}
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"
+ "Остановить всё — командой /stop.";
public const string NoChannels = "Пока нет ни одного включённого канала.";
public const string Stopped = "Оповещения остановлены. Вернуть — командой /start.";
public const string Subscribed = "Включено";
public const string Unsubscribed = "Выключено";
public const string ChannelGone = "Канала больше нет";
}
}
@@ -0,0 +1,25 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Notifications;
public static class TelegramErrors
{
public static readonly Error NotConfigured = Error.Validation(
"Telegram.NotConfigured",
"Бот не настроен: укажите токен от @BotFather."
);
public static readonly Error WebhookUrlRequired = Error.Validation(
"Telegram.WebhookUrlRequired",
"Для режима вебхука нужен публичный HTTPS-адрес."
);
public static readonly Error ProxyHostRequired = Error.Validation(
"Telegram.ProxyHostRequired",
"Для прокси нужен адрес и порт."
);
/// <summary>Telegram ответил отказом — текст его же, он объясняет причину лучше нашего.</summary>
public static Error Unreachable(string details) =>
Error.Validation("Telegram.Unreachable", $"Telegram не ответил: {details}");
}
@@ -0,0 +1,208 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications;
/// <summary>
/// Рассылка по ленте: «в эфире началось» и «скоро начнётся».
///
/// Считается по материализованному расписанию, а не по факту раздачи: лента и есть эфир, а ждать
/// подтверждения от плеера не от кого — зрителей может не быть вовсе, а оповещение всё равно нужно.
///
/// Граница разобранного (<see cref="TelegramSettings.NotifiedUntil"/>) двигается всегда, даже когда
/// подписчиков нет: иначе первый же подписавшийся получил бы пачку сообщений про всё, что успело
/// пройти с момента настройки бота.
/// </summary>
public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api)
{
/// <summary>За сколько предупреждать о следующей программе.</summary>
public static readonly TimeSpan UpNextLead = TimeSpan.FromMinutes(5);
/// <summary>
/// Насколько далеко в прошлое смотреть при первом заходе. Без потолка перезапуск после долгого
/// простоя разослал бы «началось» про весь вчерашний эфир.
/// </summary>
private static readonly TimeSpan MaxCatchUp = TimeSpan.FromMinutes(15);
public async Task<int> RunAsync(
TelegramSettings settings,
DateTimeOffset now,
CancellationToken cancellationToken
)
{
var from =
settings.NotifiedUntil is { } marker && now - marker < MaxCatchUp
? marker
: now - MaxCatchUp;
var sent = 0;
sent += await NotifyStartedAsync(settings, from, now, cancellationToken);
sent += await NotifyUpNextAsync(settings, from, now, cancellationToken);
settings.MarkNotifiedUntil(now);
return sent;
}
/// <summary>
/// Начавшиеся программы. Оповещаем о смене шоу, а не о каждой записи: четыре серии подряд —
/// это одна программа в глазах зрителя, и четыре сообщения о ней читаются как спам.
/// </summary>
private async Task<int> NotifyStartedAsync(
TelegramSettings settings,
DateTimeOffset from,
DateTimeOffset now,
CancellationToken cancellationToken
)
{
var started = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& e.StartsAtUtc > from
&& e.StartsAtUtc <= now
)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
var sent = 0;
foreach (var entry in started)
{
if (!await IsShowChangeAsync(entry, cancellationToken))
continue;
var title = await ShowNameAsync(entry.ShowId!.Value, cancellationToken);
if (title is null)
continue;
sent += await SendAsync(
settings,
entry.ChannelId,
TelegramNotificationKind.ShowStart,
$"▶️ <b>{Escape(title)}</b>\nСейчас в эфире",
cancellationToken
);
}
return sent;
}
/// <summary>Что начнётся через <see cref="UpNextLead"/> — предупреждаем один раз, в своё окно.</summary>
private async Task<int> NotifyUpNextAsync(
TelegramSettings settings,
DateTimeOffset from,
DateTimeOffset now,
CancellationToken cancellationToken
)
{
var soon = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& e.StartsAtUtc > from + UpNextLead
&& e.StartsAtUtc <= now + UpNextLead
)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
var sent = 0;
foreach (var entry in soon)
{
if (!await IsShowChangeAsync(entry, cancellationToken))
continue;
var title = await ShowNameAsync(entry.ShowId!.Value, cancellationToken);
if (title is null)
continue;
var minutes = Math.Max(1, (int)Math.Round((entry.StartsAtUtc - now).TotalMinutes));
sent += await SendAsync(
settings,
entry.ChannelId,
TelegramNotificationKind.UpNext,
$"🕔 Через {minutes} мин: <b>{Escape(title)}</b>",
cancellationToken
);
}
return sent;
}
/// <summary>
/// Сменилось ли шоу этой записью. Предыдущая запись канала берётся из ленты: врезки и заставки
/// в счёт не идут — реклама между сериями не делает следующую серию новой программой.
/// </summary>
private async Task<bool> IsShowChangeAsync(
ScheduleEntry entry,
CancellationToken cancellationToken
)
{
var previousShowId = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == entry.ChannelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& e.StartsAtUtc < entry.StartsAtUtc
)
.OrderByDescending(e => e.StartsAtUtc)
.Select(e => e.ShowId)
.FirstOrDefaultAsync(cancellationToken);
return previousShowId != entry.ShowId;
}
private Task<string?> ShowNameAsync(Guid showId, CancellationToken cancellationToken) =>
dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken);
/// <summary>Шлёт всем, кто подписан на этот канал и вид. Возвращает число доставленных сообщений.</summary>
private async Task<int> SendAsync(
TelegramSettings settings,
Guid channelId,
TelegramNotificationKind kind,
string text,
CancellationToken cancellationToken
)
{
var chats = await (
from subscription in dbContext.TelegramSubscriptions
join subscriber in dbContext.TelegramSubscribers
on subscription.SubscriberId equals subscriber.Id
where
subscription.ChannelId == channelId
&& subscription.Kind == kind
&& !subscriber.IsStopped
select subscriber.ChatId
)
.Distinct()
.ToListAsync(cancellationToken);
var sent = 0;
foreach (var chatId in chats)
{
// Один недоступный чат (бота заблокировали) не должен срывать рассылку остальным.
try
{
await api.SendMessageAsync(settings, chatId, text, null, cancellationToken);
sent++;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
settings.MarkError(exception.Message);
}
}
return sent;
}
/// <summary>Экранирование под HTML-разметку Telegram: в названиях шоу встречаются «&» и кавычки.</summary>
private static string Escape(string value) =>
value.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
@@ -0,0 +1,37 @@
namespace TeleWave.Domain.Notifications;
/// <summary>
/// Одноразовый код привязки чата к учётке. Зритель нажимает «открыть чат» на странице эфира,
/// код уезжает в ссылку <c>t.me/bot?start=КОД</c>, и бот по нему узнаёт, кто пришёл.
///
/// Живёт минуты и сгорает при первом использовании: ссылка попадает в историю браузера и в чат,
/// а вечный код означал бы вечный пропуск к чужим оповещениям.
/// </summary>
public class TelegramLinkCode
{
/// <summary>Сколько код действителен.</summary>
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
public Guid Id { get; private set; }
public string Code { get; private set; } = string.Empty;
public Guid UserId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset ExpiresAt { get; private set; }
public DateTimeOffset? UsedAt { get; private set; }
private TelegramLinkCode() { }
public static TelegramLinkCode Issue(string code, Guid userId, DateTimeOffset now) =>
new()
{
Id = Guid.NewGuid(),
Code = code,
UserId = userId,
CreatedAt = now,
ExpiresAt = now + Lifetime,
};
public bool IsUsable(DateTimeOffset now) => UsedAt is null && now < ExpiresAt;
public void MarkUsed(DateTimeOffset now) => UsedAt = now;
}
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Notifications;
/// <summary>
/// О чём оповещать. Подписка хранится парой «канал + вид», поэтому зритель может следить
/// за началом шоу на одном канале и не получать ничего с другого.
/// </summary>
public enum TelegramNotificationKind
{
/// <summary>В эфире началось новое шоу (смена программы, а не очередная серия того же).</summary>
ShowStart = 0,
/// <summary>Через несколько минут начнётся следующая программа.</summary>
UpNext = 1,
}
@@ -0,0 +1,10 @@
namespace TeleWave.Domain.Notifications;
/// <summary>Через что ходить к api.telegram.org.</summary>
public enum TelegramProxyKind
{
/// <summary>Напрямую.</summary>
None = 0,
Http = 1,
Socks5 = 2,
}
@@ -0,0 +1,148 @@
namespace TeleWave.Domain.Notifications;
/// <summary>
/// Настройки бота расписания — одна строка на установку.
///
/// Отдельная таблица, а не набор ключей в <c>AppSetting</c>: полей десяток, они меняются вместе
/// и вместе же должны быть согласованы («вебхук без адреса» — не состояние, а поломка). Токен
/// лежит открытым текстом: шифровать его на диске нечем (Data Protection в проекте сознательно
/// не заводили), поэтому наружу он не отдаётся — админка видит только признак «задан».
/// </summary>
public class TelegramSettings
{
/// <summary>Единственная строка настроек: идентификатор фиксированный, чтобы не заводить второй.</summary>
public static readonly Guid SingletonId = new("11111111-2222-3333-4444-555555555555");
public Guid Id { get; private set; }
/// <summary>Бот включён: без этого ни опрос, ни рассылка не идут, даже если токен задан.</summary>
public bool IsEnabled { get; private set; }
/// <summary>Токен от @BotFather. Пусто — бот не настроен.</summary>
public string? BotToken { get; private set; }
/// <summary>Имя бота (@name) — из него собирается ссылка на чат для зрителя.</summary>
public string? BotUsername { get; private set; }
public TelegramTransport Transport { get; private set; }
/// <summary>Публичный адрес для вебхука (https://host/api/telegram/webhook).</summary>
public string? WebhookUrl { get; private set; }
/// <summary>
/// Секрет вебхука: Telegram присылает его заголовком, и по нему мы отличаем настоящий вызов
/// от постороннего, нашедшего адрес. Генерируется при сохранении, наружу не отдаётся.
/// </summary>
public string? WebhookSecret { get; private set; }
public TelegramProxyKind ProxyKind { get; private set; }
public string? ProxyHost { get; private set; }
public int? ProxyPort { get; private set; }
public string? ProxyUsername { get; private set; }
public string? ProxyPassword { get; private set; }
/// <summary>Смещение опроса: с какого обновления продолжать после перезапуска.</summary>
public long LastUpdateId { get; private set; }
/// <summary>Когда бот в последний раз успешно достучался до Telegram — признак живой связи.</summary>
public DateTimeOffset? LastContactAt { get; private set; }
/// <summary>Последняя ошибка связи: её показывает админка вместо молчаливого «ничего не приходит».</summary>
public string? LastError { get; private set; }
/// <summary>
/// До какого момента лента уже разобрана рассылкой. Без этой отметки перезапуск сервиса
/// заново оповестил бы обо всём, что успело начаться.
/// </summary>
public DateTimeOffset? NotifiedUntil { get; private set; }
private TelegramSettings() { }
public static TelegramSettings CreateDefault() =>
new()
{
Id = SingletonId,
IsEnabled = false,
Transport = TelegramTransport.Polling,
ProxyKind = TelegramProxyKind.None,
};
/// <summary>Настроен ли бот настолько, чтобы вообще пытаться работать.</summary>
public bool IsOperational =>
IsEnabled
&& !string.IsNullOrWhiteSpace(BotToken)
&& (Transport == TelegramTransport.Polling || !string.IsNullOrWhiteSpace(WebhookUrl));
public void Update(TelegramSettingsUpdate update)
{
IsEnabled = update.IsEnabled;
Transport = update.Transport;
WebhookUrl = Normalize(update.WebhookUrl);
ProxyKind = update.ProxyKind;
ProxyHost = Normalize(update.ProxyHost);
ProxyPort = update.ProxyPort;
ProxyUsername = Normalize(update.ProxyUsername);
// Пустое значение секрета означает «не менять»: наружу мы их не отдаём, и форма
// возвращает пустоту там, где админ просто ничего не трогал.
if (!string.IsNullOrWhiteSpace(update.BotToken))
BotToken = update.BotToken.Trim();
if (update.ProxyPassword is not null)
ProxyPassword = Normalize(update.ProxyPassword);
}
/// <summary>Снимает токен: бот перестаёт быть настроенным.</summary>
public void ClearToken()
{
BotToken = null;
BotUsername = null;
IsEnabled = false;
}
/// <summary>Имя бота, узнанное у Telegram (getMe): по нему строится ссылка на чат.</summary>
public void SetBotUsername(string? username) => BotUsername = Normalize(username);
public void SetWebhookSecret(string? secret) => WebhookSecret = Normalize(secret);
/// <summary>Двигает смещение опроса. Только вперёд: повторный разбор старых обновлений
/// разослал бы уже отправленные ответы заново.</summary>
public void AdvanceUpdateId(long updateId)
{
if (updateId > LastUpdateId)
LastUpdateId = updateId;
}
/// <summary>Двигает границу разобранной рассылкой ленты — только вперёд.</summary>
public void MarkNotifiedUntil(DateTimeOffset at)
{
if (NotifiedUntil is null || at > NotifiedUntil)
NotifiedUntil = at;
}
public void MarkContact(DateTimeOffset at)
{
LastContactAt = at;
LastError = null;
}
public void MarkError(string error) => LastError = error.Length > 512 ? error[..512] : error;
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// Что админ меняет в настройках. Токен и пароль прокси необязательны: пустое значение означает
/// «оставить как есть» — форма их не показывает и вернуть не может.
/// </summary>
public sealed record TelegramSettingsUpdate(
bool IsEnabled,
TelegramTransport Transport,
string? BotToken,
string? WebhookUrl,
TelegramProxyKind ProxyKind,
string? ProxyHost,
int? ProxyPort,
string? ProxyUsername,
string? ProxyPassword
);
@@ -0,0 +1,93 @@
namespace TeleWave.Domain.Notifications;
/// <summary>
/// Зритель в телеграме: чат, куда шлём, и учётка, которой он представился.
///
/// Привязка к пользователю обязательна по построению — бот отвечает только на одноразовый код
/// из админки. Иначе рассылка уходила бы кому угодно, кто нашёл бота, а отписать его при
/// блокировке учётки было бы нечем.
/// </summary>
public class TelegramSubscriber
{
private readonly List<TelegramSubscription> _subscriptions = [];
public Guid Id { get; private set; }
/// <summary>Идентификатор чата в Telegram — им же адресуется отправка.</summary>
public long ChatId { get; private set; }
/// <summary>Пользователь TeleWave, с которым связан чат.</summary>
public Guid UserId { get; private set; }
/// <summary>Как зритель подписан в телеграме — только для списка в админке.</summary>
public string? DisplayName { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Последняя активность: по ней видно, живой чат или зритель давно ушёл.</summary>
public DateTimeOffset? LastSeenAt { get; private set; }
/// <summary>
/// Чат закрыт: зритель нажал «стоп» или заблокировал бота. Подписки при этом остаются —
/// вернувшись, он получит свои каналы, а не пустой список.
/// </summary>
public bool IsStopped { get; private set; }
public IReadOnlyList<TelegramSubscription> Subscriptions => _subscriptions;
private TelegramSubscriber() { }
public static TelegramSubscriber Create(
long chatId,
Guid userId,
string? displayName,
DateTimeOffset now
) =>
new()
{
Id = Guid.NewGuid(),
ChatId = chatId,
UserId = userId,
DisplayName = displayName,
CreatedAt = now,
LastSeenAt = now,
};
/// <summary>Тот же чат представился заново — например, по новому коду привязки.</summary>
public void Rebind(Guid userId, string? displayName, DateTimeOffset now)
{
UserId = userId;
DisplayName = displayName;
LastSeenAt = now;
IsStopped = false;
}
public void Touch(DateTimeOffset now) => LastSeenAt = now;
public void Stop() => IsStopped = true;
public void Resume() => IsStopped = false;
/// <summary>Переключает подписку и возвращает её новое состояние.</summary>
public bool Toggle(Guid channelId, TelegramNotificationKind kind)
{
var existing = _subscriptions.FirstOrDefault(s =>
s.ChannelId == channelId && s.Kind == kind
);
if (existing is not null)
{
_subscriptions.Remove(existing);
return false;
}
_subscriptions.Add(TelegramSubscription.Create(Id, channelId, kind));
return true;
}
public bool IsSubscribed(Guid channelId, TelegramNotificationKind kind) =>
_subscriptions.Any(s => s.ChannelId == channelId && s.Kind == kind);
/// <summary>Снимает подписки на канал — его удалили, и слать по нему больше нечего.</summary>
public void DropChannel(Guid channelId) =>
_subscriptions.RemoveAll(s => s.ChannelId == channelId);
}
@@ -0,0 +1,25 @@
namespace TeleWave.Domain.Notifications;
/// <summary>Одна подписка: «этот чат, этот канал, этот вид оповещений».</summary>
public class TelegramSubscription
{
public Guid Id { get; private set; }
public Guid SubscriberId { get; private set; }
public Guid ChannelId { get; private set; }
public TelegramNotificationKind Kind { get; private set; }
private TelegramSubscription() { }
public static TelegramSubscription Create(
Guid subscriberId,
Guid channelId,
TelegramNotificationKind kind
) =>
new()
{
Id = Guid.NewGuid(),
SubscriberId = subscriberId,
ChannelId = channelId,
Kind = kind,
};
}
@@ -0,0 +1,17 @@
namespace TeleWave.Domain.Notifications;
/// <summary>Как бот получает обновления от Telegram.</summary>
public enum TelegramTransport
{
/// <summary>
/// Сам ходит за обновлениями (getUpdates). Работает из-за NAT и через прокси — там, где
/// публичного адреса нет, это единственный вариант.
/// </summary>
Polling = 0,
/// <summary>
/// Telegram стучится на наш HTTPS-эндпоинт. Дешевле и быстрее, но требует доступного извне
/// домена с сертификатом; прокси здесь не поможет — соединение устанавливает Telegram.
/// </summary>
Webhook = 1,
}
@@ -16,6 +16,7 @@ using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Library;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Metadata;
using TeleWave.Infrastructure.Notifications;
using TeleWave.Infrastructure.Persistence;
using TeleWave.Infrastructure.Settings;
using TeleWave.Infrastructure.Streaming;
@@ -155,6 +156,9 @@ public static class DependencyInjection
configuration.GetSection(DebugExportOptions.SectionName)
);
services.AddSingleton<IDebugExportStore, DebugExportStore>();
services.AddScoped<ITelegramApi, TelegramApi>();
services.AddHostedService<TelegramPollingBackgroundService>();
services.AddHostedService<TelegramNotifierBackgroundService>();
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton<MediaPathResolver>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TelegramBot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TelegramLinkCodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Code = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
ExpiresAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
UsedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("PK_TelegramLinkCodes", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "TelegramSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
BotToken = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: true
),
BotUsername = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: true
),
Transport = table.Column<int>(type: "integer", nullable: false),
WebhookUrl = table.Column<string>(
type: "character varying(512)",
maxLength: 512,
nullable: true
),
WebhookSecret = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: true
),
ProxyKind = table.Column<int>(type: "integer", nullable: false),
ProxyHost = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
ProxyPort = table.Column<int>(type: "integer", nullable: true),
ProxyUsername = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: true
),
ProxyPassword = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
LastUpdateId = table.Column<long>(type: "bigint", nullable: false),
LastContactAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
LastError = table.Column<string>(
type: "character varying(512)",
maxLength: 512,
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("PK_TelegramSettings", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "TelegramSubscribers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChatId = table.Column<long>(type: "bigint", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
DisplayName = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
LastSeenAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
IsStopped = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_TelegramSubscribers", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "TelegramSubscriptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SubscriberId = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_TelegramSubscriptions", x => x.Id);
table.ForeignKey(
name: "FK_TelegramSubscriptions_TelegramSubscribers_SubscriberId",
column: x => x.SubscriberId,
principalTable: "TelegramSubscribers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_TelegramLinkCodes_Code",
table: "TelegramLinkCodes",
column: "Code",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_TelegramSubscribers_ChatId",
table: "TelegramSubscribers",
column: "ChatId",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_TelegramSubscribers_UserId",
table: "TelegramSubscribers",
column: "UserId"
);
migrationBuilder.CreateIndex(
name: "IX_TelegramSubscriptions_ChannelId_Kind",
table: "TelegramSubscriptions",
columns: new[] { "ChannelId", "Kind" }
);
migrationBuilder.CreateIndex(
name: "IX_TelegramSubscriptions_SubscriberId_ChannelId_Kind",
table: "TelegramSubscriptions",
columns: new[] { "SubscriberId", "ChannelId", "Kind" },
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "TelegramLinkCodes");
migrationBuilder.DropTable(name: "TelegramSettings");
migrationBuilder.DropTable(name: "TelegramSubscriptions");
migrationBuilder.DropTable(name: "TelegramSubscribers");
}
}
}
@@ -0,0 +1,28 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TelegramNotifiedMarker : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "NotifiedUntil",
table: "TelegramSettings",
type: "timestamp with time zone",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "NotifiedUntil", table: "TelegramSettings");
}
}
}
@@ -736,6 +736,157 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramLinkCode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("UsedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("TelegramLinkCodes");
});
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSettings", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("BotToken")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("BotUsername")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastContactAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<long>("LastUpdateId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("NotifiedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("ProxyHost")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("ProxyKind")
.HasColumnType("integer");
b.Property<string>("ProxyPassword")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("ProxyPort")
.HasColumnType("integer");
b.Property<string>("ProxyUsername")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Transport")
.HasColumnType("integer");
b.Property<string>("WebhookSecret")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("WebhookUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.ToTable("TelegramSettings");
});
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<long>("ChatId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("IsStopped")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ChatId")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("TelegramSubscribers");
});
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscription", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ChannelId", "Kind");
b.HasIndex("SubscriberId", "ChannelId", "Kind")
.IsUnique();
b.ToTable("TelegramSubscriptions");
});
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
{
b.Property<Guid>("Id")
@@ -1313,6 +1464,15 @@ namespace TeleWave.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscription", b =>
{
b.HasOne("TeleWave.Domain.Notifications.TelegramSubscriber", null)
.WithMany("Subscriptions")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
{
b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null)
@@ -1400,6 +1560,11 @@ namespace TeleWave.Infrastructure.Migrations
b.Navigation("Genres");
});
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b =>
{
b.Navigation("Subscriptions");
});
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
{
b.Navigation("Slots");
@@ -0,0 +1,292 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Notifications;
namespace TeleWave.Infrastructure.Notifications;
/// <summary>
/// Bot API поверх обычного <see cref="HttpClient"/>. Сторонняя обёртка не заводится намеренно:
/// боту нужно шесть вызовов, а библиотека потянула бы свою модель обновлений, свой цикл опроса
/// и своё представление о прокси — то есть ровно то, чем мы управляем сами.
///
/// Клиент собирается на каждый вызов из настроек: токен и прокси админ меняет в рантайме,
/// а закешированный клиент означал бы перезапуск контейнера ради смены прокси.
/// </summary>
public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
{
/// <summary>
/// Сколько висеть на getUpdates. Длинный опрос — это одно соединение вместо частых запросов;
/// таймаут клиента заведомо больше, иначе он рвал бы соединение раньше Telegram.
/// </summary>
private const int LongPollSeconds = 25;
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<TelegramBotInfo?> GetMeAsync(
TelegramSettings settings,
CancellationToken cancellationToken
)
{
var result = await CallAsync<MeResult>(settings, "getMe", null, cancellationToken);
return result is { Ok: true, Result: { } me }
? new TelegramBotInfo(me.Id, me.Username)
: null;
}
public async Task<IReadOnlyList<TelegramUpdate>> GetUpdatesAsync(
TelegramSettings settings,
long offset,
CancellationToken cancellationToken
)
{
var payload = new
{
offset,
timeout = LongPollSeconds,
allowed_updates = new[] { "message", "callback_query" },
};
var result = await CallAsync<UpdatesResult>(
settings,
"getUpdates",
payload,
cancellationToken,
TimeSpan.FromSeconds(LongPollSeconds + 10)
);
return result?.Result?.Select(Map).ToList() ?? [];
}
public Task SendMessageAsync(
TelegramSettings settings,
long chatId,
string text,
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
CancellationToken cancellationToken
) =>
CallAsync<OkResult>(
settings,
"sendMessage",
new
{
chat_id = chatId,
text,
parse_mode = "HTML",
reply_markup = Markup(keyboard),
},
cancellationToken
);
public Task EditMessageAsync(
TelegramSettings settings,
long chatId,
long messageId,
string text,
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
CancellationToken cancellationToken
) =>
CallAsync<OkResult>(
settings,
"editMessageText",
new
{
chat_id = chatId,
message_id = messageId,
text,
parse_mode = "HTML",
reply_markup = Markup(keyboard),
},
cancellationToken
);
public Task AnswerCallbackAsync(
TelegramSettings settings,
string callbackId,
string? text,
CancellationToken cancellationToken
) =>
CallAsync<OkResult>(
settings,
"answerCallbackQuery",
new { callback_query_id = callbackId, text },
cancellationToken
);
public async Task<TelegramUpdate?> ReadUpdateAsync(
Stream body,
CancellationToken cancellationToken
)
{
try
{
var dto = await JsonSerializer.DeserializeAsync<UpdateDto>(
body,
Json,
cancellationToken
);
return dto is null ? null : Map(dto);
}
catch (JsonException exception)
{
// Мусор в теле вебхука — не повод отвечать ошибкой: Telegram начнёт повторять доставку.
logger.LogWarning(exception, "Тело вебхука Telegram не разобрано");
return null;
}
}
public Task SetWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken) =>
CallAsync<OkResult>(
settings,
"setWebhook",
new
{
url = settings.WebhookUrl,
secret_token = settings.WebhookSecret,
allowed_updates = new[] { "message", "callback_query" },
},
cancellationToken
);
public Task DeleteWebhookAsync(
TelegramSettings settings,
CancellationToken cancellationToken
) => CallAsync<OkResult>(settings, "deleteWebhook", null, cancellationToken);
private static object? Markup(IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard) =>
keyboard is null
? null
: new
{
inline_keyboard = keyboard.Select(row =>
row.Select(b => new { text = b.Text, callback_data = b.CallbackData })
),
};
private async Task<T?> CallAsync<T>(
TelegramSettings settings,
string method,
object? payload,
CancellationToken cancellationToken,
TimeSpan? timeout = null
)
where T : class
{
if (string.IsNullOrWhiteSpace(settings.BotToken))
return null;
using var handler = CreateHandler(settings);
using var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://api.telegram.org/"),
Timeout = timeout ?? TimeSpan.FromSeconds(30),
};
var url = $"bot{settings.BotToken}/{method}";
using var response = payload is null
? await client.PostAsync(url, null, cancellationToken)
: await client.PostAsJsonAsync(url, payload, Json, cancellationToken);
if (!response.IsSuccessStatusCode)
{
// Тело ошибки Telegram — это описание причины («Unauthorized», «chat not found»),
// и без него в логе остаётся только код, по которому ничего не понять.
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new TelegramApiException($"{method}: {(int)response.StatusCode} {body}");
}
return await response.Content.ReadFromJsonAsync<T>(Json, cancellationToken);
}
/// <summary>
/// Транспорт с прокси, если он задан. SOCKS5 поддерживает сам <see cref="WebProxy"/> начиная
/// с .NET 6 — достаточно схемы в адресе, отдельной библиотеки не нужно.
/// </summary>
private HttpClientHandler CreateHandler(TelegramSettings settings)
{
var handler = new HttpClientHandler();
if (
settings.ProxyKind == TelegramProxyKind.None
|| string.IsNullOrWhiteSpace(settings.ProxyHost)
)
return handler;
var scheme = settings.ProxyKind == TelegramProxyKind.Socks5 ? "socks5" : "http";
var port = settings.ProxyPort is > 0 ? settings.ProxyPort.Value : 1080;
try
{
var proxy = new WebProxy($"{scheme}://{settings.ProxyHost}:{port}");
if (!string.IsNullOrWhiteSpace(settings.ProxyUsername))
proxy.Credentials = new NetworkCredential(
settings.ProxyUsername,
settings.ProxyPassword ?? string.Empty
);
handler.Proxy = proxy;
handler.UseProxy = true;
}
catch (UriFormatException exception)
{
// Кривой адрес прокси — это ошибка настройки, а не повод молча пойти напрямую:
// «работает в обход прокси» на канале, ради которого прокси и заводили, хуже отказа.
logger.LogError(
exception,
"Неверный адрес прокси Telegram: {Host}",
settings.ProxyHost
);
handler.Dispose();
throw new TelegramApiException($"Неверный адрес прокси: {settings.ProxyHost}:{port}");
}
return handler;
}
private static TelegramUpdate Map(UpdateDto dto) =>
new(
dto.UpdateId,
dto.Message?.Chat?.Id ?? dto.CallbackQuery?.Message?.Chat?.Id,
dto.Message?.Text,
dto.Message?.From?.Username ?? dto.CallbackQuery?.From?.Username,
dto.CallbackQuery?.Id,
dto.CallbackQuery?.Data,
dto.CallbackQuery?.Message?.MessageId
);
private sealed record OkResult(bool Ok);
private sealed record MeResult(bool Ok, MeDto? Result);
private sealed record MeDto(long Id, string Username);
private sealed record UpdatesResult(bool Ok, List<UpdateDto>? Result);
private sealed record UpdateDto(
long UpdateId,
MessageDto? Message,
CallbackQueryDto? CallbackQuery
);
private sealed record MessageDto(long MessageId, ChatDto? Chat, UserDto? From, string? Text);
private sealed record CallbackQueryDto(
string Id,
UserDto? From,
MessageDto? Message,
string? Data
);
private sealed record ChatDto(long Id);
private sealed record UserDto(string? Username);
}
/// <summary>Ошибка обращения к Bot API: наружу уходит текстом, который показывает админка.</summary>
public sealed class TelegramApiException(string message) : Exception(message);
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Notifications;
namespace TeleWave.Infrastructure.Notifications;
/// <summary>
/// Тик рассылки: раз в минуту сверяет ленту с подписками.
///
/// Минута — компромисс между «сейчас в эфире» и нагрузкой: точнее не нужно (сообщение о начале
/// программы с задержкой в полминуты остаётся правдой), а чаще — это лишние запросы к ленте
/// круглые сутки.
/// </summary>
public sealed class TelegramNotifierBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<TelegramNotifierBackgroundService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
logger.LogError(exception, "Ошибка рассылки Telegram");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var settings = await db.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
if (settings is null || !settings.IsOperational)
return;
var notifier = scope.ServiceProvider.GetRequiredService<TelegramNotifier>();
var sent = await notifier.RunAsync(settings, DateTimeOffset.UtcNow, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
if (sent > 0)
logger.LogInformation("Telegram: отправлено оповещений — {Count}", sent);
}
}
@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Notifications;
using TeleWave.Domain.Notifications;
namespace TeleWave.Infrastructure.Notifications;
/// <summary>
/// Опрос обновлений (getUpdates) — режим для установки без публичного адреса.
///
/// Служба крутится всегда, а не заводится по настройке: бота включают и выключают в рантайме,
/// и перезапускать контейнер ради этого никто не должен. Пока бот выключен или выбран вебхук,
/// цикл просто дремлет.
/// </summary>
public sealed class TelegramPollingBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<TelegramPollingBackgroundService> logger
) : BackgroundService
{
/// <summary>Пауза, когда работать нечего: бот выключен, не настроен или выбран вебхук.</summary>
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(30);
/// <summary>Пауза после ошибки — чтобы недоступный прокси не превращался в поток запросов.</summary>
private static readonly TimeSpan ErrorDelay = TimeSpan.FromSeconds(15);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
TimeSpan delay;
try
{
delay = await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
logger.LogError(exception, "Ошибка опроса Telegram");
delay = ErrorDelay;
}
if (delay > TimeSpan.Zero)
{
try
{
await Task.Delay(delay, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
}
/// <summary>Один заход опроса. Возвращает, сколько ждать перед следующим.</summary>
private async Task<TimeSpan> TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var settings = await db.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
if (
settings is null
|| !settings.IsOperational
|| settings.Transport != TelegramTransport.Polling
)
return IdleDelay;
var api = scope.ServiceProvider.GetRequiredService<ITelegramApi>();
var bot = scope.ServiceProvider.GetRequiredService<TelegramBotService>();
IReadOnlyList<TelegramUpdate> updates;
try
{
// Смещение — «следующее за последним разобранным»: так Telegram сам подтверждает приём
// и не отдаёт то же обновление второй раз.
updates = await api.GetUpdatesAsync(
settings,
settings.LastUpdateId + 1,
cancellationToken
);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
settings.MarkError(exception.Message);
await db.SaveChangesAsync(cancellationToken);
logger.LogWarning(exception, "Telegram недоступен");
return ErrorDelay;
}
settings.MarkContact(DateTimeOffset.UtcNow);
foreach (var update in updates)
{
try
{
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Одно кривое обновление не должно останавливать разбор остальных — и, главное,
// не должно повторяться вечно: смещение двигаем в любом случае.
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
}
settings.AdvanceUpdateId(update.UpdateId);
}
await db.SaveChangesAsync(cancellationToken);
// Длинный опрос сам держит соединение — своей паузы между заходами не нужно.
return TimeSpan.Zero;
}
}
@@ -8,6 +8,7 @@ using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Notifications;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
using TeleWave.Infrastructure.Identity;
@@ -45,6 +46,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>();
public DbSet<TelegramSettings> TelegramSettings => Set<TelegramSettings>();
public DbSet<TelegramSubscriber> TelegramSubscribers => Set<TelegramSubscriber>();
public DbSet<TelegramSubscription> TelegramSubscriptions => Set<TelegramSubscription>();
public DbSet<TelegramLinkCode> TelegramLinkCodes => Set<TelegramLinkCode>();
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
Database.BeginTransactionAsync(cancellationToken);
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Notifications;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class TelegramSettingsConfiguration : IEntityTypeConfiguration<TelegramSettings>
{
public void Configure(EntityTypeBuilder<TelegramSettings> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.BotToken).HasMaxLength(128);
builder.Property(x => x.BotUsername).HasMaxLength(64);
builder.Property(x => x.WebhookUrl).HasMaxLength(512);
builder.Property(x => x.WebhookSecret).HasMaxLength(128);
builder.Property(x => x.ProxyHost).HasMaxLength(256);
builder.Property(x => x.ProxyUsername).HasMaxLength(128);
builder.Property(x => x.ProxyPassword).HasMaxLength(256);
builder.Property(x => x.LastError).HasMaxLength(512);
}
}
public class TelegramSubscriberConfiguration : IEntityTypeConfiguration<TelegramSubscriber>
{
public void Configure(EntityTypeBuilder<TelegramSubscriber> builder)
{
builder.HasKey(x => x.Id);
// Один чат — один подписчик: повторный /start перепривязывает существующего, а не плодит копии.
builder.HasIndex(x => x.ChatId).IsUnique();
builder.HasIndex(x => x.UserId);
builder.Property(x => x.DisplayName).HasMaxLength(128);
builder
.HasMany(x => x.Subscriptions)
.WithOne()
.HasForeignKey(x => x.SubscriberId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Subscriptions).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class TelegramSubscriptionConfiguration : IEntityTypeConfiguration<TelegramSubscription>
{
public void Configure(EntityTypeBuilder<TelegramSubscription> builder)
{
builder.HasKey(x => x.Id);
// Рассылка идёт «по каналу и виду» — индекс под тот самый запрос.
builder.HasIndex(x => new { x.ChannelId, x.Kind });
builder
.HasIndex(x => new
{
x.SubscriberId,
x.ChannelId,
x.Kind,
})
.IsUnique();
}
}
public class TelegramLinkCodeConfiguration : IEntityTypeConfiguration<TelegramLinkCode>
{
public void Configure(EntityTypeBuilder<TelegramLinkCode> builder)
{
builder.HasKey(x => x.Id);
builder.HasIndex(x => x.Code).IsUnique();
builder.Property(x => x.Code).IsRequired().HasMaxLength(64);
}
}
@@ -0,0 +1,188 @@
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Notifications;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Notifications;
using Xunit;
namespace TeleWave.Application.Tests.Notifications;
/// <summary>
/// Разговор с ботом. Главное свойство — подписаться можно только по одноразовому коду: рассылка
/// уходит в личные чаты, и «кто нашёл бота, тот и подписался» здесь означало бы чужие оповещения.
/// </summary>
public class TelegramBotTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
private static TelegramSettings Settings()
{
var settings = TelegramSettings.CreateDefault();
settings.Update(
new TelegramSettingsUpdate(
true,
TelegramTransport.Polling,
"123:ABC",
null,
TelegramProxyKind.None,
null,
null,
null,
null
)
);
return settings;
}
private static TelegramUpdate Message(long chatId, string text) =>
new(1, chatId, text, "viewer", null, null, null);
private static TelegramUpdate Callback(long chatId, string data) =>
new(2, chatId, null, "viewer", "cb-1", data, 55);
[Fact]
public async Task Start_WithoutCode_DoesNotSubscribeAnyone()
{
var fixture = new TestDb();
var api = Substitute.For<ITelegramApi>();
await using var db = fixture.New();
await new TelegramBotService(db, api).HandleAsync(
Settings(),
Message(100, "/start"),
Now,
CancellationToken.None
);
Assert.Empty(db.TelegramSubscribers);
// Незнакомцу объясняют, где взять код, а не молчат в ответ.
await api.Received(1)
.SendMessageAsync(
Arg.Any<TelegramSettings>(),
100,
Arg.Is<string>(s => s.Contains("код")),
null,
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Start_WithCode_LinksChatToUserAndBurnsCode()
{
var fixture = new TestDb();
var userId = Guid.NewGuid();
var code = TelegramLinkCode.Issue("abc123", userId, Now);
var channel = Channel.Create("Мультреалити", "mult", Now);
channel.UpdateSettings(channel.Name, isEnabled: true, null);
await using (var seed = fixture.New())
{
seed.TelegramLinkCodes.Add(code);
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
Settings(),
Message(100, "/start abc123"),
Now,
CancellationToken.None
);
var subscriber = Assert.Single(db.TelegramSubscribers);
Assert.Equal(userId, subscriber.UserId);
Assert.Equal(100, subscriber.ChatId);
// Код одноразовый: ссылка оседает в истории браузера и в чате.
Assert.NotNull(db.TelegramLinkCodes.Single().UsedAt);
}
[Fact]
public async Task Start_WithUsedCode_IsRefused()
{
var fixture = new TestDb();
var code = TelegramLinkCode.Issue("abc123", Guid.NewGuid(), Now);
code.MarkUsed(Now);
await using (var seed = fixture.New())
{
seed.TelegramLinkCodes.Add(code);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
Settings(),
Message(100, "/start abc123"),
Now,
CancellationToken.None
);
Assert.Empty(db.TelegramSubscribers);
}
[Fact]
public async Task Callback_TogglesSubscriptionBothWays()
{
var fixture = new TestDb();
var channel = Channel.Create("Мультреалити", "mult", Now);
channel.UpdateSettings(channel.Name, isEnabled: true, null);
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.TelegramSubscribers.Add(subscriber);
await seed.SaveChangesAsync(CancellationToken.None);
}
var data = $"{channel.Id}:{TelegramNotificationKind.ShowStart}";
await using (var db = fixture.New())
{
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
Settings(),
Callback(100, data),
Now,
CancellationToken.None
);
}
await using (var check = fixture.New())
{
Assert.Single(check.TelegramSubscriptions);
}
await using (var db = fixture.New())
{
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
Settings(),
Callback(100, data),
Now,
CancellationToken.None
);
}
await using var verify = fixture.New();
// Повторное нажатие выключает: кнопка — тумблер, а не «добавить ещё одну подписку».
Assert.Empty(verify.TelegramSubscriptions);
}
[Fact]
public async Task Callback_FromUnknownChat_IsRefused()
{
var fixture = new TestDb();
var api = Substitute.For<ITelegramApi>();
await using var db = fixture.New();
await new TelegramBotService(db, api).HandleAsync(
Settings(),
Callback(100, $"{Guid.NewGuid()}:{TelegramNotificationKind.ShowStart}"),
Now,
CancellationToken.None
);
Assert.Empty(db.TelegramSubscriptions);
}
}
@@ -0,0 +1,147 @@
using NSubstitute;
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.Media;
using TeleWave.Domain.Notifications;
using Xunit;
namespace TeleWave.Application.Tests.Notifications;
/// <summary>
/// Рассылка по ленте. Проверяется то, из-за чего бот становится спамом: оповещение шлётся
/// на смену шоу, а не на каждую серию, и только тем, кто подписан на этот канал.
/// </summary>
public class TelegramNotifierTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 1, 20, 0, 0, TimeSpan.Zero);
private static TelegramSettings Settings()
{
var settings = TelegramSettings.CreateDefault();
settings.Update(
new TelegramSettingsUpdate(
true,
TelegramTransport.Polling,
"123:ABC",
null,
TelegramProxyKind.None,
null,
null,
null,
null
)
);
return settings;
}
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 Notifies_OnlySubscribersOfTheChannel_AndOnlyOnShowChange()
{
var fixture = new TestDb();
var channel = Channel.Create("Мультреалити", "mult", Now);
var other = Channel.Create("Второй", "second", Now);
var show = Show.Create("Футурама", ShowKind.Series);
var another = Show.Create("Симпсоны", ShowKind.Series);
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
subscriber.Toggle(channel.Id, TelegramNotificationKind.ShowStart);
var stranger = TelegramSubscriber.Create(200, Guid.NewGuid(), "other", Now);
stranger.Toggle(other.Id, TelegramNotificationKind.ShowStart);
await using (var seed = fixture.New())
{
seed.Channels.AddRange(channel, other);
seed.Shows.AddRange(show, another);
seed.TelegramSubscribers.AddRange(subscriber, stranger);
// Предыдущая программа того же шоу: следующая серия сменой не считается.
seed.ScheduleEntries.Add(Program(channel.Id, show.Id, Now.AddMinutes(-30), 25));
seed.ScheduleEntries.Add(Program(channel.Id, show.Id, Now.AddMinutes(-2), 25));
await seed.SaveChangesAsync(CancellationToken.None);
}
var api = Substitute.For<ITelegramApi>();
var settings = Settings();
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(0, sent);
await api.DidNotReceive()
.SendMessageAsync(
Arg.Any<TelegramSettings>(),
Arg.Any<long>(),
Arg.Any<string>(),
Arg.Any<IReadOnlyList<IReadOnlyList<TelegramButton>>?>(),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Notifies_WhenShowChanges()
{
var fixture = new TestDb();
var channel = Channel.Create("Мультреалити", "mult", Now);
var previous = Show.Create("Симпсоны", ShowKind.Series);
var started = Show.Create("Футурама", ShowKind.Series);
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
subscriber.Toggle(channel.Id, TelegramNotificationKind.ShowStart);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.Shows.AddRange(previous, started);
seed.TelegramSubscribers.Add(subscriber);
seed.ScheduleEntries.Add(Program(channel.Id, previous.Id, Now.AddMinutes(-30), 25));
seed.ScheduleEntries.Add(Program(channel.Id, started.Id, Now.AddMinutes(-1), 25));
await seed.SaveChangesAsync(CancellationToken.None);
}
var api = Substitute.For<ITelegramApi>();
var settings = Settings();
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<TelegramSettings>(),
100,
Arg.Is<string>(s => s.Contains("Футурама")),
null,
Arg.Any<CancellationToken>()
);
// Граница разобранного двигается всегда — иначе следующий тик оповестил бы о том же.
Assert.Equal(Now, settings.NotifiedUntil);
}
}