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("Id") + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsStopped") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", 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("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChoiceKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ChoiceWeight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId"); + + b.HasIndex("BumperVariantId"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxTotalSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 => + { + b1.Property("BumperTextVariantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Color"); + + b1.Property("Position"); + + b1.Property("Style"); + + b1.Property("Text") + .IsRequired() + .HasMaxLength(120); + + b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal"); + + b1.ToTable("BumperTextVariants"); + + b1 + .ToJson("Lines") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BumperTextVariantId"); + }); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .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) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany() + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null) + .WithMany() + .HasForeignKey("BumperVariantId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260731043731_TelegramBot.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260731043731_TelegramBot.cs new file mode 100644 index 0000000..3def3b5 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260731043731_TelegramBot.cs @@ -0,0 +1,201 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class TelegramBot : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TelegramLinkCodes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Code = table.Column( + type: "character varying(64)", + maxLength: 64, + nullable: false + ), + UserId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + ExpiresAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + UsedAt = table.Column( + 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(type: "uuid", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + BotToken = table.Column( + type: "character varying(128)", + maxLength: 128, + nullable: true + ), + BotUsername = table.Column( + type: "character varying(64)", + maxLength: 64, + nullable: true + ), + Transport = table.Column(type: "integer", nullable: false), + WebhookUrl = table.Column( + type: "character varying(512)", + maxLength: 512, + nullable: true + ), + WebhookSecret = table.Column( + type: "character varying(128)", + maxLength: 128, + nullable: true + ), + ProxyKind = table.Column(type: "integer", nullable: false), + ProxyHost = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + ProxyPort = table.Column(type: "integer", nullable: true), + ProxyUsername = table.Column( + type: "character varying(128)", + maxLength: 128, + nullable: true + ), + ProxyPassword = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: true + ), + LastUpdateId = table.Column(type: "bigint", nullable: false), + LastContactAt = table.Column( + type: "timestamp with time zone", + nullable: true + ), + LastError = table.Column( + 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(type: "uuid", nullable: false), + ChatId = table.Column(type: "bigint", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + DisplayName = table.Column( + type: "character varying(128)", + maxLength: 128, + nullable: true + ), + CreatedAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + LastSeenAt = table.Column( + type: "timestamp with time zone", + nullable: true + ), + IsStopped = table.Column(type: "boolean", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_TelegramSubscribers", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "TelegramSubscriptions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SubscriberId = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + Kind = table.Column(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 + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "TelegramLinkCodes"); + + migrationBuilder.DropTable(name: "TelegramSettings"); + + migrationBuilder.DropTable(name: "TelegramSubscriptions"); + + migrationBuilder.DropTable(name: "TelegramSubscribers"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260731043844_TelegramNotifiedMarker.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260731043844_TelegramNotifiedMarker.Designer.cs new file mode 100644 index 0000000..e985a33 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260731043844_TelegramNotifiedMarker.Designer.cs @@ -0,0 +1,1593 @@ +// +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("20260731043844_TelegramNotifiedMarker")] + partial class TelegramNotifiedMarker + { + /// + 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("NotifiedUntil") + .HasColumnType("timestamp with time zone"); + + 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("Id") + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsStopped") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", 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("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChoiceKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ChoiceWeight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId"); + + b.HasIndex("BumperVariantId"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxTotalSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 => + { + b1.Property("BumperTextVariantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Color"); + + b1.Property("Position"); + + b1.Property("Style"); + + b1.Property("Text") + .IsRequired() + .HasMaxLength(120); + + b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal"); + + b1.ToTable("BumperTextVariants"); + + b1 + .ToJson("Lines") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BumperTextVariantId"); + }); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .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) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany() + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null) + .WithMany() + .HasForeignKey("BumperVariantId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260731043844_TelegramNotifiedMarker.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260731043844_TelegramNotifiedMarker.cs new file mode 100644 index 0000000..5bd228b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260731043844_TelegramNotifiedMarker.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class TelegramNotifiedMarker : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NotifiedUntil", + table: "TelegramSettings", + type: "timestamp with time zone", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "NotifiedUntil", table: "TelegramSettings"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 0dff446..f87acb2 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -736,6 +736,157 @@ namespace TeleWave.Infrastructure.Migrations 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("NotifiedUntil") + .HasColumnType("timestamp with time zone"); + + 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("Id") + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsStopped") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("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("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"); diff --git a/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs b/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs new file mode 100644 index 0000000..8f122b6 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs @@ -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; + +/// +/// Bot API поверх обычного . Сторонняя обёртка не заводится намеренно: +/// боту нужно шесть вызовов, а библиотека потянула бы свою модель обновлений, свой цикл опроса +/// и своё представление о прокси — то есть ровно то, чем мы управляем сами. +/// +/// Клиент собирается на каждый вызов из настроек: токен и прокси админ меняет в рантайме, +/// а закешированный клиент означал бы перезапуск контейнера ради смены прокси. +/// +public sealed class TelegramApi(ILogger logger) : ITelegramApi +{ + /// + /// Сколько висеть на getUpdates. Длинный опрос — это одно соединение вместо частых запросов; + /// таймаут клиента заведомо больше, иначе он рвал бы соединение раньше Telegram. + /// + private const int LongPollSeconds = 25; + + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public async Task GetMeAsync( + TelegramSettings settings, + CancellationToken cancellationToken + ) + { + var result = await CallAsync(settings, "getMe", null, cancellationToken); + return result is { Ok: true, Result: { } me } + ? new TelegramBotInfo(me.Id, me.Username) + : null; + } + + public async Task> GetUpdatesAsync( + TelegramSettings settings, + long offset, + CancellationToken cancellationToken + ) + { + var payload = new + { + offset, + timeout = LongPollSeconds, + allowed_updates = new[] { "message", "callback_query" }, + }; + + var result = await CallAsync( + settings, + "getUpdates", + payload, + cancellationToken, + TimeSpan.FromSeconds(LongPollSeconds + 10) + ); + + return result?.Result?.Select(Map).ToList() ?? []; + } + + public Task SendMessageAsync( + TelegramSettings settings, + long chatId, + string text, + IReadOnlyList>? keyboard, + CancellationToken cancellationToken + ) => + CallAsync( + 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>? keyboard, + CancellationToken cancellationToken + ) => + CallAsync( + 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( + settings, + "answerCallbackQuery", + new { callback_query_id = callbackId, text }, + cancellationToken + ); + + public async Task ReadUpdateAsync( + Stream body, + CancellationToken cancellationToken + ) + { + try + { + var dto = await JsonSerializer.DeserializeAsync( + 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( + 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(settings, "deleteWebhook", null, cancellationToken); + + private static object? Markup(IReadOnlyList>? 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 CallAsync( + 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(Json, cancellationToken); + } + + /// + /// Транспорт с прокси, если он задан. SOCKS5 поддерживает сам начиная + /// с .NET 6 — достаточно схемы в адресе, отдельной библиотеки не нужно. + /// + 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? 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); +} + +/// Ошибка обращения к Bot API: наружу уходит текстом, который показывает админка. +public sealed class TelegramApiException(string message) : Exception(message); diff --git a/backend/src/TeleWave.Infrastructure/Notifications/TelegramNotifierBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Notifications/TelegramNotifierBackgroundService.cs new file mode 100644 index 0000000..1b047ec --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Notifications/TelegramNotifierBackgroundService.cs @@ -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; + +/// +/// Тик рассылки: раз в минуту сверяет ленту с подписками. +/// +/// Минута — компромисс между «сейчас в эфире» и нагрузкой: точнее не нужно (сообщение о начале +/// программы с задержкой в полминуты остаётся правдой), а чаще — это лишние запросы к ленте +/// круглые сутки. +/// +public sealed class TelegramNotifierBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger 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(); + + var settings = await db.TelegramSettings.FirstOrDefaultAsync(cancellationToken); + if (settings is null || !settings.IsOperational) + return; + + var notifier = scope.ServiceProvider.GetRequiredService(); + var sent = await notifier.RunAsync(settings, DateTimeOffset.UtcNow, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + + if (sent > 0) + logger.LogInformation("Telegram: отправлено оповещений — {Count}", sent); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Notifications/TelegramPollingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Notifications/TelegramPollingBackgroundService.cs new file mode 100644 index 0000000..066923f --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Notifications/TelegramPollingBackgroundService.cs @@ -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; + +/// +/// Опрос обновлений (getUpdates) — режим для установки без публичного адреса. +/// +/// Служба крутится всегда, а не заводится по настройке: бота включают и выключают в рантайме, +/// и перезапускать контейнер ради этого никто не должен. Пока бот выключен или выбран вебхук, +/// цикл просто дремлет. +/// +public sealed class TelegramPollingBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger +) : BackgroundService +{ + /// Пауза, когда работать нечего: бот выключен, не настроен или выбран вебхук. + private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(30); + + /// Пауза после ошибки — чтобы недоступный прокси не превращался в поток запросов. + 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; + } + } + } + } + + /// Один заход опроса. Возвращает, сколько ждать перед следующим. + private async Task TickAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var settings = await db.TelegramSettings.FirstOrDefaultAsync(cancellationToken); + if ( + settings is null + || !settings.IsOperational + || settings.Transport != TelegramTransport.Polling + ) + return IdleDelay; + + var api = scope.ServiceProvider.GetRequiredService(); + var bot = scope.ServiceProvider.GetRequiredService(); + + IReadOnlyList 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; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index 49b2786..f62738a 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -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 options) public DbSet BumperAssets => Set(); public DbSet AppSettings => Set(); public DbSet Images => Set(); + public DbSet TelegramSettings => Set(); + public DbSet TelegramSubscribers => Set(); + public DbSet TelegramSubscriptions => Set(); + public DbSet TelegramLinkCodes => Set(); public Task BeginTransactionAsync(CancellationToken cancellationToken) => Database.BeginTransactionAsync(cancellationToken); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/TelegramConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/TelegramConfiguration.cs new file mode 100644 index 0000000..f0d45b2 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/TelegramConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.Code).IsUnique(); + builder.Property(x => x.Code).IsRequired().HasMaxLength(64); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs new file mode 100644 index 0000000..e7862cb --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs @@ -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; + +/// +/// Разговор с ботом. Главное свойство — подписаться можно только по одноразовому коду: рассылка +/// уходит в личные чаты, и «кто нашёл бота, тот и подписался» здесь означало бы чужие оповещения. +/// +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(); + + 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(), + 100, + Arg.Is(s => s.Contains("код")), + null, + Arg.Any() + ); + } + + [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()).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()).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()).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()).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(); + + 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); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs new file mode 100644 index 0000000..fa2bcdb --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs @@ -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; + +/// +/// Рассылка по ленте. Проверяется то, из-за чего бот становится спамом: оповещение шлётся +/// на смену шоу, а не на каждую серию, и только тем, кто подписан на этот канал. +/// +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(); + 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(), + Arg.Any(), + Arg.Any(), + Arg.Any>?>(), + Arg.Any() + ); + } + + [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(); + 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(), + 100, + Arg.Is(s => s.Contains("Футурама")), + null, + Arg.Any() + ); + + // Граница разобранного двигается всегда — иначе следующий тик оповестил бы о том же. + Assert.Equal(Now, settings.NotifiedUntil); + } +} diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md new file mode 100644 index 0000000..b5679e8 --- /dev/null +++ b/docs/telegram-bot.md @@ -0,0 +1,63 @@ +# Бот расписания в Telegram + +Оповещения зрителю: «на канале началось шоу» и «скоро начнётся». Настраивается в админке +(вкладка «Телеграм»), подписки собирает сам зритель кнопками в чате. + +## Что решено и почему + +**Подписка только по одноразовому коду.** Кнопка «Открыть чат с ботом» на странице эфира выдаёт +ссылку `t.me/бот?start=КОД`; код живёт 15 минут, сгорает при первом использовании и привязывает +чат к учётной записи TeleWave. Анонимный бот («кто нашёл, тот и подписался») означал бы рассылку +неизвестно кому и невозможность отписать чат вместе с блокировкой пользователя. Ссылка выдаётся +по нажатию, а не лежит в разметке: в ней пропуск к чужим оповещениям. + +**Два транспорта, переключателем.** `Polling` — сервер сам ходит за обновлениями: работает из-за +NAT и через прокси, и это единственный вариант там, где публичного адреса нет. `Webhook` — Telegram +стучится на наш HTTPS-эндпоинт: дешевле и быстрее, но требует доступного извне домена, и прокси +здесь не поможет — соединение устанавливает Telegram. Разговор с ботом при этом один и тот же: +оба транспорта приносят одинаковое обновление и разбираются общим кодом. + +**Прокси — часть настроек, а не окружения.** HTTP или SOCKS5 с логином и паролем; клиент собирается +на каждый вызов, поэтому смена прокси не требует перезапуска контейнера. Кривой адрес — ошибка, а +не повод молча пойти напрямую: «работает в обход прокси» на канале, ради которого прокси и заводили, +хуже отказа. + +**Секреты в базе, наружу не отдаются.** Токен и пароль прокси лежат открытым текстом: шифровать их +нечем — Data Protection в проекте сознательно не заводили (см. CLAUDE.md). Наружу уходит только +признак «задан», а пустое поле в форме означает «оставить как есть». Секрет вебхука генерируется +сам и проверяется заголовком `X-Telegram-Bot-Api-Secret-Token`: адрес рано или поздно окажется +в чужих логах, и без секрета туда постучится кто угодно. + +**Проверка связи — на сохранении.** Настройки сразу спрашивают у Telegram `getMe`: неверный токен +или мёртвый прокси иначе всплыли бы через сутки молчания бота. Оттуда же приходит имя бота — без +него не собрать ссылку на чат, поэтому кнопка у зрителя появляется только после успешного контакта. +Вебхук ставится и снимается тем же сохранением: пока он стоит, Telegram не отдаёт обновления +опросом, и переключение режима без этого оставило бы бота немым. + +## Оповещения + +Считаются по материализованной ленте, а не по факту раздачи: лента и есть эфир, а ждать +подтверждения от плеера не от кого — зрителей может не быть вовсе. + +- **Начало шоу** — на смену шоу, а не на каждую запись: четыре серии подряд это одна программа + в глазах зрителя, и четыре сообщения о ней читаются как спам. +- **Скоро в эфире** — за пять минут до следующей программы. + +Граница разобранного (`TelegramSettings.NotifiedUntil`) двигается на каждом тике, даже когда +подписчиков нет: иначе первый же подписавшийся получил бы пачку сообщений про всё, что успело +пройти. Догон ограничен пятнадцатью минутами — после долгого простоя бот не станет пересказывать +вчерашний эфир. + +## Меню в чате + +`/start` с кодом привязывает чат и открывает меню; `/start` без кода — открывает меню уже +привязанному. Меню живёт **одним сообщением**: нажатие кнопки переписывает его на месте, иначе +чат заполняется одинаковыми «ваши подписки» после каждого клика. Кнопка — тумблер: повторное +нажатие снимает подписку. `/stop` останавливает рассылку, не стирая подписки: вернувшись, зритель +получит свои каналы, а не пустой список. + +## Границы + +- Бот не отвечает на произвольные вопросы и не показывает программу передач — только подписки. +- Групповые чаты не поддерживаются: подписка привязана к учётке, а в группе её владельца нет. +- Один чат — один подписчик; повторная привязка по новому коду меняет учётку, а не плодит копии. diff --git a/frontend/src/features/admin/telegram/TelegramPanel.tsx b/frontend/src/features/admin/telegram/TelegramPanel.tsx new file mode 100644 index 0000000..825f938 --- /dev/null +++ b/frontend/src/features/admin/telegram/TelegramPanel.tsx @@ -0,0 +1,287 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useApiError } from '@/shared/lib/use-api-error' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { getTelegramSettings, listTelegramSubscribers, updateTelegramSettings } from './api' +import type { TelegramProxyKind, TelegramSettingsBody, TelegramTransport } from './types' + +const EMPTY: TelegramSettingsBody = { + isEnabled: false, + transport: 'Polling', + botToken: null, + webhookUrl: null, + proxyKind: 'None', + proxyHost: null, + proxyPort: null, + proxyUsername: null, + proxyPassword: null, +} + +/** Пустая строка формы — это «не задано», а для секретов ещё и «не менять». */ +function trimmed(value: string): string | null { + const next = value.trim() + return next.length === 0 ? null : next +} + +/** + * Бот расписания: токен, способ доставки обновлений, прокси и список подписчиков. + * + * Сохранение сразу проверяет связь — Telegram отвечает на getMe, и неверный токен или мёртвый + * прокси видно тут же, а не через сутки молчания бота. + */ +export function TelegramPanel() { + const { t } = useTranslation() + const onError = useApiError() + const queryClient = useQueryClient() + const [form, setForm] = useState(EMPTY) + + const { data: settings, isLoading } = useQuery({ + queryKey: ['telegram', 'settings'], + queryFn: getTelegramSettings, + }) + + const { data: subscribers } = useQuery({ + queryKey: ['telegram', 'subscribers'], + queryFn: listTelegramSubscribers, + }) + + // Форма наполняется тем, что вернул сервер; секреты остаются пустыми — их он не отдаёт. + useEffect(() => { + if (!settings) return + setForm({ + isEnabled: settings.isEnabled, + transport: settings.transport, + botToken: null, + webhookUrl: settings.webhookUrl, + proxyKind: settings.proxyKind, + proxyHost: settings.proxyHost, + proxyPort: settings.proxyPort, + proxyUsername: settings.proxyUsername, + proxyPassword: null, + }) + }, [settings]) + + const save = useMutation({ + mutationFn: () => updateTelegramSettings(form), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['telegram'] }) + toast.success(t('admin.telegram.saved')) + }, + onError, + }) + + const patch = (part: Partial) => setForm((prev) => ({ ...prev, ...part })) + + if (isLoading) return

{t('common.loading')}

+ + return ( +
+
+

{t('admin.telegram.title')}

+ {settings?.botUsername && @{settings.botUsername}} + {settings?.isEnabled ? ( + {t('admin.telegram.enabled')} + ) : ( + {t('admin.telegram.disabled')} + )} + + {t('admin.telegram.subscribers')}: {settings?.subscribers ?? 0} + +
+ + {settings?.lastError && ( +

+ {settings.lastError} +

+ )} + +
+ + +
+
+ + patch({ botToken: trimmed(e.target.value) })} + /> +

{t('admin.telegram.tokenHint')}

+
+ +
+ + +

+ {t(`admin.telegram.transportHint.${form.transport}`)} +

+
+
+ + {form.transport === 'Webhook' && ( +
+ + patch({ webhookUrl: trimmed(e.target.value) })} + /> +

{t('admin.telegram.webhookHint')}

+
+ )} + +
+
+ + +

