diff --git a/CLAUDE.md b/CLAUDE.md
index 61c03e0..f1d7cca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,7 +10,7 @@
Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
(TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
-публичный просмотр, статистика хранилища. Telegram-бот сознательно не делаем. Конвенции — зеркало
+публичный просмотр, статистика хранилища. Telegram-бот расписания (подписки на оповещения по каналам, прокси, опрос или вебхук). Конвенции — зеркало
[`PnvPanel`](../PnvPanel) (тот же автор).
**Спеки в `docs/` — источник правды по домену**, читай до правок в подсистеме:
diff --git a/backend/src/TeleWave.Api/Endpoints/TelegramEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TelegramEndpoints.cs
new file mode 100644
index 0000000..48fb9de
--- /dev/null
+++ b/backend/src/TeleWave.Api/Endpoints/TelegramEndpoints.cs
@@ -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;
+
+///
+/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
+///
+public static class TelegramEndpoints
+{
+ /// Заголовок, которым Telegram подтверждает, что вызов вебхука — его.
+ 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();
+ admin.MapPut("/settings", UpdateSettings).Produces();
+ admin
+ .MapGet("/subscribers", ListSubscribers)
+ .Produces>();
+
+ // Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
+ // кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
+ app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces();
+
+ // Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
+ app.MapPost("/api/telegram/link", IssueLink)
+ .RequireAuthorization()
+ .Produces();
+
+ // Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
+ // только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
+ app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
+
+ return app;
+ }
+
+ private static async Task 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 GetSettings(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task 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 ListSubscribers(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task IssueLink(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ ///
+ /// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
+ /// разговор один.
+ ///
+ /// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
+ /// кривое обновление зациклило бы вебхук.
+ ///
+ private static async Task 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();
+ }
+}
+
+/// Что зрительской части нужно знать о боте: настроен ли и как он называется.
+public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
+
+/// Тело правки настроек: секреты необязательны — пусто значит «не менять».
+public sealed record TelegramSettingsBody(
+ bool IsEnabled,
+ TelegramTransport Transport,
+ string? BotToken,
+ string? WebhookUrl,
+ TelegramProxyKind ProxyKind,
+ string? ProxyHost,
+ int? ProxyPort,
+ string? ProxyUsername,
+ string? ProxyPassword
+);
diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs
index 103f515..9c2e90a 100644
--- a/backend/src/TeleWave.Api/Program.cs
+++ b/backend/src/TeleWave.Api/Program.cs
@@ -133,6 +133,7 @@ app.MapTemplateEndpoints();
app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
+app.MapTelegramEndpoints();
app.MapStreamingEndpoints();
app.MapIptvEndpoints();
app.MapMaintenanceEndpoints();
diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
index 013d7bf..ca7775f 100644
--- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
+++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
@@ -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 ScheduleEntries { get; }
DbSet BumperTemplates { get; }
DbSet BumperTextVariants { get; }
+ DbSet TelegramSettings { get; }
+ DbSet TelegramSubscribers { get; }
+ DbSet TelegramSubscriptions { get; }
+ DbSet TelegramLinkCodes { get; }
DbSet BumperAssets { get; }
DbSet AppSettings { get; }
DbSet Images { get; }
diff --git a/backend/src/TeleWave.Application/Common/Interfaces/ITelegramApi.cs b/backend/src/TeleWave.Application/Common/Interfaces/ITelegramApi.cs
new file mode 100644
index 0000000..0ce923c
--- /dev/null
+++ b/backend/src/TeleWave.Application/Common/Interfaces/ITelegramApi.cs
@@ -0,0 +1,78 @@
+using TeleWave.Domain.Notifications;
+
+namespace TeleWave.Application.Common.Interfaces;
+
+/// Кнопка инлайн-клавиатуры: подпись и что вернётся в callback.
+public sealed record TelegramButton(string Text, string CallbackData);
+
+/// Обновление от Telegram — только то, что нам нужно от его модели.
+public sealed record TelegramUpdate(
+ long UpdateId,
+ long? ChatId,
+ string? Text,
+ string? From,
+ /// Идентификатор нажатия: на него надо ответить, иначе кнопка «крутится» у зрителя.
+ string? CallbackId,
+ string? CallbackData,
+ long? CallbackMessageId
+);
+
+/// Кто мы для Telegram — ответ getMe, по нему проверяется токен.
+public sealed record TelegramBotInfo(long Id, string Username);
+
+///
+/// Порт к Bot API. Держим ровно те вызовы, что нужны боту расписания: узнать себя, получить
+/// обновления, ответить сообщением с кнопками и переключить вебхук.
+///
+/// Настройки (токен, прокси) реализация читает сама на каждый вызов: админ меняет их в рантайме,
+/// и держать клиента, собранного на старте, значило бы требовать перезапуск контейнера.
+///
+public interface ITelegramApi
+{
+ Task GetMeAsync(
+ TelegramSettings settings,
+ CancellationToken cancellationToken
+ );
+
+ Task> GetUpdatesAsync(
+ TelegramSettings settings,
+ long offset,
+ CancellationToken cancellationToken
+ );
+
+ Task SendMessageAsync(
+ TelegramSettings settings,
+ long chatId,
+ string text,
+ IReadOnlyList>? keyboard,
+ CancellationToken cancellationToken
+ );
+
+ /// Переписывает уже отправленное сообщение — так меню подписок обновляется на месте.
+ Task EditMessageAsync(
+ TelegramSettings settings,
+ long chatId,
+ long messageId,
+ string text,
+ IReadOnlyList>? keyboard,
+ CancellationToken cancellationToken
+ );
+
+ /// Гасит «часики» на нажатой кнопке; текст — всплывающая подсказка.
+ Task AnswerCallbackAsync(
+ TelegramSettings settings,
+ string callbackId,
+ string? text,
+ CancellationToken cancellationToken
+ );
+
+ ///
+ /// Разбирает тело вебхука в то же обновление, что приходит опросом: разговор с ботом один,
+ /// и знать про две модели ему незачем. null — тело не разобралось.
+ ///
+ Task ReadUpdateAsync(Stream body, CancellationToken cancellationToken);
+
+ Task SetWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
+
+ Task DeleteWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
+}
diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs
index 86746cc..00c526f 100644
--- a/backend/src/TeleWave.Application/DependencyInjection.cs
+++ b/backend/src/TeleWave.Application/DependencyInjection.cs
@@ -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();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/backend/src/TeleWave.Application/Notifications/Link/IssueTelegramLinkCommand.cs b/backend/src/TeleWave.Application/Notifications/Link/IssueTelegramLinkCommand.cs
new file mode 100644
index 0000000..bf8b125
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Link/IssueTelegramLinkCommand.cs
@@ -0,0 +1,7 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Notifications.Link;
+
+/// Выдать текущему зрителю ссылку на чат с ботом и одноразовым кодом привязки.
+public sealed record IssueTelegramLinkCommand : ICommand>;
diff --git a/backend/src/TeleWave.Application/Notifications/Link/IssueTelegramLinkCommandHandler.cs b/backend/src/TeleWave.Application/Notifications/Link/IssueTelegramLinkCommandHandler.cs
new file mode 100644
index 0000000..c0b323a
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Link/IssueTelegramLinkCommandHandler.cs
@@ -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;
+
+///
+/// Выдаёт одноразовый код привязки и собирает из него ссылку на чат.
+///
+/// Код — единственный способ познакомить чат с учёткой: бот не спрашивает логин и не верит
+/// на слово, а ссылка уходит только авторизованному зрителю. Старые неиспользованные коды
+/// того же пользователя гасим: две живые ссылки на один аккаунт — это две двери вместо одной.
+///
+public sealed class IssueTelegramLinkCommandHandler(
+ IAppDbContext dbContext,
+ TelegramSettingsService settingsService,
+ ICurrentUser currentUser
+) : ICommandHandler>
+{
+ public async Task> Handle(
+ IssueTelegramLinkCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ if (currentUser.UserId is not { } userId)
+ return Result.Failure(TelegramErrors.NotConfigured);
+
+ var settings = await settingsService.ReadAsync(cancellationToken);
+ if (settings is null || TelegramSettingsService.BotLink(settings) is not { } botLink)
+ return Result.Failure(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));
+ }
+}
diff --git a/backend/src/TeleWave.Application/Notifications/Link/TelegramLinkDto.cs b/backend/src/TeleWave.Application/Notifications/Link/TelegramLinkDto.cs
new file mode 100644
index 0000000..ce3a21b
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Link/TelegramLinkDto.cs
@@ -0,0 +1,5 @@
+namespace TeleWave.Application.Notifications.Link;
+
+/// Готовая ссылка t.me/бот?start=код — её и открывает кнопка на странице эфира.
+/// Когда код сгорит: ссылка живёт минуты, а не вечно.
+public sealed record TelegramLinkDto(string Url, DateTimeOffset ExpiresAt);
diff --git a/backend/src/TeleWave.Application/Notifications/Settings/GetTelegramSettingsQuery.cs b/backend/src/TeleWave.Application/Notifications/Settings/GetTelegramSettingsQuery.cs
new file mode 100644
index 0000000..d0d3997
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Settings/GetTelegramSettingsQuery.cs
@@ -0,0 +1,6 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Notifications.Settings;
+
+public sealed record GetTelegramSettingsQuery : IQuery>;
diff --git a/backend/src/TeleWave.Application/Notifications/Settings/GetTelegramSettingsQueryHandler.cs b/backend/src/TeleWave.Application/Notifications/Settings/GetTelegramSettingsQueryHandler.cs
new file mode 100644
index 0000000..778e9f7
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Settings/GetTelegramSettingsQueryHandler.cs
@@ -0,0 +1,19 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Notifications.Settings;
+
+public sealed class GetTelegramSettingsQueryHandler(TelegramSettingsService settings)
+ : IQueryHandler>
+{
+ public async Task> 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));
+ }
+}
diff --git a/backend/src/TeleWave.Application/Notifications/Settings/TelegramSettingsDto.cs b/backend/src/TeleWave.Application/Notifications/Settings/TelegramSettingsDto.cs
new file mode 100644
index 0000000..a820f85
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Settings/TelegramSettingsDto.cs
@@ -0,0 +1,25 @@
+using TeleWave.Domain.Notifications;
+
+namespace TeleWave.Application.Notifications.Settings;
+
+///
+/// Настройки бота для админки. Секретов здесь нет: токен и пароль прокси наружу не отдаются,
+/// вместо них признак «задан» — иначе они утекали бы в каждый GET и в отладочные дампы.
+///
+public sealed record TelegramSettingsDto(
+ bool IsEnabled,
+ bool HasToken,
+ string? BotUsername,
+ /// Ссылка на чат с ботом; null — бот не настроен, и кнопку зрителю показывать нечем.
+ string? BotLink,
+ TelegramTransport Transport,
+ string? WebhookUrl,
+ TelegramProxyKind ProxyKind,
+ string? ProxyHost,
+ int? ProxyPort,
+ string? ProxyUsername,
+ bool HasProxyPassword,
+ DateTimeOffset? LastContactAt,
+ string? LastError,
+ int Subscribers
+);
diff --git a/backend/src/TeleWave.Application/Notifications/Settings/TelegramSettingsService.cs b/backend/src/TeleWave.Application/Notifications/Settings/TelegramSettingsService.cs
new file mode 100644
index 0000000..af99baa
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Settings/TelegramSettingsService.cs
@@ -0,0 +1,67 @@
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Notifications;
+
+namespace TeleWave.Application.Notifications.Settings;
+
+///
+/// Доступ к единственной строке настроек бота и сборка её представления для админки.
+///
+/// Отдельный тип, а не метод хендлера: настройки читают все — оба транспорта, рассылка, выдача
+/// ссылки зрителю, — и «создать, если строки ещё нет» должно жить в одном месте.
+///
+public sealed class TelegramSettingsService(IAppDbContext dbContext)
+{
+ /// Настройки для записи. Первое обращение заводит строку с умолчаниями.
+ public async Task 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;
+ }
+
+ /// Настройки только для чтения — для фоновых служб, которым писать нечего.
+ public Task ReadAsync(CancellationToken cancellationToken) =>
+ dbContext.TelegramSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
+
+ public async Task 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
+ );
+ }
+
+ ///
+ /// Ссылка на чат. Без имени бота её не собрать, а имя приходит от Telegram при проверке
+ /// токена — поэтому кнопка у зрителя появляется только после успешного соединения.
+ ///
+ public static string? BotLink(TelegramSettings settings) =>
+ string.IsNullOrWhiteSpace(settings.BotUsername) || !settings.IsEnabled
+ ? null
+ : $"https://t.me/{settings.BotUsername}";
+}
diff --git a/backend/src/TeleWave.Application/Notifications/Settings/UpdateTelegramSettingsCommand.cs b/backend/src/TeleWave.Application/Notifications/Settings/UpdateTelegramSettingsCommand.cs
new file mode 100644
index 0000000..666ecba
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Settings/UpdateTelegramSettingsCommand.cs
@@ -0,0 +1,21 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+using TeleWave.Domain.Notifications;
+
+namespace TeleWave.Application.Notifications.Settings;
+
+///
+/// Правка настроек бота. и пустые
+/// означают «оставить как есть»: наружу они не отдаются, и форма вернуть их не может.
+///
+public sealed record UpdateTelegramSettingsCommand(
+ bool IsEnabled,
+ TelegramTransport Transport,
+ string? BotToken,
+ string? WebhookUrl,
+ TelegramProxyKind ProxyKind,
+ string? ProxyHost,
+ int? ProxyPort,
+ string? ProxyUsername,
+ string? ProxyPassword
+) : ICommand>;
diff --git a/backend/src/TeleWave.Application/Notifications/Settings/UpdateTelegramSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Notifications/Settings/UpdateTelegramSettingsCommandHandler.cs
new file mode 100644
index 0000000..e10b043
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Settings/UpdateTelegramSettingsCommandHandler.cs
@@ -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;
+
+///
+/// Сохраняет настройки бота и сразу проверяет их делом: спрашивает у Telegram, кто мы,
+/// и переключает вебхук.
+///
+/// Проверка на сохранении, а не отдельной кнопкой, намеренно: неверный токен или недоступный
+/// прокси иначе всплыли бы через сутки молчания бота, а не в момент правки. Имя бота приходит
+/// из того же ответа — без него не собрать ссылку на чат для зрителя.
+///
+public sealed class UpdateTelegramSettingsCommandHandler(
+ IAppDbContext dbContext,
+ TelegramSettingsService settingsService,
+ ITelegramApi api
+) : ICommandHandler>
+{
+ public async Task> Handle(
+ UpdateTelegramSettingsCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ if (
+ command.Transport == TelegramTransport.Webhook
+ && string.IsNullOrWhiteSpace(command.WebhookUrl)
+ )
+ return Result.Failure(TelegramErrors.WebhookUrlRequired);
+
+ if (
+ command.ProxyKind != TelegramProxyKind.None
+ && string.IsNullOrWhiteSpace(command.ProxyHost)
+ )
+ return Result.Failure(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(TelegramErrors.NotConfigured);
+
+ if (settings.IsOperational)
+ {
+ var check = await ConnectAsync(settings, cancellationToken);
+ if (check is { } error)
+ return Result.Failure(error);
+ }
+
+ return Result.Success(await settingsService.ToDtoAsync(settings, cancellationToken));
+ }
+
+ ///
+ /// Проверяет связь и приводит режим доставки в соответствие настройке. Вебхук снимается явно:
+ /// пока он стоит, Telegram не отдаёт обновления опросом, и переключение режима без этого
+ /// оставило бы бота немым.
+ ///
+ private async Task 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);
+ }
+ }
+}
diff --git a/backend/src/TeleWave.Application/Notifications/Subscribers/ListTelegramSubscribersQuery.cs b/backend/src/TeleWave.Application/Notifications/Subscribers/ListTelegramSubscribersQuery.cs
new file mode 100644
index 0000000..20d465d
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Subscribers/ListTelegramSubscribersQuery.cs
@@ -0,0 +1,7 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Notifications.Subscribers;
+
+public sealed record ListTelegramSubscribersQuery
+ : IQuery>>;
diff --git a/backend/src/TeleWave.Application/Notifications/Subscribers/ListTelegramSubscribersQueryHandler.cs b/backend/src/TeleWave.Application/Notifications/Subscribers/ListTelegramSubscribersQueryHandler.cs
new file mode 100644
index 0000000..75f42b4
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Subscribers/ListTelegramSubscribersQueryHandler.cs
@@ -0,0 +1,63 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Notifications.Subscribers;
+
+///
+/// Кто подписан и на что. Нужен не ради красоты: рассылка уходит в чужие чаты, и админ должен
+/// видеть список — иначе «бот кому-то что-то шлёт» проверяется только по логам Telegram.
+///
+public sealed class ListTelegramSubscribersQueryHandler(
+ IAppDbContext dbContext,
+ IIdentityService identity
+) : IQueryHandler>>
+{
+ public async Task>> 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();
+ 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>(result);
+ }
+}
diff --git a/backend/src/TeleWave.Application/Notifications/Subscribers/TelegramSubscriberDto.cs b/backend/src/TeleWave.Application/Notifications/Subscribers/TelegramSubscriberDto.cs
new file mode 100644
index 0000000..9d26ad8
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/Subscribers/TelegramSubscriberDto.cs
@@ -0,0 +1,19 @@
+namespace TeleWave.Application.Notifications.Subscribers;
+
+/// На что подписан: имя канала и виды оповещений по нему.
+public sealed record TelegramSubscriberDto(
+ Guid Id,
+ long ChatId,
+ string? DisplayName,
+ string? UserName,
+ bool IsStopped,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? LastSeenAt,
+ IReadOnlyList Channels
+);
+
+public sealed record TelegramSubscriptionDto(
+ Guid ChannelId,
+ string ChannelName,
+ IReadOnlyList Kinds
+);
diff --git a/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs b/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs
new file mode 100644
index 0000000..159e336
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs
@@ -0,0 +1,299 @@
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Notifications;
+
+namespace TeleWave.Application.Notifications;
+
+///
+/// Разговор с ботом: привязка чата к учётке и меню подписок.
+///
+/// Один и тот же код обслуживает оба транспорта — опрос и вебхук приносят одно и то же обновление,
+/// и разводить логику по ним значило бы чинить каждую находку дважды.
+///
+/// Меню живёт одним сообщением: нажатие кнопки переписывает его на месте. Лента из десятка
+/// одинаковых сообщений «ваши подписки» после каждого клика — это не настройка, а мусор в чате.
+///
+public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api)
+{
+ /// Разделитель в callback: идентификатор канала и вид оповещения.
+ 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);
+ }
+
+ ///
+ /// /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 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(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;
+
+ ///
+ /// Меню подписок: строка на канал, в ней — кнопка на каждый вид оповещения с отметкой.
+ /// задан — правим уже отправленное сообщение, а не шлём новое.
+ ///
+ 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>();
+ 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 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"
+ + "Остановить всё — командой /stop.";
+
+ public const string NoChannels = "Пока нет ни одного включённого канала.";
+
+ public const string Stopped = "Оповещения остановлены. Вернуть — командой /start.";
+
+ public const string Subscribed = "Включено";
+
+ public const string Unsubscribed = "Выключено";
+
+ public const string ChannelGone = "Канала больше нет";
+ }
+}
diff --git a/backend/src/TeleWave.Application/Notifications/TelegramErrors.cs b/backend/src/TeleWave.Application/Notifications/TelegramErrors.cs
new file mode 100644
index 0000000..99bfa85
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/TelegramErrors.cs
@@ -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",
+ "Для прокси нужен адрес и порт."
+ );
+
+ /// Telegram ответил отказом — текст его же, он объясняет причину лучше нашего.
+ public static Error Unreachable(string details) =>
+ Error.Validation("Telegram.Unreachable", $"Telegram не ответил: {details}");
+}
diff --git a/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs b/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs
new file mode 100644
index 0000000..36fbe0d
--- /dev/null
+++ b/backend/src/TeleWave.Application/Notifications/TelegramNotifier.cs
@@ -0,0 +1,208 @@
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Notifications;
+
+namespace TeleWave.Application.Notifications;
+
+///
+/// Рассылка по ленте: «в эфире началось» и «скоро начнётся».
+///
+/// Считается по материализованному расписанию, а не по факту раздачи: лента и есть эфир, а ждать
+/// подтверждения от плеера не от кого — зрителей может не быть вовсе, а оповещение всё равно нужно.
+///
+/// Граница разобранного () двигается всегда, даже когда
+/// подписчиков нет: иначе первый же подписавшийся получил бы пачку сообщений про всё, что успело
+/// пройти с момента настройки бота.
+///
+public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api)
+{
+ /// За сколько предупреждать о следующей программе.
+ public static readonly TimeSpan UpNextLead = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Насколько далеко в прошлое смотреть при первом заходе. Без потолка перезапуск после долгого
+ /// простоя разослал бы «началось» про весь вчерашний эфир.
+ ///
+ private static readonly TimeSpan MaxCatchUp = TimeSpan.FromMinutes(15);
+
+ public async Task 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;
+ }
+
+ ///
+ /// Начавшиеся программы. Оповещаем о смене шоу, а не о каждой записи: четыре серии подряд —
+ /// это одна программа в глазах зрителя, и четыре сообщения о ней читаются как спам.
+ ///
+ private async Task 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,
+ $"▶️ {Escape(title)}\nСейчас в эфире",
+ cancellationToken
+ );
+ }
+
+ return sent;
+ }
+
+ /// Что начнётся через — предупреждаем один раз, в своё окно.
+ private async Task 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} мин: {Escape(title)}",
+ cancellationToken
+ );
+ }
+
+ return sent;
+ }
+
+ ///
+ /// Сменилось ли шоу этой записью. Предыдущая запись канала берётся из ленты: врезки и заставки
+ /// в счёт не идут — реклама между сериями не делает следующую серию новой программой.
+ ///
+ private async Task 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 ShowNameAsync(Guid showId, CancellationToken cancellationToken) =>
+ dbContext
+ .Shows.AsNoTracking()
+ .Where(s => s.Id == showId)
+ .Select(s => s.Name)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ /// Шлёт всем, кто подписан на этот канал и вид. Возвращает число доставленных сообщений.
+ private async Task 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;
+ }
+
+ /// Экранирование под HTML-разметку Telegram: в названиях шоу встречаются «&» и кавычки.
+ private static string Escape(string value) =>
+ value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
+}
diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramLinkCode.cs b/backend/src/TeleWave.Domain/Notifications/TelegramLinkCode.cs
new file mode 100644
index 0000000..d8fa7a6
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Notifications/TelegramLinkCode.cs
@@ -0,0 +1,37 @@
+namespace TeleWave.Domain.Notifications;
+
+///
+/// Одноразовый код привязки чата к учётке. Зритель нажимает «открыть чат» на странице эфира,
+/// код уезжает в ссылку t.me/bot?start=КОД, и бот по нему узнаёт, кто пришёл.
+///
+/// Живёт минуты и сгорает при первом использовании: ссылка попадает в историю браузера и в чат,
+/// а вечный код означал бы вечный пропуск к чужим оповещениям.
+///
+public class TelegramLinkCode
+{
+ /// Сколько код действителен.
+ 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;
+}
diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramNotificationKind.cs b/backend/src/TeleWave.Domain/Notifications/TelegramNotificationKind.cs
new file mode 100644
index 0000000..d77e495
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Notifications/TelegramNotificationKind.cs
@@ -0,0 +1,14 @@
+namespace TeleWave.Domain.Notifications;
+
+///
+/// О чём оповещать. Подписка хранится парой «канал + вид», поэтому зритель может следить
+/// за началом шоу на одном канале и не получать ничего с другого.
+///
+public enum TelegramNotificationKind
+{
+ /// В эфире началось новое шоу (смена программы, а не очередная серия того же).
+ ShowStart = 0,
+
+ /// Через несколько минут начнётся следующая программа.
+ UpNext = 1,
+}
diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramProxyKind.cs b/backend/src/TeleWave.Domain/Notifications/TelegramProxyKind.cs
new file mode 100644
index 0000000..a136c69
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Notifications/TelegramProxyKind.cs
@@ -0,0 +1,10 @@
+namespace TeleWave.Domain.Notifications;
+
+/// Через что ходить к api.telegram.org.
+public enum TelegramProxyKind
+{
+ /// Напрямую.
+ None = 0,
+ Http = 1,
+ Socks5 = 2,
+}
diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramSettings.cs b/backend/src/TeleWave.Domain/Notifications/TelegramSettings.cs
new file mode 100644
index 0000000..de6dbdb
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Notifications/TelegramSettings.cs
@@ -0,0 +1,148 @@
+namespace TeleWave.Domain.Notifications;
+
+///
+/// Настройки бота расписания — одна строка на установку.
+///
+/// Отдельная таблица, а не набор ключей в AppSetting: полей десяток, они меняются вместе
+/// и вместе же должны быть согласованы («вебхук без адреса» — не состояние, а поломка). Токен
+/// лежит открытым текстом: шифровать его на диске нечем (Data Protection в проекте сознательно
+/// не заводили), поэтому наружу он не отдаётся — админка видит только признак «задан».
+///
+public class TelegramSettings
+{
+ /// Единственная строка настроек: идентификатор фиксированный, чтобы не заводить второй.
+ public static readonly Guid SingletonId = new("11111111-2222-3333-4444-555555555555");
+
+ public Guid Id { get; private set; }
+
+ /// Бот включён: без этого ни опрос, ни рассылка не идут, даже если токен задан.
+ public bool IsEnabled { get; private set; }
+
+ /// Токен от @BotFather. Пусто — бот не настроен.
+ public string? BotToken { get; private set; }
+
+ /// Имя бота (@name) — из него собирается ссылка на чат для зрителя.
+ public string? BotUsername { get; private set; }
+
+ public TelegramTransport Transport { get; private set; }
+
+ /// Публичный адрес для вебхука (https://host/api/telegram/webhook).
+ public string? WebhookUrl { get; private set; }
+
+ ///
+ /// Секрет вебхука: Telegram присылает его заголовком, и по нему мы отличаем настоящий вызов
+ /// от постороннего, нашедшего адрес. Генерируется при сохранении, наружу не отдаётся.
+ ///
+ 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; }
+
+ /// Смещение опроса: с какого обновления продолжать после перезапуска.
+ public long LastUpdateId { get; private set; }
+
+ /// Когда бот в последний раз успешно достучался до Telegram — признак живой связи.
+ public DateTimeOffset? LastContactAt { get; private set; }
+
+ /// Последняя ошибка связи: её показывает админка вместо молчаливого «ничего не приходит».
+ public string? LastError { get; private set; }
+
+ ///
+ /// До какого момента лента уже разобрана рассылкой. Без этой отметки перезапуск сервиса
+ /// заново оповестил бы обо всём, что успело начаться.
+ ///
+ public DateTimeOffset? NotifiedUntil { get; private set; }
+
+ private TelegramSettings() { }
+
+ public static TelegramSettings CreateDefault() =>
+ new()
+ {
+ Id = SingletonId,
+ IsEnabled = false,
+ Transport = TelegramTransport.Polling,
+ ProxyKind = TelegramProxyKind.None,
+ };
+
+ /// Настроен ли бот настолько, чтобы вообще пытаться работать.
+ 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);
+ }
+
+ /// Снимает токен: бот перестаёт быть настроенным.
+ public void ClearToken()
+ {
+ BotToken = null;
+ BotUsername = null;
+ IsEnabled = false;
+ }
+
+ /// Имя бота, узнанное у Telegram (getMe): по нему строится ссылка на чат.
+ public void SetBotUsername(string? username) => BotUsername = Normalize(username);
+
+ public void SetWebhookSecret(string? secret) => WebhookSecret = Normalize(secret);
+
+ /// Двигает смещение опроса. Только вперёд: повторный разбор старых обновлений
+ /// разослал бы уже отправленные ответы заново.
+ public void AdvanceUpdateId(long updateId)
+ {
+ if (updateId > LastUpdateId)
+ LastUpdateId = updateId;
+ }
+
+ /// Двигает границу разобранной рассылкой ленты — только вперёд.
+ 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();
+}
+
+///
+/// Что админ меняет в настройках. Токен и пароль прокси необязательны: пустое значение означает
+/// «оставить как есть» — форма их не показывает и вернуть не может.
+///
+public sealed record TelegramSettingsUpdate(
+ bool IsEnabled,
+ TelegramTransport Transport,
+ string? BotToken,
+ string? WebhookUrl,
+ TelegramProxyKind ProxyKind,
+ string? ProxyHost,
+ int? ProxyPort,
+ string? ProxyUsername,
+ string? ProxyPassword
+);
diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramSubscriber.cs b/backend/src/TeleWave.Domain/Notifications/TelegramSubscriber.cs
new file mode 100644
index 0000000..a7e8424
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Notifications/TelegramSubscriber.cs
@@ -0,0 +1,93 @@
+namespace TeleWave.Domain.Notifications;
+
+///
+/// Зритель в телеграме: чат, куда шлём, и учётка, которой он представился.
+///
+/// Привязка к пользователю обязательна по построению — бот отвечает только на одноразовый код
+/// из админки. Иначе рассылка уходила бы кому угодно, кто нашёл бота, а отписать его при
+/// блокировке учётки было бы нечем.
+///
+public class TelegramSubscriber
+{
+ private readonly List _subscriptions = [];
+
+ public Guid Id { get; private set; }
+
+ /// Идентификатор чата в Telegram — им же адресуется отправка.
+ public long ChatId { get; private set; }
+
+ /// Пользователь TeleWave, с которым связан чат.
+ public Guid UserId { get; private set; }
+
+ /// Как зритель подписан в телеграме — только для списка в админке.
+ public string? DisplayName { get; private set; }
+
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ /// Последняя активность: по ней видно, живой чат или зритель давно ушёл.
+ public DateTimeOffset? LastSeenAt { get; private set; }
+
+ ///
+ /// Чат закрыт: зритель нажал «стоп» или заблокировал бота. Подписки при этом остаются —
+ /// вернувшись, он получит свои каналы, а не пустой список.
+ ///
+ public bool IsStopped { get; private set; }
+
+ public IReadOnlyList 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,
+ };
+
+ /// Тот же чат представился заново — например, по новому коду привязки.
+ 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;
+
+ /// Переключает подписку и возвращает её новое состояние.
+ 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);
+
+ /// Снимает подписки на канал — его удалили, и слать по нему больше нечего.
+ public void DropChannel(Guid channelId) =>
+ _subscriptions.RemoveAll(s => s.ChannelId == channelId);
+}
diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramSubscription.cs b/backend/src/TeleWave.Domain/Notifications/TelegramSubscription.cs
new file mode 100644
index 0000000..0f160cf
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Notifications/TelegramSubscription.cs
@@ -0,0 +1,25 @@
+namespace TeleWave.Domain.Notifications;
+
+/// Одна подписка: «этот чат, этот канал, этот вид оповещений».
+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,
+ };
+}
diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramTransport.cs b/backend/src/TeleWave.Domain/Notifications/TelegramTransport.cs
new file mode 100644
index 0000000..a3bf546
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Notifications/TelegramTransport.cs
@@ -0,0 +1,17 @@
+namespace TeleWave.Domain.Notifications;
+
+/// Как бот получает обновления от Telegram.
+public enum TelegramTransport
+{
+ ///
+ /// Сам ходит за обновлениями (getUpdates). Работает из-за NAT и через прокси — там, где
+ /// публичного адреса нет, это единственный вариант.
+ ///
+ Polling = 0,
+
+ ///
+ /// Telegram стучится на наш HTTPS-эндпоинт. Дешевле и быстрее, но требует доступного извне
+ /// домена с сертификатом; прокси здесь не поможет — соединение устанавливает Telegram.
+ ///
+ Webhook = 1,
+}
diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs
index 06c2634..01beee2 100644
--- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs
+++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs
@@ -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();
+ services.AddScoped();
+ services.AddHostedService();
+ services.AddHostedService();
services.Configure(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton();
diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260731043731_TelegramBot.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260731043731_TelegramBot.Designer.cs
new file mode 100644
index 0000000..07d8b5a
--- /dev/null
+++ b/backend/src/TeleWave.Infrastructure/Migrations/20260731043731_TelegramBot.Designer.cs
@@ -0,0 +1,1590 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeleWave.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace TeleWave.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260731043731_TelegramBot")]
+ partial class TelegramBot
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReplacedByTokenHash")
+ .HasColumnType("text");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BackgroundAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("PosterShowId")
+ .HasColumnType("uuid");
+
+ b.Property("RenderedLinesJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Signature")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("VariantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaAssetId");
+
+ b.HasIndex("Signature")
+ .IsUnique();
+
+ b.ToTable("BumperAssets");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AccentColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("AudioDurationSeconds")
+ .HasColumnType("double precision");
+
+ b.Property("AudioExtension")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("BackgroundColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("BackgroundColor2")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("BackgroundImageId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Font")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Revision")
+ .HasColumnType("integer");
+
+ b.Property("TextColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("BumperTemplates");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Background")
+ .HasColumnType("integer");
+
+ b.Property("BackgroundClipShowId")
+ .HasColumnType("uuid");
+
+ b.Property("BumperTemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Trigger")
+ .HasColumnType("integer");
+
+ b.Property("Weight")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.HasKey("Id");
+
+ b.HasIndex("BumperTemplateId", "Position");
+
+ b.ToTable("BumperTextVariants");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AnalogFilterStrength")
+ .HasColumnType("double precision");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DayStartTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("EpochUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FillerAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LogoCorner")
+ .HasColumnType("integer");
+
+ b.Property("LogoImageId")
+ .HasColumnType("uuid");
+
+ b.Property("LogoOpacity")
+ .HasColumnType("double precision");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Number")
+ .HasColumnType("integer");
+
+ b.Property("ShowClock")
+ .HasColumnType("boolean");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("UtcOffsetMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Number")
+ .IsUnique()
+ .HasFilter("\"Number\" IS NOT NULL");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BumperVariantId")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CollectionId")
+ .HasColumnType("uuid");
+
+ b.Property("EndsAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EpisodeIndex")
+ .HasColumnType("integer");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("SlotId")
+ .HasColumnType("uuid");
+
+ b.Property("StartsAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TraceJson")
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId", "EndsAtUtc");
+
+ b.HasIndex("ChannelId", "StartsAtUtc");
+
+ b.HasIndex("ChannelId", "ShowId", "StartsAtUtc");
+
+ b.ToTable("ScheduleEntries");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Category")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileExtension")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category", "CreatedAt");
+
+ b.ToTable("Images");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PosterImageId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("Collections");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CollectionId")
+ .HasColumnType("uuid");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ShowId");
+
+ b.HasIndex("CollectionId", "Position");
+
+ b.HasIndex("CollectionId", "ShowId")
+ .IsUnique();
+
+ b.ToTable("CollectionItems");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Genre", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsSystem")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("GenreId")
+ .HasColumnType("uuid");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GenreId");
+
+ b.HasIndex("Value")
+ .IsUnique();
+
+ b.ToTable("GenreAliases");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Audience")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("FranchiseExternalId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("FranchiseName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("MetadataExternalId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("MetadataProvider")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("OriginalName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PosterImageId")
+ .HasColumnType("uuid");
+
+ b.Property("Year")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Shows");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AirDate")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Episode")
+ .HasColumnType("integer");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("Overview")
+ .HasMaxLength(4096)
+ .HasColumnType("character varying(4096)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Season")
+ .HasColumnType("integer");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("StillImageId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaAssetId");
+
+ b.HasIndex("ShowId", "Position");
+
+ b.ToTable("ShowEpisode");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b =>
+ {
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("GenreId")
+ .HasColumnType("uuid");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean");
+
+ b.HasKey("ShowId", "GenreId");
+
+ b.HasIndex("GenreId");
+
+ b.ToTable("ShowGenres");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AudioCodec")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Duration")
+ .HasColumnType("interval");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Height")
+ .HasColumnType("integer");
+
+ b.Property("OriginalExtension")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("OriginalFileName")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("ProcessingDuration")
+ .HasColumnType("interval");
+
+ b.Property("ProcessingStartedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RelativePath")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SegmentCount")
+ .HasColumnType("integer");
+
+ b.Property("SegmentSeconds")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VideoCodec")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Width")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("Status");
+
+ b.ToTable("MediaAssets");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramLinkCode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("TelegramLinkCodes");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSettings", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BotToken")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("BotUsername")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LastContactAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastError")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("LastUpdateId")
+ .HasColumnType("bigint");
+
+ b.Property("ProxyHost")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ProxyKind")
+ .HasColumnType("integer");
+
+ b.Property("ProxyPassword")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ProxyPort")
+ .HasColumnType("integer");
+
+ b.Property("ProxyUsername")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Transport")
+ .HasColumnType("integer");
+
+ b.Property("WebhookSecret")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("WebhookUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.HasKey("Id");
+
+ b.ToTable("TelegramSettings");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b =>
+ {
+ b.Property