From d6bc2b15ac15a8929bd4caffd2d6b5269b9b8829 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 31 Jul 2026 08:24:42 +0300 Subject: [PATCH] Enhance Telegram bot functionality with message management and new API methods Updated the ITelegramApi interface to include a method for deleting messages and modified SendMessageAsync to return the message ID. Enhanced the TelegramBotService to manage message visibility by replacing previous messages with new ones, ensuring a cleaner user experience. Introduced properties in TelegramSubscriber to track the latest menu and notice message IDs. Updated related tests to verify the new message management behavior and adjusted documentation to reflect these changes. --- .../Common/Interfaces/ITelegramApi.cs | 14 +- .../Notifications/TelegramBotService.cs | 81 +- .../Notifications/TelegramSubscriber.cs | 18 + ...0731052216_TelegramBotMessages.Designer.cs | 1599 +++++++++++++++++ .../20260731052216_TelegramBotMessages.cs | 36 + .../Migrations/AppDbContextModelSnapshot.cs | 6 + .../Notifications/TelegramApi.cs | 36 +- .../Notifications/TelegramBotTests.cs | 61 + docs/telegram-bot.md | 8 + 9 files changed, 1835 insertions(+), 24 deletions(-) create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260731052216_TelegramBotMessages.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260731052216_TelegramBotMessages.cs diff --git a/backend/src/TeleWave.Application/Common/Interfaces/ITelegramApi.cs b/backend/src/TeleWave.Application/Common/Interfaces/ITelegramApi.cs index 0ce923c..e919e34 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/ITelegramApi.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/ITelegramApi.cs @@ -40,7 +40,8 @@ public interface ITelegramApi CancellationToken cancellationToken ); - Task SendMessageAsync( + /// Отправляет сообщение и возвращает его идентификатор — по нему потом правят и удаляют. + Task SendMessageAsync( TelegramSettings settings, long chatId, string text, @@ -58,6 +59,17 @@ public interface ITelegramApi CancellationToken cancellationToken ); + /// + /// Снимает своё сообщение. В личном чате бот вправе удалять только собственные — команды + /// зрителя остаются, и это не в нашей власти. + /// + Task DeleteMessageAsync( + TelegramSettings settings, + long chatId, + long messageId, + CancellationToken cancellationToken + ); + /// Гасит «часики» на нажатой кнопке; текст — всплывающая подсказка. Task AnswerCallbackAsync( TelegramSettings settings, diff --git a/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs b/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs index 096a1f0..bdb8dfd 100644 --- a/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs +++ b/backend/src/TeleWave.Application/Notifications/TelegramBotService.cs @@ -60,9 +60,13 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api // Любое другое сообщение — это «я не знаю, что нажать»: показываем меню, если чат известен. 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); + { + await NoticeAsync(settings, null, chatId, Texts.NeedLink, cancellationToken); + return; + } + + await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); } /// @@ -89,20 +93,14 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api // Уже привязанный чат просто открывает меню: заставлять его снова ходить за кодом незачем. if (subscriber is null) { - await api.SendMessageAsync( - settings, - chatId, - Texts.NeedLink, - null, - cancellationToken - ); + await NoticeAsync(settings, null, chatId, Texts.NeedLink, cancellationToken); return; } subscriber.Resume(); subscriber.Touch(now); - await dbContext.SaveChangesAsync(cancellationToken); await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); return; } @@ -112,7 +110,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api ); if (link is null || !link.IsUsable(now)) { - await api.SendMessageAsync(settings, chatId, Texts.BadCode, null, cancellationToken); + await NoticeAsync(settings, subscriber, chatId, Texts.BadCode, cancellationToken); return; } @@ -128,9 +126,9 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api subscriber.Rebind(link.UserId, from, now); } - await dbContext.SaveChangesAsync(cancellationToken); - await api.SendMessageAsync(settings, chatId, Texts.Linked, null, cancellationToken); + await NoticeAsync(settings, subscriber, chatId, Texts.Linked, cancellationToken); await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); } private async Task HandleStopAsync( @@ -145,8 +143,8 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api // Подписки не трогаем: вернувшись по /start, зритель получит свои каналы, а не пустой список. subscriber.Stop(); + await NoticeAsync(settings, subscriber, chatId, Texts.Stopped, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - await api.SendMessageAsync(settings, chatId, Texts.Stopped, null, cancellationToken); } private async Task HandleCallbackAsync( @@ -188,7 +186,8 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api subscriber.Touch(now); await dbContext.SaveChangesAsync(cancellationToken); await AnswerAsync(settings, update, null, cancellationToken); - await SendScheduleAsync(settings, channel, chatId, now, cancellationToken); + await SendScheduleAsync(settings, subscriber, channel, chatId, now, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); return; } @@ -220,6 +219,28 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api update.CallbackMessageId, cancellationToken ); + await dbContext.SaveChangesAsync(cancellationToken); + } + + /// + /// Одноразовое сообщение бота: программа передач, подтверждение привязки, отказ. Предыдущее + /// такое же снимается — три программы на разное время в чате не нужны никому. + /// + private async Task NoticeAsync( + TelegramSettings settings, + TelegramSubscriber? subscriber, + long chatId, + string text, + CancellationToken cancellationToken + ) + { + if (subscriber?.NoticeMessageId is { } previous) + await api.DeleteMessageAsync(settings, chatId, previous, cancellationToken); + + var messageId = await api.SendMessageAsync(settings, chatId, text, null, cancellationToken); + + // Незнакомому чату помнить нечего: подписчика ещё нет, и следующее сообщение будет первым. + subscriber?.SetNoticeMessage(messageId); } private Task AnswerAsync( @@ -254,7 +275,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api if (channels.Count == 0) { - await api.SendMessageAsync(settings, chatId, Texts.NoChannels, null, cancellationToken); + await NoticeAsync(settings, subscriber, chatId, Texts.NoChannels, cancellationToken); return; } @@ -273,7 +294,10 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api ]); } + // Нажали кнопку — правим то же сообщение. Пришли заново — старое меню снимаем: две панели + // с разными галочками в одном чате означают, что одна из них врёт. if (messageId is { } id) + { await api.EditMessageAsync( settings, chatId, @@ -282,8 +306,21 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api keyboard, cancellationToken ); - else - await api.SendMessageAsync(settings, chatId, Texts.Menu, keyboard, cancellationToken); + subscriber.SetMenuMessage(id); + return; + } + + if (subscriber.MenuMessageId is { } previous) + await api.DeleteMessageAsync(settings, chatId, previous, cancellationToken); + + var sent = await api.SendMessageAsync( + settings, + chatId, + Texts.Menu, + keyboard, + cancellationToken + ); + subscriber.SetMenuMessage(sent); } private static TelegramButton Button( @@ -310,6 +347,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api /// private async Task SendScheduleAsync( TelegramSettings settings, + TelegramSubscriber subscriber, Channel channel, long chatId, DateTimeOffset now, @@ -340,7 +378,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api if (entries.Count == 0) { - await api.SendMessageAsync(settings, chatId, Texts.NoSchedule, null, cancellationToken); + await NoticeAsync(settings, subscriber, chatId, Texts.NoSchedule, cancellationToken); return; } @@ -396,6 +434,9 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api public const string Menu = "Оповещения\nНажмите, чтобы включить или выключить. ✅ — включено.\n" + // Кнопка-заголовок ничем не выдаёт, что она кнопка: про программу передач надо сказать + // словами, иначе её найдут только промахнувшись мимо тумблера. + + "Программа передач — нажмите на название канала.\n" + "Остановить всё — командой /stop."; public const string NoChannels = "Пока нет ни одного включённого канала."; diff --git a/backend/src/TeleWave.Domain/Notifications/TelegramSubscriber.cs b/backend/src/TeleWave.Domain/Notifications/TelegramSubscriber.cs index a7e8424..eff9aec 100644 --- a/backend/src/TeleWave.Domain/Notifications/TelegramSubscriber.cs +++ b/backend/src/TeleWave.Domain/Notifications/TelegramSubscriber.cs @@ -33,6 +33,19 @@ public class TelegramSubscriber /// public bool IsStopped { get; private set; } + /// + /// Последнее меню подписок в чате. Бот убирает за собой: при новом /start старое меню + /// снимается, иначе чат превращается в ленту одинаковых панелей, и непонятно, какая из них + /// показывает правду. + /// + public long? MenuMessageId { get; private set; } + + /// + /// Последнее одноразовое сообщение бота — программа передач, подтверждение привязки. Живёт + /// до следующего такого же: держать в чате три программы на разное время бессмысленно. + /// + public long? NoticeMessageId { get; private set; } + public IReadOnlyList Subscriptions => _subscriptions; private TelegramSubscriber() { } @@ -64,6 +77,11 @@ public class TelegramSubscriber public void Touch(DateTimeOffset now) => LastSeenAt = now; + /// Запоминает, каким сообщением сейчас показано меню (null — его в чате нет). + public void SetMenuMessage(long? messageId) => MenuMessageId = messageId; + + public void SetNoticeMessage(long? messageId) => NoticeMessageId = messageId; + public void Stop() => IsStopped = true; public void Resume() => IsStopped = false; diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260731052216_TelegramBotMessages.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260731052216_TelegramBotMessages.Designer.cs new file mode 100644 index 0000000..71323c3 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260731052216_TelegramBotMessages.Designer.cs @@ -0,0 +1,1599 @@ +// +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("20260731052216_TelegramBotMessages")] + partial class TelegramBotMessages + { + /// + 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("MenuMessageId") + .HasColumnType("bigint"); + + b.Property("NoticeMessageId") + .HasColumnType("bigint"); + + 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/20260731052216_TelegramBotMessages.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260731052216_TelegramBotMessages.cs new file mode 100644 index 0000000..1c80082 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260731052216_TelegramBotMessages.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class TelegramBotMessages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MenuMessageId", + table: "TelegramSubscribers", + type: "bigint", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "NoticeMessageId", + table: "TelegramSubscribers", + type: "bigint", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "MenuMessageId", table: "TelegramSubscribers"); + + migrationBuilder.DropColumn(name: "NoticeMessageId", table: "TelegramSubscribers"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index f87acb2..7d01512 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -850,6 +850,12 @@ namespace TeleWave.Infrastructure.Migrations b.Property("LastSeenAt") .HasColumnType("timestamp with time zone"); + b.Property("MenuMessageId") + .HasColumnType("bigint"); + + b.Property("NoticeMessageId") + .HasColumnType("bigint"); + b.Property("UserId") .HasColumnType("uuid"); diff --git a/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs b/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs index 984a177..eb931b2 100644 --- a/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs +++ b/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs @@ -65,14 +65,15 @@ public sealed class TelegramApi(ILogger logger) : ITelegramApi return result?.Result?.Select(Map).ToList() ?? []; } - public Task SendMessageAsync( + public async Task SendMessageAsync( TelegramSettings settings, long chatId, string text, IReadOnlyList>? keyboard, CancellationToken cancellationToken - ) => - CallAsync( + ) + { + var result = await CallAsync( settings, "sendMessage", new @@ -85,6 +86,33 @@ public sealed class TelegramApi(ILogger logger) : ITelegramApi cancellationToken ); + return result?.Result?.MessageId; + } + + public async Task DeleteMessageAsync( + TelegramSettings settings, + long chatId, + long messageId, + CancellationToken cancellationToken + ) + { + try + { + await CallAsync( + settings, + "deleteMessage", + new { chat_id = chatId, message_id = messageId }, + cancellationToken + ); + } + catch (TelegramApiException exception) + { + // Сообщение мог удалить сам зритель, или ему больше 48 часов — Telegram столько + // и хранит право на удаление. Уборка не та задача, ради которой стоит рвать разговор. + logger.LogDebug(exception, "Не удалось снять сообщение {MessageId}", messageId); + } + } + public Task EditMessageAsync( TelegramSettings settings, long chatId, @@ -269,6 +297,8 @@ public sealed class TelegramApi(ILogger logger) : ITelegramApi private sealed record OkResult(bool Ok); + private sealed record MessageResult(bool Ok, MessageDto? Result); + private sealed record MeResult(bool Ok, MeDto? Result); private sealed record MeDto(long Id, string Username); diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs index 8f23779..6715952 100644 --- a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs @@ -235,6 +235,67 @@ public class TelegramBotTests Assert.Equal(1, sent!.Split("Симпсоны").Length - 1); } + [Fact] + public async Task Menu_ReplacesThePreviousOne() + { + // Два меню в чате — это две панели с разными галочками, и одна из них врёт. Старое снимаем. + 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 api = Substitute.For(); + api.SendMessageAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>?>(), + Arg.Any() + ) + .Returns(777L); + + await using (var db = fixture.New()) + { + await new TelegramBotService(db, api).HandleAsync( + Settings(), + Message(100, "/start"), + Now, + CancellationToken.None + ); + } + + await using (var check = fixture.New()) + { + Assert.Equal(777L, check.TelegramSubscribers.Single().MenuMessageId); + } + + await using (var db = fixture.New()) + { + await new TelegramBotService(db, api).HandleAsync( + Settings(), + Message(100, "/start"), + Now, + CancellationToken.None + ); + } + + // Второй заход снял предыдущее меню, а не оставил его висеть. + await api.Received(1) + .DeleteMessageAsync( + Arg.Any(), + 100, + 777L, + Arg.Any() + ); + } + [Fact] public async Task Callback_FromUnknownChat_IsRefused() { diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index c5d4e47..ba12beb 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -60,6 +60,14 @@ NAT и через прокси, и это единственный вариан нажатие снимает подписку. `/stop` останавливает рассылку, не стирая подписки: вернувшись, зритель получит свои каналы, а не пустой список. +**Бот убирает за собой.** В чате живут ровно два его сообщения: меню подписок и последнее +одноразовое — программа передач, подтверждение привязки или отказ. Новое меню снимает предыдущее +(две панели с разными галочками означают, что одна врёт), новая программа — предыдущую программу. +Идентификаторы обоих сообщений хранятся у подписчика. Команды зрителя остаются: в личном чате бот +вправе удалять только собственные сообщения, и права на чужие у него нет и не будет. Не удалившееся +сообщение (зритель убрал его сам, прошло больше 48 часов) уборку не срывает — это не та задача, +ради которой стоит рвать разговор. + Данные кнопок несут действие: `sub:канал:вид` переключает подписку, `epg:канал` показывает программу. Действие в первом поле, а не «угадаем по числу частей»: кнопок со временем станет больше, и разбор по длине строки сломался бы на первой же новой.