{t('admin.telegram.proxyHint')}

+
+ + {form.proxyKind !== 'None' && ( +
+
+ + patch({ proxyHost: trimmed(e.target.value) })} + /> +
+
+ + patch({ proxyPort: Number(e.target.value) || null })} + /> +
+
+ )} +
+ + {form.proxyKind !== 'None' && ( +
+
+ + patch({ proxyUsername: trimmed(e.target.value) })} + /> +
+
+ + patch({ proxyPassword: trimmed(e.target.value) })} + /> +
+
+ )} + +
+ + {settings?.lastContactAt && ( + + {t('admin.telegram.lastContact')}: {new Date(settings.lastContactAt).toLocaleString()} + + )} +
+
+ +
+

+ {t('admin.telegram.subscribers')} +

+
+ + + + + + + + + + + {(subscribers ?? []).map((s) => ( + + + + + + + ))} + {(subscribers ?? []).length === 0 && ( + + + + )} + +
{t('admin.telegram.user')}{t('admin.telegram.chat')}{t('admin.telegram.channels')}{t('admin.telegram.lastSeen')}
+ {s.userName ?? '—'} + {s.isStopped && ( + + {t('admin.telegram.stopped')} + + )} + + {s.displayName ? `@${s.displayName}` : s.chatId} + + {s.channels.length === 0 + ? '—' + : s.channels.map((c) => `${c.channelName} (${c.kinds.length})`).join(', ')} + + {s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : '—'} +
+ {t('admin.telegram.noSubscribers')} +
+
+
+
+ ) +} diff --git a/frontend/src/features/admin/telegram/api.ts b/frontend/src/features/admin/telegram/api.ts new file mode 100644 index 0000000..93d3fab --- /dev/null +++ b/frontend/src/features/admin/telegram/api.ts @@ -0,0 +1,19 @@ +import { apiRequest } from '@/shared/api/client' +import type { TelegramSettingsBody, TelegramSettingsDto, TelegramSubscriberDto } from './types' + +export function getTelegramSettings() { + return apiRequest('/admin/telegram/settings') +} + +export function updateTelegramSettings(body: TelegramSettingsBody) { + return apiRequest('/admin/telegram/settings', { method: 'PUT', body }) +} + +export function listTelegramSubscribers() { + return apiRequest('/admin/telegram/subscribers') +} + +/** Ссылка на чат с одноразовым кодом — её открывает кнопка на странице эфира. */ +export function issueTelegramLink() { + return apiRequest<{ url: string; expiresAt: string }>('/telegram/link', { method: 'POST' }) +} diff --git a/frontend/src/features/admin/telegram/types.ts b/frontend/src/features/admin/telegram/types.ts new file mode 100644 index 0000000..fa10129 --- /dev/null +++ b/frontend/src/features/admin/telegram/types.ts @@ -0,0 +1,44 @@ +export type TelegramTransport = 'Polling' | 'Webhook' +export type TelegramProxyKind = 'None' | 'Http' | 'Socks5' + +export type TelegramSettingsDto = { + isEnabled: boolean + /** Токен наружу не отдаётся — только признак, что он задан. */ + hasToken: boolean + botUsername: string | null + botLink: string | null + transport: TelegramTransport + webhookUrl: string | null + proxyKind: TelegramProxyKind + proxyHost: string | null + proxyPort: number | null + proxyUsername: string | null + hasProxyPassword: boolean + lastContactAt: string | null + lastError: string | null + subscribers: number +} + +/** Пустые токен и пароль означают «оставить как есть»: форма их не показывает. */ +export type TelegramSettingsBody = { + isEnabled: boolean + transport: TelegramTransport + botToken: string | null + webhookUrl: string | null + proxyKind: TelegramProxyKind + proxyHost: string | null + proxyPort: number | null + proxyUsername: string | null + proxyPassword: string | null +} + +export type TelegramSubscriberDto = { + id: string + chatId: number + displayName: string | null + userName: string | null + isStopped: boolean + createdAt: string + lastSeenAt: string | null + channels: { channelId: string; channelName: string; kinds: string[] }[] +} diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index fa07579..e70b96f 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Download, Radio, RotateCw } from 'lucide-react' +import { Download, Send, Radio, RotateCw } from 'lucide-react' import { qk } from '@/shared/api/query-keys' import type { PublicEpgEntryDto } from '@/shared/api/types' import { cn } from '@/shared/lib/cn' @@ -11,6 +11,8 @@ import { toast } from '@/shared/ui/toast-store' import { ChannelPlayer } from './ChannelPlayer' import { downloadIptvPlaylist, + getTelegramStatus, + issueTelegramLink, getEpg, getViewerFeatures, imageUrl, @@ -80,6 +82,25 @@ export function AirPage() { }) const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures }) + // Настройки бота читаем публичной частью: кнопка нужна только когда бот действительно настроен. + const { data: telegram } = useQuery({ + queryKey: qk.air.telegram, + queryFn: getTelegramStatus, + }) + + /** + * Ссылка на чат выдаётся по нажатию, а не заранее: в ней одноразовый код привязки, и держать + * его в разметке страницы всё время незачем. + */ + const openBot = async () => { + try { + const { url } = await issueTelegramLink() + window.open(url, '_blank', 'noopener') + } catch { + toast.error(t('admin.telegram.linkFailed')) + } + } + const numbersEnabled = features?.channelNumbersEnabled ?? false const currentChannel = channels?.find((c) => c.slug === selected) @@ -193,6 +214,12 @@ export function AirPage() { {numbersEnabled && ( {t('air.numbersHint')} )} + {telegram?.isConfigured && ( + + )}