diff --git a/BotPages.Core/Abstractions/Capabilities.cs b/BotPages.Core/Abstractions/Capabilities.cs new file mode 100644 index 0000000..1decc18 --- /dev/null +++ b/BotPages.Core/Abstractions/Capabilities.cs @@ -0,0 +1,25 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Возможности мессенджера (inline кнопки, альбомы, форматирование и т.д.). +/// +public sealed class Capabilities +{ + /// Поддержка inline-кнопок. + public bool SupportsInlineButtons { get; init; } + + /// Поддержка reply-кнопок. + public bool SupportsReplyButtons { get; init; } + + /// Поддержка альбомов (медиагрупп). + public bool SupportsAlbums { get; init; } + + /// Поддержка форматирования Markdown. + public bool SupportsFormattingMarkdown { get; init; } + + /// Поддержка форматирования HTML. + public bool SupportsFormattingHtml { get; init; } + + /// Максимальная длина сообщения. + public int MaxMessageLength { get; init; } = 4096; +} diff --git a/BotPages.Core/Abstractions/CompositeSessionKey.cs b/BotPages.Core/Abstractions/CompositeSessionKey.cs new file mode 100644 index 0000000..0190701 --- /dev/null +++ b/BotPages.Core/Abstractions/CompositeSessionKey.cs @@ -0,0 +1,6 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Ключ для идентификации пользовательской сессии. +/// +public readonly record struct CompositeSessionKey(string MessengerType, string ChatId, string? UserId); diff --git a/BotPages.Core/Abstractions/FileDescriptor.cs b/BotPages.Core/Abstractions/FileDescriptor.cs new file mode 100644 index 0000000..7d08336 --- /dev/null +++ b/BotPages.Core/Abstractions/FileDescriptor.cs @@ -0,0 +1,22 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Описание файла, полученного или отправляемого через мессенджер. +/// +public sealed class FileDescriptor +{ + /// Идентификатор файла в мессенджере. + public required string Id { get; init; } + /// Имя файла. + public required string Name { get; init; } + /// Расширение файла. + public required string Extension { get; init; } + /// Размер файла в байтах. + public long Size { get; init; } + /// MIME-тип файла. + public string? Mime { get; init; } + /// Тип файла. + public FileKind Kind { get; init; } = FileKind.Document; + /// Функция получения потока файла. + public Func> GetStreamAsync { get; init; } = _ => Task.FromResult(Stream.Null); +} diff --git a/BotPages.Core/Abstractions/FileKind.cs b/BotPages.Core/Abstractions/FileKind.cs new file mode 100644 index 0000000..d58f937 --- /dev/null +++ b/BotPages.Core/Abstractions/FileKind.cs @@ -0,0 +1,20 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Тип файла. +/// +public enum FileKind +{ + /// Фото + Photo, + /// Документ + Document, + /// Аудио + Audio, + /// Видео + Video, + /// Стикер + Sticker, + /// Остальное + Other, +} diff --git a/BotPages.Core/Abstractions/IAlbumBuilder.cs b/BotPages.Core/Abstractions/IAlbumBuilder.cs new file mode 100644 index 0000000..4566fc8 --- /dev/null +++ b/BotPages.Core/Abstractions/IAlbumBuilder.cs @@ -0,0 +1,12 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Билдер отправки медиагруппы (альбома). +/// +public interface IAlbumBuilder +{ + /// Добавить элемент в альбом. + IAlbumBuilder Add(FileDescriptor file, string? caption = null); + /// Отправить альбом. + Task SendAsync(CancellationToken ct = default); +} \ No newline at end of file diff --git a/BotPages.Core/Abstractions/IMessengerAdapter.cs b/BotPages.Core/Abstractions/IMessengerAdapter.cs new file mode 100644 index 0000000..08e74b3 --- /dev/null +++ b/BotPages.Core/Abstractions/IMessengerAdapter.cs @@ -0,0 +1,42 @@ +using BotPages.Core.Messaging; + +namespace BotPages.Core.Abstractions; + +/// +/// Контракт адаптера мессенджера. +/// Определяет операции отправки сообщений, файлов и прогресса. +/// +public interface IMessengerAdapter +{ + /// + /// Отправить текстовое сообщение в чат. + /// + Task SendTextAsync(PageContext ctx, string text, MessageFormat format, + IEnumerable>? inline, + IEnumerable>? reply, CancellationToken ct); + + /// + /// Отправить файл в чат. + /// + Task SendFileAsync(PageContext ctx, FileDescriptor file, string? caption, CancellationToken ct); + + /// + /// Создать билдер альбома для отправки медиагруппы. + /// + IAlbumBuilder CreateAlbumBuilder(PageContext ctx); + + /// + /// Начать отображение прогресса операции. + /// + Task StartProgressAsync(PageContext ctx, string title, CancellationToken ct); + + /// + /// Обновить прогресс операции. + /// + Task UpdateProgressAsync(PageContext ctx, string messageId, string title, int percent, CancellationToken ct); + + /// + /// Вызывается при выходе со страницы. + /// + Task OnLeaveAsync(PageContext ctx, CancellationToken ct); +} diff --git a/BotPages.Core/Abstractions/IMessengerAdapterFactory.cs b/BotPages.Core/Abstractions/IMessengerAdapterFactory.cs new file mode 100644 index 0000000..055a46e --- /dev/null +++ b/BotPages.Core/Abstractions/IMessengerAdapterFactory.cs @@ -0,0 +1,25 @@ +using BotPages.Core.Context; + +namespace BotPages.Core.Abstractions; + +/// +/// Фабрика адаптеров мессенджеров. +/// Используется для разрешения конкретного по типу мессенджера. +/// +public interface IMessengerAdapterFactory +{ + /// + /// Получить адаптер для указанного мессенджера. + /// + /// + /// Тип мессенджера (например, "Telegram", "Slack", "VK"). + /// Значение должно совпадать с .. + /// + /// + /// Экземпляр , зарегистрированный для данного типа мессенджера. + /// + /// + /// Выбрасывается, если адаптер для указанного типа не зарегистрирован. + /// + IMessengerAdapter Resolve(string messengerType); +} diff --git a/BotPages.Core/Abstractions/IPageMiddleware.cs b/BotPages.Core/Abstractions/IPageMiddleware.cs new file mode 100644 index 0000000..a106eb0 --- /dev/null +++ b/BotPages.Core/Abstractions/IPageMiddleware.cs @@ -0,0 +1,11 @@ +namespace BotPages.Core.Abstractions; +/// +/// Интерфейс middleware для обработки входящих обновлений. +/// +public interface IPageMiddleware +{ + /// + /// Выполнить промежуточную логику, затем вызвать следующий обработчик. + /// + Task InvokeAsync(PageContext ctx, Func next, CancellationToken ct); +} diff --git a/BotPages.Core/Abstractions/IStateStorage.cs b/BotPages.Core/Abstractions/IStateStorage.cs new file mode 100644 index 0000000..28c2deb --- /dev/null +++ b/BotPages.Core/Abstractions/IStateStorage.cs @@ -0,0 +1,20 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Интерфейс универсального хранилища состояния. +/// Позволяет сохранять и восстанавливать данные между обновлениями. +/// +public interface IStateStorage +{ + /// Получить состояние по ключу. + Task GetAsync(CompositeSessionKey session, string key, CancellationToken ct); + + /// Сохранить состояние по ключу. + Task SetAsync(CompositeSessionKey session, string key, T state, CancellationToken ct); + + /// Удалить состояние по ключу. + Task RemoveAsync(CompositeSessionKey session, string key, CancellationToken ct); + + /// Удалить все состояния по ключу. + Task ClearAsync(CompositeSessionKey session, CancellationToken ct); +} \ No newline at end of file diff --git a/BotPages.Core/Abstractions/MessageFormat.cs b/BotPages.Core/Abstractions/MessageFormat.cs new file mode 100644 index 0000000..0844732 --- /dev/null +++ b/BotPages.Core/Abstractions/MessageFormat.cs @@ -0,0 +1,14 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Форматирование текста сообщения. +/// +public enum MessageFormat +{ + /// Обычный текст без форматирования. + Plain, + /// Markdown форматирование. + Markdown, + /// HTML форматирование. + Html +} \ No newline at end of file diff --git a/BotPages.Core/Abstractions/MultiAdapterFactory.cs b/BotPages.Core/Abstractions/MultiAdapterFactory.cs new file mode 100644 index 0000000..7ad8be3 --- /dev/null +++ b/BotPages.Core/Abstractions/MultiAdapterFactory.cs @@ -0,0 +1,45 @@ +namespace BotPages.Core.Abstractions; + + +/// +/// Реализация , позволяющая регистрировать и разрешать несколько адаптеров мессенджеров. +/// +public sealed class MultiAdapterFactory : IMessengerAdapterFactory +{ + private readonly Dictionary _adapters = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Зарегистрировать адаптер для указанного типа мессенджера. + /// + /// + /// Тип мессенджера (например, "Telegram", "Slack", "VK"). + /// + /// + /// Экземпляр адаптера, реализующий . + /// + /// + /// Текущий экземпляр для цепочки вызовов. + /// + public MultiAdapterFactory Register(string messengerType, IMessengerAdapter adapter) + { + _adapters[messengerType] = adapter; + return this; + } + + /// + /// Получить адаптер для указанного мессенджера. + /// + /// + /// Тип мессенджера (например, "Telegram", "Slack", "VK"). + /// + /// + /// Экземпляр , зарегистрированный для данного типа мессенджера. + /// + /// + /// Выбрасывается, если адаптер для указанного типа не зарегистрирован. + /// + public IMessengerAdapter Resolve(string messengerType) + => _adapters.TryGetValue(messengerType, out var adapter) + ? adapter + : throw new InvalidOperationException($"No adapter registered for {messengerType}"); +} diff --git a/BotPages.Core/BotPages.Core.csproj b/BotPages.Core/BotPages.Core.csproj index fa71b7a..a790aaf 100644 --- a/BotPages.Core/BotPages.Core.csproj +++ b/BotPages.Core/BotPages.Core.csproj @@ -1,9 +1,22 @@  - - net8.0 - enable - enable - + + net8.0 + enable + enable + true + BotPages.Core + 1.0.0 + FrigaT + FrigaT + BotPages + Платформонезависимое ядро для создания диалоговых ботов с системой страниц. Часть проекта BotPages. + Copyright © 2025 FrigaT + https://git.frigat.duckdns.org/FrigaT/BotPages + git + https://git.frigat.duckdns.org/FrigaT/BotPages + README.md + MIT + - + \ No newline at end of file diff --git a/BotPages.Core/BotPagesApp.cs b/BotPages.Core/BotPagesApp.cs new file mode 100644 index 0000000..c9958c1 --- /dev/null +++ b/BotPages.Core/BotPagesApp.cs @@ -0,0 +1,176 @@ +namespace BotPages.Core; + +using BotPages.Core.Abstractions; +using BotPages.Core.Context; +using BotPages.Core.Logging; +using BotPages.Core.Routing; + +/// +/// Основное приложение BotPages. +/// Управляет маршрутизацией, командами, middleware и страницами. +/// +public sealed class BotPagesApp +{ + private readonly IMessengerAdapterFactory _adapterFactory; + private readonly List _middlewares = new(); + private readonly RoutesRegistry _routes = new(); + private readonly CommandsRegistry _commands = new(); + private readonly IStateStorage _state; + private readonly ILogger _logger; + private readonly NavigationService _navigation; + + /// + /// Создать приложение BotPages. + /// + public BotPagesApp(IMessengerAdapterFactory adapterFactory, IStateStorage state, ILogger logger) + { + _adapterFactory = adapterFactory; + _state = state; + _logger = logger; + + _navigation = new NavigationService(_routes); + } + + /// + /// Установить страницу по умолчанию. + /// + public BotPagesApp AddDefaultPage() where TPage : Page + { + _navigation.AddDefaultPage(); + return this; + } + + /// + /// Добавить middleware. + /// + public BotPagesApp AddMiddleware(TMiddleware instance) where TMiddleware : IPageMiddleware + { + _middlewares.Add(instance); + return this; + } + + /// + /// Зарегистрировать команду, ведущую на страницу. + /// + public BotPagesApp MapCommand(string commandTemplate) where TPage : Page + { + _commands.Map(commandTemplate); + return this; + } + + /// + /// Зарегистрировать команду с кастомным обработчиком. + /// + public BotPagesApp MapCommand(string template, Func handler) + { + _commands.Map(template, handler); + return this; + } + + /// + /// Зарегистрировать маршрут для страницы. + /// + public BotPagesApp MapRoute(string template) where TPage : Page + { + _routes.Map(template); + return this; + } + + /// + /// Обработать входящее обновление. + /// + public async Task HandleUpdateAsync(UpdateContext update, CancellationToken ct) + { + var ctx = await CreatePageContextAsync(update, ct); + + // Команды выше событий страниц + if (update.Kind == UpdateKind.Text && update.Text is not null && update.Text.StartsWith("/")) + { + if (_commands.TryDispatch(ctx, update.Text, ct, out var dispatched) && dispatched is not null) + { + _logger.Log(LogLevel.Info, $"Command '{update.Text}' dispatched."); + await dispatched; + return; + } + } + + // Конвейер middleware + var pipeline = BuildPipeline(ctx, async () => + { + await DispatchToPageAsync(ctx, update, ct); + }); + + await pipeline(); + } + + private Func BuildPipeline(PageContext ctx, Func terminal) + { + Func next = terminal; + foreach (var mw in _middlewares.AsEnumerable().Reverse()) + { + var prev = next; + next = () => mw.InvokeAsync(ctx, prev, _currentCt); + } + return next; + } + + // Технические поля для конвейера + private CancellationToken _currentCt; + + /// + /// Создать контекст страницы для текущего обновления. + /// + private async Task CreatePageContextAsync(UpdateContext update, CancellationToken ct) + { + _currentCt = ct; + + var sessionKey = new CompositeSessionKey(update.MessengerType, update.Chat.Id, update.User.Id); + + var ctx = new PageContext + { + Update = update, + SessionKey = sessionKey, + StateStorage = _state, + Navigation = _navigation, + Adapter = _adapterFactory.Resolve(update.MessengerType), + }; + + return await Task.FromResult(ctx); + } + + /// + /// Отправить обновление на текущую страницу. + /// + private async Task DispatchToPageAsync(PageContext ctx, UpdateContext update, CancellationToken ct) + { + var page = ResolveCurrentPage(ctx); + + if (page is null) + { + await ctx.Navigation.GoToHome(ctx, ct); + return; + } + + + try + { + await page.OnUpdate(ctx, update, ct); + + if (update.Kind.HasFlag(UpdateKind.Text) && update.Text is not null) await page.OnText(ctx, update.Text, ct); + if (update.Kind.HasFlag(UpdateKind.Button) && update.Text is not null) await page.OnButton(ctx, update.Text, ct); + if (update.Kind.HasFlag(UpdateKind.File) && update.Files.Count > 0) await page.OnFile(ctx, update.Files, ct); + + } + catch (Exception ex) + { + _logger.Log(LogLevel.Critical, "Unhandled page error.", ex); + await page.OnError(ctx, ex, ct); + } + } + + /// + /// Определить текущую страницу. + /// + private Page? ResolveCurrentPage(PageContext ctx) + => _navigation.ResolveCurrentPage(ctx); +} \ No newline at end of file diff --git a/BotPages.Core/Context/ChatContext.cs b/BotPages.Core/Context/ChatContext.cs index a263e51..bc498a5 100644 --- a/BotPages.Core/Context/ChatContext.cs +++ b/BotPages.Core/Context/ChatContext.cs @@ -1,18 +1,21 @@ -namespace BotPages.Core -{ - /// - /// Описывает чат/конверсацию для универсального контекста. - /// - public sealed class ChatContext - { - /// - /// Уникальный идентификатор чата/диалога. - /// - public long Id { get; init; } +using BotPages.Core.Abstractions; - /// - /// Человеко-читаемое имя чата (если доступно). - /// - public string? Title { get; init; } - } -} \ No newline at end of file +namespace BotPages.Core; + +/// +/// Данные чата. +/// +public sealed class ChatContext +{ + /// Идентификатор чата. + public required string Id { get; init; } + + /// Название чата (опционально). + public string? Title { get; init; } + + /// Идентификатор треда (опционально). + public string? ThreadId { get; init; } + + /// Возможности мессенджера. + public Capabilities Capabilities { get; init; } = new(); +} diff --git a/BotPages.Core/Context/FileDescriptor.cs b/BotPages.Core/Context/FileDescriptor.cs deleted file mode 100644 index 2115bfb..0000000 --- a/BotPages.Core/Context/FileDescriptor.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Универсальный дескриптор файла для операций загрузки/отправки. - /// - public sealed record FileDescriptor( - string Id, - string Name, - string MimeType, - int Size, - Stream? Content = null - ); -} \ No newline at end of file diff --git a/BotPages.Core/Context/PageContext.cs b/BotPages.Core/Context/PageContext.cs new file mode 100644 index 0000000..a259870 --- /dev/null +++ b/BotPages.Core/Context/PageContext.cs @@ -0,0 +1,88 @@ +using BotPages.Core.Abstractions; +using BotPages.Core.Context; +using BotPages.Core.Messaging; + +namespace BotPages.Core; + +/// +/// Контекст страницы, объединяющий пользователя, чат, состояние и адаптер. +/// +public sealed class PageContext +{ + /// Ключ сессии. + public required CompositeSessionKey SessionKey { get; init; } + + /// Данные обновления. + public required UpdateContext Update { get; init; } + + /// Хранилище состояния. + public required IStateStorage StateStorage { get; init; } + /// Сервис навигации. + public required NavigationService Navigation { get; init; } + /// Адаптер мессенджера. + public required IMessengerAdapter Adapter { get; init; } + + /// + /// Отправить текстовое сообщение. + /// + public Task SendTextAsync(string text, MessageFormat format = MessageFormat.Plain, + IEnumerable>? inline = null, + IEnumerable>? reply = null, + CancellationToken ct = default) + => Adapter.SendTextAsync(this, text, format, inline, reply, ct); + + /// + /// Отправить файл. + /// + public Task SendFileAsync(FileDescriptor file, string? caption = null, CancellationToken ct = default) + => Adapter.SendFileAsync(this, file, caption, ct); + + /// + /// Получить билдер альбомов. + /// + public IAlbumBuilder Albums => Adapter.CreateAlbumBuilder(this); + + /// + /// Начать прогресс операции. + /// + public async Task StartProgressAsync(string title, CancellationToken ct) + { + var messageId = await Adapter.StartProgressAsync(this, title, ct); + + if (messageId != null) + { + _progressMessageId = messageId; + _progressTitle = title; + } + + return messageId; + } + + /// + /// Обновить прогресс операции. + /// + public Task UpdateProgressAsync(int percent, CancellationToken ct) + { + if (_progressMessageId != null) + { + return Adapter.UpdateProgressAsync(this, _progressMessageId, _progressTitle ?? "", percent, ct); + } + else + { + return Task.CompletedTask; + } + } + + /// + /// Обновить прогресс операции. + /// + public Task UpdateProgressAsync(string messageId, int percent, CancellationToken ct) + { + return Adapter.UpdateProgressAsync(this, messageId, _progressTitle ?? "", percent, ct); + } + + + private string? _progressMessageId = null; + private string? _progressTitle = null; + +} \ No newline at end of file diff --git a/BotPages.Core/Context/UpdateContext.cs b/BotPages.Core/Context/UpdateContext.cs index 4a687cb..ce54a0c 100644 --- a/BotPages.Core/Context/UpdateContext.cs +++ b/BotPages.Core/Context/UpdateContext.cs @@ -1,48 +1,59 @@ -namespace BotPages.Core +namespace BotPages.Core.Context; + +using BotPages.Core.Abstractions; + +/// +/// Тип входящего обновления. +/// +[Flags] +public enum UpdateKind { + /// Неизвестное сообщение. + None = 0, + + /// Текстовое сообщение. + Text = 1 << 0, + + /// Файлы (один или несколько). + File = 1 << 1, + + /// Нажатие кнопки. + Button = 1 << 2, +} + +/// +/// Контекст входящего обновления от мессенджера. +/// Содержит нормализованные данные для страниц. +/// +public sealed class UpdateContext +{ + /// Тип мессенджера. + public required string MessengerType { get; init; } + /// - /// Универсальный контекст обновления, независимый от транспорта. + /// Данные пользователя, от которого пришло обновление. /// - public sealed class UpdateContext - { - /// - /// Клиент транспорта для отправки сообщений/файлов. - /// - public required IChatClient Client { get; init; } + public required UserContext User { get; init; } - /// - /// Контекст чата. - /// - public required ChatContext Chat { get; init; } + /// + /// Данные чата, в котором произошло обновление. + /// + public required ChatContext Chat { get; init; } - /// - /// Контекст пользователя. - /// - public required UserContext User { get; init; } + /// + /// Тип обновления (текст, файлы, кнопка). + /// + public required UpdateKind Kind { get; init; } - /// - /// Текст сообщения или полезная нагрузка колбэка, если доступна. - /// - public string? Text { get; init; } + /// + /// Текст сообщения, если Kind = Text. + /// Payload кнопки, если Kind = Button. + /// + public string? Text { get; init; } - /// - /// Список полученных файлов (если транспорт поддерживает). - /// - public IReadOnlyList? IncomingFiles { get; init; } - - /// - /// Сырой объект обновления транспорта (например, Telegram.Update). - /// - public object? RawUpdate { get; init; } - - /// - /// Сервис навигации страниц. - /// - public required INavigationService Nav { get; init; } - - /// - /// Хранилище состояния пользователя. - /// - public required IStateStore State { get; init; } - } -} \ No newline at end of file + /// + /// Список файлов, если Kind = File. + /// Может содержать один или несколько файлов. + /// + public List Files { get; init; } = new(); +} diff --git a/BotPages.Core/Context/UserContext.cs b/BotPages.Core/Context/UserContext.cs index 16bc1ec..f2b396f 100644 --- a/BotPages.Core/Context/UserContext.cs +++ b/BotPages.Core/Context/UserContext.cs @@ -1,18 +1,16 @@ -namespace BotPages.Core -{ - /// - /// Описывает пользователя для универсального контекста. - /// - public sealed class UserContext - { - /// - /// Уникальный идентификатор пользователя в транспортном слое. - /// - public long Id { get; init; } +namespace BotPages.Core; - /// - /// Отображаемое имя пользователя (если доступно). - /// - public string? DisplayName { get; init; } - } + +/// +/// Данные пользователя. +/// +public sealed class UserContext +{ + /// Идентификатор пользователя. + public required string Id { get; init; } + + /// + /// Отображаемое имя пользователя (если доступно). + /// + public string? DisplayName { get; init; } } \ No newline at end of file diff --git a/BotPages.Core/Logging/ConsoleLogger.cs b/BotPages.Core/Logging/ConsoleLogger.cs new file mode 100644 index 0000000..29bb1b3 --- /dev/null +++ b/BotPages.Core/Logging/ConsoleLogger.cs @@ -0,0 +1,30 @@ +namespace BotPages.Core.Logging; + +/// +/// Вывод лога в консоль. +/// +public sealed class ConsoleLogger : ILogger +{ + /// + public void Log(LogLevel level, string message, Exception? ex = null) + { + var prefix = level switch + { + LogLevel.Info => "[INFO] ", + LogLevel.Warn => "[WARN] ", + LogLevel.Critical => "[CRIT] ", + _ => "[LOG] " + }; + + string text = $"{DateTime.UtcNow:O} {prefix}{message}{(ex is null ? "" : $" :: {ex.Message}")}"; + + if (level == LogLevel.Critical) + { + Console.Error.WriteLine(text); + } + else + { + Console.WriteLine(text); + } + } +} \ No newline at end of file diff --git a/BotPages.Core/Logging/ILogger.cs b/BotPages.Core/Logging/ILogger.cs new file mode 100644 index 0000000..bfdc4bc --- /dev/null +++ b/BotPages.Core/Logging/ILogger.cs @@ -0,0 +1,15 @@ +namespace BotPages.Core.Logging; + +/// +/// Интерфейс логгера для BotPages. +/// +public interface ILogger +{ + /// + /// Записать сообщение в лог. + /// + /// Уровень логирования. + /// Текст сообщения. + /// Исключение, если есть. + void Log(LogLevel level, string message, Exception? ex = null); +} diff --git a/BotPages.Core/Logging/LogLevel.cs b/BotPages.Core/Logging/LogLevel.cs new file mode 100644 index 0000000..231a7a8 --- /dev/null +++ b/BotPages.Core/Logging/LogLevel.cs @@ -0,0 +1,14 @@ +namespace BotPages.Core.Logging; + +/// +/// Уровни логирования для BotPages. +/// +public enum LogLevel +{ + /// Информационные сообщения. + Info, + /// Предупреждения (например, деградация возможностей). + Warn, + /// Критические ошибки, делающие невозможным продолжение работы. + Critical +} diff --git a/BotPages.Core/Logging/WithoutLogger.cs b/BotPages.Core/Logging/WithoutLogger.cs new file mode 100644 index 0000000..f62ee0d --- /dev/null +++ b/BotPages.Core/Logging/WithoutLogger.cs @@ -0,0 +1,12 @@ +namespace BotPages.Core.Logging; + +/// +/// Отключение логирования +/// +public sealed class WithoutLogger : ILogger +{ + /// + public void Log(LogLevel level, string message, Exception? ex = null) + { + } +} \ No newline at end of file diff --git a/BotPages.Core/Messaging/ButtonAttribute.cs b/BotPages.Core/Messaging/ButtonAttribute.cs new file mode 100644 index 0000000..b287c41 --- /dev/null +++ b/BotPages.Core/Messaging/ButtonAttribute.cs @@ -0,0 +1,98 @@ +using System.Reflection; + +namespace BotPages.Core.Messaging; + +/// +/// Описание для кнопки. +/// +[AttributeUsage(AttributeTargets.Field)] +public class ButtonAttribute : Attribute +{ + /// + public ButtonAttribute(string label) + { + Label = label; + } + + /// + /// Описание кнопки. + /// + public string Label { get; } + + /// + /// Значение кнопки. Используется в InlineButton. + /// + public string? Value { get; } +} + +public static class ButtonExtensions +{ + private static readonly Dictionary> _cacheName = new(); + + /// + /// Получить подпись кнопки. + /// + /// Enum тип. + /// Значение enum. + /// + public static string GetButtonLabel(this T value) + where T : Enum + { + var fieldName = value.ToString(); + var type = value.GetType(); + var field = type.GetField(fieldName, BindingFlags.Public | BindingFlags.Static); + return field?.GetCustomAttribute()?.Label ?? fieldName; + } + + /// + /// Получить значение кнопки. + /// + /// Enum тип. + /// Значение enum. + /// + public static string GetButtonValue(this T value) + where T : Enum + { + var fieldName = value.ToString(); + var type = value.GetType(); + var field = type.GetField(fieldName, BindingFlags.Public | BindingFlags.Static); + return field?.GetCustomAttribute()?.Value ?? fieldName; + } + + /// + /// Получить значение enum из подписи кнопки. + /// + /// + /// + /// + public static T? FromButtonLabel(string? value) where T : struct, Enum + { + if (value == null) return null; + + var type = typeof(T); + if (!_cacheName.TryGetValue(type, out var map)) + { + map = new Dictionary(); + + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static); + + foreach (var field in fields) + { + var fieldValue = field.GetValue(null)!; + var fieldName = field.Name; + + var attr = field.GetCustomAttribute(); + + if (attr != null) + { + fieldName = attr.Label; + } + + map[fieldName] = fieldValue; + } + + } + + return map.TryGetValue(value, out var result) ? (T)result : null; + } +} \ No newline at end of file diff --git a/BotPages.Core/Messaging/InlineButton.cs b/BotPages.Core/Messaging/InlineButton.cs new file mode 100644 index 0000000..b161669 --- /dev/null +++ b/BotPages.Core/Messaging/InlineButton.cs @@ -0,0 +1,37 @@ +namespace BotPages.Core.Messaging; + +/// +/// Кнопки под сообщением +/// +public class InlineButton +{ + /// + /// Подпись на кнопке + /// + public string Label { get; private set; } + + /// + /// Значение кнопки + /// + public string Value { get; private set; } + + /// + public InlineButton(string label, string value) + { + this.Label = label; + this.Value = value; + } + + /// + public InlineButton(Enum value) + { + this.Label = value.GetButtonLabel(); + this.Value = value.GetButtonValue(); + } + + /// + /// Преобразование enum к кнопке. + /// + /// + public static implicit operator InlineButton(Enum en) => new InlineButton(en); +} diff --git a/BotPages.Core/Messaging/MessageBuilder.cs b/BotPages.Core/Messaging/MessageBuilder.cs new file mode 100644 index 0000000..5c6c5c0 --- /dev/null +++ b/BotPages.Core/Messaging/MessageBuilder.cs @@ -0,0 +1,153 @@ +using BotPages.Core.Abstractions; + +namespace BotPages.Core.Messaging; + +/// +/// Fluent‑билдер для отправки сообщений (текст, кнопки, файлы, альбомы, прогресс). +/// +public sealed class MessageBuilder +{ + private readonly PageContext _ctx; + private string? _text = null; + private MessageFormat _format = MessageFormat.Plain; + private readonly List> _inline = new(); + private readonly List> _reply = new(); + private readonly List<(FileDescriptor file, string? caption)> _files = new(); + private readonly List<(FileDescriptor file, string? caption)> _album = new(); + private string? _progressTitle = null; + private int? _progressPercent = null; + private string? _progressMessageId = null; + + /// Создать билдер сообщений. + public MessageBuilder(PageContext ctx) => _ctx = ctx; + + /// Текст сообщения. + public MessageBuilder Text(string text, MessageFormat format = MessageFormat.Plain) + { + _text = text; + _format = format; + return this; + } + + /// Добавить inline‑кнопку. + public MessageBuilder Inline(string label, string value) + { + _inline.Add(new() { new(label, value) }); + return this; + } + + /// Добавить inline‑кнопку. + public MessageBuilder Inline(InlineButton button) + { + _inline.Add(new() { button }); + return this; + } + + /// Добавить inline‑кнопку. + public MessageBuilder Inline(params InlineButton[] buttons) + { + _inline.Add(buttons.ToList()); + return this; + } + + /// Добавить строку inline‑кнопок. + public MessageBuilder Inline(IEnumerable row) + { + _inline.Add(row.ToList()); + return this; + } + + /// Добавить строку inline‑кнопок. + public MessageBuilder Inline(IEnumerable> row) + { + _inline.AddRange(row.Select(t => t.ToList()).ToList()); + return this; + } + + /// Добавить reply‑кнопку. + public MessageBuilder Reply(params ReplyButton[] label) + { + _reply.Add(label.ToList()); + return this; + } + + /// Добавить строку reply‑кнопок. + public MessageBuilder Reply(IEnumerable row) + { + _reply.Add(row.ToList()); + return this; + } + + /// Добавить строку reply‑кнопок. + public MessageBuilder Reply(IEnumerable> row) + { + _reply.AddRange(row.Select(t => t.ToList()).ToList()); + return this; + } + + /// Добавить файл для отправки. + public MessageBuilder File(FileDescriptor file, string? caption = null) + { + _files.Add((file, caption)); + return this; + } + + /// Добавить файл в альбом. + public MessageBuilder Album(FileDescriptor file, string? caption = null) + { + _album.Add((file, caption)); + return this; + } + + /// Установить прогресс операции. + public MessageBuilder Progress(string title, int percent = 0) + { + _progressTitle = title; + _progressPercent = percent; + return this; + } + + /// Отправить собранное сообщение. + public async Task SendAsync(CancellationToken ct = default) + { + // Текст + if (!string.IsNullOrWhiteSpace(_text)) + { + await _ctx.SendTextAsync(_text, _format, _inline, _reply, ct); + } + + // Файлы + foreach (var (file, caption) in _files) + await _ctx.SendFileAsync(file, caption, ct); + + // Альбом + if (_album.Count > 0) + { + var builder = _ctx.Albums; + foreach (var (file, caption) in _album) + builder.Add(file, caption); + await builder.SendAsync(ct); + } + + // Прогресс + if (_progressTitle is not null) + { + if (_progressMessageId is null) + _progressMessageId = await _ctx.StartProgressAsync(_progressTitle, ct); + + if (_progressPercent > 0 && !string.IsNullOrEmpty(_progressMessageId)) + await _ctx.UpdateProgressAsync(_progressMessageId, _progressPercent.Value, ct); + } + + _text = null; + _files.Clear(); + _album.Clear(); + + if (_progressPercent >= 100) + { + _progressTitle = null; + _progressMessageId = null; + _progressPercent = null; + } + } +} diff --git a/BotPages.Core/Messaging/ReplyButton.cs b/BotPages.Core/Messaging/ReplyButton.cs new file mode 100644 index 0000000..ff7f608 --- /dev/null +++ b/BotPages.Core/Messaging/ReplyButton.cs @@ -0,0 +1,36 @@ +namespace BotPages.Core.Messaging; + +/// +/// Кнопки снизу чата +/// +public class ReplyButton +{ + /// + /// Подпись на кнопке + /// + public string Label { get; private set; } + + /// + public ReplyButton(string label) + { + this.Label = label; + } + + /// + public ReplyButton(Enum value) + { + this.Label = value.GetButtonLabel(); + } + + /// + /// Преобразование строки к кнопке. + /// + /// + public static implicit operator ReplyButton(string str) => new ReplyButton(str); + + /// + /// Преобразование enum к кнопке. + /// + /// + public static implicit operator ReplyButton(Enum en) => new ReplyButton(en); +} diff --git a/BotPages.Core/Middleware/ErrorHandlingMiddleware.cs b/BotPages.Core/Middleware/ErrorHandlingMiddleware.cs new file mode 100644 index 0000000..931f83a --- /dev/null +++ b/BotPages.Core/Middleware/ErrorHandlingMiddleware.cs @@ -0,0 +1,34 @@ +using BotPages.Core.Abstractions; +using BotPages.Core.Logging; + +namespace BotPages.Core.Middleware; + +/// +/// Middleware для глобальной ловли ошибок. +/// +public sealed class ErrorHandlingMiddleware : IPageMiddleware +{ + private readonly ILogger _logger; + + /// + public ErrorHandlingMiddleware(ILogger logger) + { + _logger = logger; + } + + /// + public async Task InvokeAsync(PageContext ctx, Func next, CancellationToken ct) + { + try + { + await next(); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Critical, "Unhandled exception in middleware pipeline.", ex); + + // Теперь можно напрямую использовать PageContext для ответа + await ctx.SendTextAsync("Произошла ошибка при обработке запроса. Попробуйте ещё раз.", ct: ct); + } + } +} diff --git a/BotPages.Core/Middleware/LoggingMiddleware.cs b/BotPages.Core/Middleware/LoggingMiddleware.cs new file mode 100644 index 0000000..f56cf5f --- /dev/null +++ b/BotPages.Core/Middleware/LoggingMiddleware.cs @@ -0,0 +1,32 @@ +using BotPages.Core.Abstractions; +using BotPages.Core.Logging; + +namespace BotPages.Core.Middleware; + +/// +/// Middleware для логирования всех входящих апдейтов. +/// +public sealed class LoggingMiddleware : IPageMiddleware +{ + private readonly ILogger _logger; + + /// + public LoggingMiddleware(ILogger logger) => _logger = logger; + + /// + public async Task InvokeAsync(PageContext ctx, Func next, CancellationToken ct) + { + // Логируем базовую информацию + _logger.Log(LogLevel.Info, $"Update from {ctx.Update.MessengerType} | Chat: {ctx.Update.Chat.Id} | User: {ctx.Update.User.Id}"); + + // Логируем текст, кнопки, файлы + if (ctx.Update.Text is not null) + _logger.Log(LogLevel.Info, $"Text: {ctx.Update.Text}"); + + if (ctx.Update.Files.Count > 0) + _logger.Log(LogLevel.Info, $"Files: {ctx.Update.Files.Count}"); + + // Передаём управление дальше по конвейеру + await next(); + } +} \ No newline at end of file diff --git a/BotPages.Core/Navigation/INavigationService.cs b/BotPages.Core/Navigation/INavigationService.cs deleted file mode 100644 index 8a9fa97..0000000 --- a/BotPages.Core/Navigation/INavigationService.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Сервис навигации по страницам. - /// - public interface INavigationService - { - /// - /// Выполняет push новой страницы и вызывает её Enter. - /// - Task PushAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct); - - /// - /// Выполняет replace текущей страницы и вызывает Enter новой. - /// - Task ReplaceAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct); - - /// - /// Возвращается назад по стеку и вызывает Enter предыдущей. - /// - Task PopAsync(UpdateContext ctx, CancellationToken ct); - - /// - /// Применяет декларативный результат страницы (навигация, текст, файлы). - /// - Task ApplyResultAsync(UpdateContext ctx, PageResult result, CancellationToken ct); - - /// - /// Возвращает текущую запись стека. - /// - Task CurrentAsync(UpdateContext ctx, CancellationToken ct); - - /// - /// Возвращает весь стек навигации. - /// - Task> StackAsync(UpdateContext ctx, CancellationToken ct); - } -} \ No newline at end of file diff --git a/BotPages.Core/Navigation/IStateStore.cs b/BotPages.Core/Navigation/IStateStore.cs deleted file mode 100644 index fa82868..0000000 --- a/BotPages.Core/Navigation/IStateStore.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Простое in-memory хранилище состояния пользователя. - /// - public interface IStateStore - { - /// - /// Получает состояние пользователя. - /// - Task GetAsync(string transportId, long userId, CancellationToken ct); - - /// - /// Сохраняет состояние пользователя. - /// - Task SaveAsync(UserState state, CancellationToken ct); - } -} \ No newline at end of file diff --git a/BotPages.Core/Navigation/InMemoryStateStore.cs b/BotPages.Core/Navigation/InMemoryStateStore.cs deleted file mode 100644 index 1d5f81d..0000000 --- a/BotPages.Core/Navigation/InMemoryStateStore.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace BotPages.Core -{ - - /// - /// In-memory реализация хранилища состояния для прототипирования. - /// - public sealed class InMemoryStateStore : IStateStore - { - private readonly Dictionary<(string chatClientId, long userId), UserState> _store = new(); - - /// - /// Получает состояние пользователя, создавая новое при отсутствии. - /// - public Task GetAsync(string chatClientId, long userId, CancellationToken ct) - { - if (!_store.TryGetValue((chatClientId, userId), out var st)) - { - st = new UserState - { - UserId = userId, - ChatClientId = chatClientId, - }; - _store[(chatClientId, userId)] = st; - } - return Task.FromResult(st); - } - - /// - /// Сохраняет состояние пользователя. - /// - public Task SaveAsync(UserState state, CancellationToken ct) - { - _store[(state.ChatClientId, state.UserId)] = state; - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Navigation/NavEntry.cs b/BotPages.Core/Navigation/NavEntry.cs deleted file mode 100644 index 2d24451..0000000 --- a/BotPages.Core/Navigation/NavEntry.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Запись навигационного стека: страница и её аргументы. - /// - public sealed record NavEntry(string PageId, object? Args = null); -} \ No newline at end of file diff --git a/BotPages.Core/Navigation/NavigationService.cs b/BotPages.Core/Navigation/NavigationService.cs index 86b4c26..089707a 100644 --- a/BotPages.Core/Navigation/NavigationService.cs +++ b/BotPages.Core/Navigation/NavigationService.cs @@ -1,108 +1,115 @@ -namespace BotPages.Core +using BotPages.Core.Abstractions; +using BotPages.Core.Routing; +using System.Collections.Concurrent; + +namespace BotPages.Core; + +/// +/// Сервис навигации между страницами. +/// Позволяет выполнять переходы, замену страниц и передачу аргументов. +/// +public sealed class NavigationService { + private readonly RoutesRegistry _routes; + private readonly Dictionary _singletonPages = new(); + private readonly ConcurrentDictionary _sessionPages = new(); + private Type? _defaultPage; + /// - /// Реализация сервиса навигации страниц. + /// Создать сервис навигации. /// - public sealed class NavigationService : INavigationService + internal NavigationService(RoutesRegistry routes) { - private readonly IPageRegistry _pages; - private readonly IStateStore _store; - - /// - /// Создаёт сервис навигации. - /// - public NavigationService(IPageRegistry pages, IStateStore store) - { - _pages = pages; - _store = store; - } - - /// - /// Выполняет push новой страницы и вызывает её Enter. - /// - public async Task PushAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct) - { - var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); - state.Stack.Add(new NavEntry(pageId, args)); - await _store.SaveAsync(state, ct); - - var pr = await _pages.Get(pageId).EnterAsync(ctx, ct); - await ApplyResultAsync(ctx, pr, ct); - } - - /// - /// Выполняет replace текущей страницы и вызывает Enter новой. - /// - public async Task ReplaceAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct) - { - var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); - if (state.Stack.Count > 0) state.Stack[^1] = new NavEntry(pageId, args); - else state.Stack.Add(new NavEntry(pageId, args)); - await _store.SaveAsync(state, ct); - - var pr = await _pages.Get(pageId).EnterAsync(ctx, ct); - await ApplyResultAsync(ctx, pr, ct); - } - - /// - /// Возвращается назад по стеку и вызывает Enter предыдущей. - /// - public async Task PopAsync(UpdateContext ctx, CancellationToken ct) - { - var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); - if (state.Stack.Count == 0) return; - - var currentId = state.Stack[^1].PageId; - await _pages.Get(currentId).ExitAsync(ctx, ct); - - state.Stack.RemoveAt(state.Stack.Count - 1); - await _store.SaveAsync(state, ct); - - var next = state.Stack.Count > 0 ? state.Stack[^1].PageId : null; - if (next is not null) - { - var pr = await _pages.Get(next).EnterAsync(ctx, ct); - await ApplyResultAsync(ctx, pr, ct); - } - } - - /// - /// Применяет декларативный результат страницы (навигация, текст, файлы). - /// - public async Task ApplyResultAsync(UpdateContext ctx, PageResult result, CancellationToken ct) - { - if (result.NavigateTo is not null) - { - if (result.NavigateTo.Replace) - await ReplaceAsync(result.NavigateTo.PageId, result.NavigateTo.Args, ctx, ct); - else - await PushAsync(result.NavigateTo.PageId, result.NavigateTo.Args, ctx, ct); - return; // навигация сама вызовет Enter новой страницы и применит её результат - } - - if (result.Message is not null) - await ctx.Client.SendTextAsync(ctx.Chat.Id, result.Message, result.Actions, ct); - - if (result.Files is not null) - await ctx.Client.SendFilesAsync(ctx.Chat.Id, result.Files, ct); - } - - /// - /// Возвращает текущую запись стека. - /// - public async Task CurrentAsync(UpdateContext ctx, CancellationToken ct) - { - var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); - return state.Stack.Count == 0 ? null : state.Stack[^1]; - } - - /// - /// Возвращает весь стек навигации. - /// - public async Task> StackAsync(UpdateContext ctx, CancellationToken ct) - { - var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); - return state.Stack.AsReadOnly(); - } + _routes = routes; } -} \ No newline at end of file + + internal void AddDefaultPage() where TPage : Page + { + _defaultPage = typeof(TPage); + } + + /// + /// Перейти по маршруту без аргументов. + /// + public Task GoToHome(PageContext ctx, CancellationToken ct) + { + return NavigateAsync(_defaultPage!, ctx, null, ct); + } + + /// + /// Перейти по маршруту без аргументов. + /// + public Task GoToAsync(string route, PageContext ctx, CancellationToken ct) + { + var pageType = _routes.Resolve(route); + return NavigateAsync(pageType!, ctx, null, ct); + } + + /// + /// Перейти по маршруту с аргументами. + /// + public Task GoToAsync(string route, TArgs args, PageContext ctx, CancellationToken ct) + { + var pageType = _routes.Resolve(route); + return NavigateAsync(pageType!, ctx, args!, ct); + } + + /// + /// Перейти на страницу без аргументов. + /// + public Task GoToAsync(PageContext ctx, CancellationToken ct) where TPage : Page + => NavigateAsync(typeof(TPage), ctx, null, ct); + + /// + /// Перейти на страницу с аргументами. + /// + public Task GoToAsync(PageContext ctx, TArgs args, CancellationToken ct) where TPage : StatefullPage + => NavigateAsync(typeof(TPage), ctx, args!, ct); + + /// + /// Заменить текущую страницу. + /// + public Task ReplaceWithAsync(PageContext ctx, CancellationToken ct) where TPage : Page + => NavigateAsync(typeof(TPage), ctx, null, ct, replace: true); + + internal async Task NavigateAsync(Type pageType, PageContext ctx, object? args, CancellationToken ct, bool replace = false) + { + Page? page; + + if (typeof(SingletonPage).IsAssignableFrom(pageType)) + { + // Singleton: один объект на всё приложение + if (!_singletonPages.TryGetValue(pageType, out page)) + { + page = (Page)Activator.CreateInstance(pageType)!; + _singletonPages[pageType] = page; + } + } + else + { + // Stateful: новый объект на пользователя + page = (Page)Activator.CreateInstance(pageType)!; + } + + if (_sessionPages.TryGetValue(ctx.SessionKey, out var currentPage)) + { + if (currentPage.GetType() != pageType || replace) + { + await currentPage.OnLeave(ctx, ct); + } + } + + _sessionPages[ctx.SessionKey] = page; + + if (args is null) + await page.OnEnter(ctx, ct); + else + await (page as dynamic).OnEnter(ctx, (dynamic)args, ct); + } + + /// + /// Восстановить текущую страницу из StateStorage. + /// + public Page? ResolveCurrentPage(PageContext ctx) + => _sessionPages.TryGetValue(ctx.SessionKey, out var page) ? page : null; +} diff --git a/BotPages.Core/Navigation/UserState.cs b/BotPages.Core/Navigation/UserState.cs deleted file mode 100644 index 1a98e4d..0000000 --- a/BotPages.Core/Navigation/UserState.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Состояние пользователя: навигационный стек и общий словарь данных. - /// - public sealed class UserState - { - /// - /// Идентификатор пользователя. - /// - public required long UserId { get; init; } - - /// - /// Идентификатор клиента чата. - /// - public required string ChatClientId { get; init; } - - /// - /// Навигационный стек страниц. - /// - public List Stack { get; } = new(); - - /// - /// Общая сумка данных, доступная на всех страницах. - /// - public Dictionary Bag { get; } = new(); - } -} \ No newline at end of file diff --git a/BotPages.Core/Pages/ActionAttribute.cs b/BotPages.Core/Pages/ActionAttribute.cs deleted file mode 100644 index c391b33..0000000 --- a/BotPages.Core/Pages/ActionAttribute.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace BotPages.Core -{ - public class ActionAttribute : Attribute - { - public ActionAttribute(string label) - { - Label = label; - } - - public string Label { get; } - } -} diff --git a/BotPages.Core/Pages/ActionExtensions.cs b/BotPages.Core/Pages/ActionExtensions.cs deleted file mode 100644 index 19bc4f2..0000000 --- a/BotPages.Core/Pages/ActionExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Reflection; - -namespace BotPages.Core -{ - public static class ActionExtensions - { - private static readonly Dictionary> _cache = new(); - - public static string GetActionLabel(this T value) - where T : Enum - { - var fieldName = value.ToString(); - var field = typeof(T).GetField(fieldName, BindingFlags.Public | BindingFlags.Static); - return field?.GetCustomAttribute()?.Label ?? fieldName; - } - - public static T? FromActionLabel(string? value) where T : struct, Enum - { - if (value == null) return null; - - var type = typeof(T); - if (!_cache.TryGetValue(type, out var map)) - { - map = new Dictionary(); - - var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static); - - foreach (var field in fields) - { - var fieldValue = field.GetValue(null)!; - var fieldName = field.Name; - - var attr = field.GetCustomAttribute(); - - if (attr != null) - { - fieldName = attr.Label; - } - - map[fieldName] = fieldValue; - } - - } - - return map.TryGetValue(value, out var result) ? (T)result : null; - } - } -} diff --git a/BotPages.Core/Pages/ActionPlacement.cs b/BotPages.Core/Pages/ActionPlacement.cs deleted file mode 100644 index a409999..0000000 --- a/BotPages.Core/Pages/ActionPlacement.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Тип размещения кнопки. - /// - public enum ActionPlacement - { - /// - /// Inline‑кнопка (под сообщением). - /// - Inline, - - /// - /// Reply‑кнопка (заменяет системную клавиатуру). - /// - Reply - } - - -} \ No newline at end of file diff --git a/BotPages.Core/Pages/IPage.cs b/BotPages.Core/Pages/IPage.cs deleted file mode 100644 index 7534da9..0000000 --- a/BotPages.Core/Pages/IPage.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Контракт страницы: экран диалога с жизненным циклом. - /// - public interface IPage - { - /// - /// Статический идентификатор страницы. - /// - string Id { get; } - - /// - /// Вызывается при входе на страницу (рендер, приветствие). - /// - Task EnterAsync(UpdateContext ctx, CancellationToken ct); - - /// - /// Обработка входящего события/сообщения на странице. - /// - Task HandleAsync(UpdateContext ctx, CancellationToken ct); - - /// - /// Вызывается при выходе со страницы (очистка, финализация). - /// - Task ExitAsync(UpdateContext ctx, CancellationToken ct); - } -} \ No newline at end of file diff --git a/BotPages.Core/Pages/IPageRegistry.cs b/BotPages.Core/Pages/IPageRegistry.cs deleted file mode 100644 index c26239c..0000000 --- a/BotPages.Core/Pages/IPageRegistry.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Реестр страниц с доступом по идентификатору. - /// - public interface IPageRegistry - { - IPage DefaultPage { get; } - - /// - /// Возвращает страницу по идентификатору. - /// - IPage Get(string id); - - /// - /// Пытается получить страницу по идентификатору. - /// - bool TryGet(string id, out IPage? page); - - /// - /// Возвращает все зарегистрированные страницы. - /// - IEnumerable All(); - IPage GetOrDefault(string id); - } -} \ No newline at end of file diff --git a/BotPages.Core/Pages/Page.cs b/BotPages.Core/Pages/Page.cs index d8e1d88..82f4c46 100644 --- a/BotPages.Core/Pages/Page.cs +++ b/BotPages.Core/Pages/Page.cs @@ -1,32 +1,28 @@ -namespace BotPages.Core +using BotPages.Core.Abstractions; +using BotPages.Core.Context; + +namespace BotPages.Core; + +/// +/// Базовый класс страницы. +/// +public abstract class Page { - /// - /// Базовая реализация страницы без обязательных переопределений. - /// - public abstract class Page : IPage - { - /// - /// Идентификатор страницы. - /// - public virtual string Id => GetType().Name; - - /// - /// Виртуальный метод входа; по умолчанию ничего не делает. - /// - public virtual Task EnterAsync(UpdateContext ctx, CancellationToken ct) => - Task.FromResult(new PageResult()); - - /// - /// Абстрактная обработка событий; обязателен к реализации. - /// - public abstract Task HandleAsync(UpdateContext ctx, CancellationToken ct); - - /// - /// Виртуальный метод выхода; по умолчанию ничего не делает. - /// - public virtual Task ExitAsync(UpdateContext ctx, CancellationToken ct) => - Task.CompletedTask; - } - + /// Вход на страницу. + public virtual Task OnEnter(PageContext ctx, CancellationToken ct) => Task.CompletedTask; + /// Общий обработчик обновлений. + public virtual Task OnUpdate(PageContext ctx, UpdateContext update, CancellationToken ct) => Task.CompletedTask; + /// Обработка текста. + public virtual Task OnText(PageContext ctx, string text, CancellationToken ct) => Task.CompletedTask; + /// Обработка файлов. + public virtual Task OnFile(PageContext ctx, List files, CancellationToken ct) => Task.CompletedTask; + /// Обработка кнопки. + public virtual Task OnButton(PageContext ctx, string payload, CancellationToken ct) => Task.CompletedTask; + /// Выход со страницы. + public virtual Task OnLeave(PageContext ctx, CancellationToken ct) => Task.CompletedTask; + /// Таймаут бездействия. + public virtual Task OnTimeout(PageContext ctx, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask; + /// Обработка ошибок. + public virtual Task OnError(PageContext ctx, Exception ex, CancellationToken ct) => Task.CompletedTask; } \ No newline at end of file diff --git a/BotPages.Core/Pages/PageAction.cs b/BotPages.Core/Pages/PageAction.cs deleted file mode 100644 index 3236a28..0000000 --- a/BotPages.Core/Pages/PageAction.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Универсальное действие (кнопка), которое может быть отображено в разных клиентах. - /// - public sealed class PageAction - { - /// - /// Текст кнопки, отображаемый пользователю. - /// - public string Label { get; init; } = ""; - - /// - /// Значение (payload), которое будет передано в при нажатии. - /// - public string Value { get; init; } = ""; - - /// - /// Тип кнопки: inline или reply. - /// - public ActionPlacement Placement { get; init; } = ActionPlacement.Reply; - - /// - /// Номер ряда для макета (0 — первая строка). - /// - public int Row { get; init; } = 0; - - public PageAction() - { - - } - - - public PageAction(Enum en) - { - Label = en.GetActionLabel(); - Value = en.ToString(); - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Pages/PageMessage.cs b/BotPages.Core/Pages/PageMessage.cs deleted file mode 100644 index d0a8998..0000000 --- a/BotPages.Core/Pages/PageMessage.cs +++ /dev/null @@ -1,39 +0,0 @@ - -/// -/// Параметры сообщения. -/// -public sealed class PageMessage -{ - /// - /// Текст сообщения. - /// - public required string Text { get; init; } - - /// - /// Формат сообщения (Plain/Markdown/Html). - /// - public MessageFormat Format { get; init; } = MessageFormat.Plain; - - /// - /// Отправить сообщение без уведомления (тихий режим). - /// - public bool IsSilent { get; init; } = false; - - public static implicit operator PageMessage(string text) - => new PageMessage { Text = text, Format = MessageFormat.Plain }; -} - -/// -/// Тип форматирования сообщения. -/// -public enum MessageFormat -{ - /// Обычный текст без форматирования. - Plain, - - /// Markdown. - Markdown, - - /// HTML. - Html, -} diff --git a/BotPages.Core/Pages/PageNavigate.cs b/BotPages.Core/Pages/PageNavigate.cs deleted file mode 100644 index f1cd3ee..0000000 --- a/BotPages.Core/Pages/PageNavigate.cs +++ /dev/null @@ -1,21 +0,0 @@ - -/// -/// Параметры навигации на другую страницу. -/// -public sealed class PageNavigate -{ - /// - /// Идентификатор страницы, на которую нужно перейти. - /// - public required string PageId { get; init; } - - /// - /// Дополнительные аргументы для навигации. - /// - public object? Args { get; init; } - - /// - /// Заменить текущую навигацию на новую. - /// - public bool Replace { get; init; } -} diff --git a/BotPages.Core/Pages/PageRegistry.cs b/BotPages.Core/Pages/PageRegistry.cs deleted file mode 100644 index aff9c39..0000000 --- a/BotPages.Core/Pages/PageRegistry.cs +++ /dev/null @@ -1,84 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Базовая реализация реестра страниц на словаре. - /// - public sealed class PageRegistry : IPageRegistry - { - private readonly Dictionary _pages = new(StringComparer.Ordinal); - private readonly IPage _defaultPage; - - /// - /// Создаёт реестр из набора страниц. - /// - public PageRegistry(IEnumerable pages) : this(pages, pages.First()) - { - - } - - /// - /// Создаёт реестр из набора страниц. - /// - public PageRegistry(IEnumerable pages, IPage defaultPage) - { - foreach (var p in pages) _pages[p.Id] = p; - _defaultPage = defaultPage; - } - - /// - /// Возвращает страницу по идентификатору. - /// - public IPage Get(string id) => _pages[id]; - - /// - /// Возвращает страницу по идентификатору. Если страницы нет, возвращает дефолтную. - /// - public IPage GetOrDefault(string id) - => _pages.TryGetValue(id, out var page) ? page : _defaultPage; - - /// - /// Возвращает дефолтную страницу. - /// - public IPage DefaultPage => _defaultPage; - - /// - /// Пытается получить страницу по идентификатору. - /// - public bool TryGet(string id, out IPage? page) => _pages.TryGetValue(id, out page); - - /// - /// Возвращает все зарегистрированные страницы. - /// - public IEnumerable All() => _pages.Values; - - /// - /// Создаёт реестр страниц из всех сборок приложения. - /// - public static PageRegistry CreateFromApplication(string? defaultPageId = null) - { - // Берём все загруженные сборки в текущем AppDomain - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - - // Находим все классы, реализующие IPage - var pages = assemblies - .SelectMany(a => a.GetTypes()) - .Where(t => typeof(IPage).IsAssignableFrom(t) && !t.IsAbstract && t.GetConstructor(Type.EmptyTypes) != null) - .Select(t => (IPage)Activator.CreateInstance(t)!) - .ToList(); - - if (pages.Count == 0) - throw new InvalidOperationException($"В приложении не найдено ни одной страницы ({nameof(IPage)})."); - - // Определяем страницу по умолчанию - var defaultPage = defaultPageId != null - ? pages.FirstOrDefault(p => p.Id == defaultPageId) - : pages.First(); - - if (defaultPage == null) - throw new InvalidOperationException($"Не найдена страница с Id={defaultPageId}."); - - return new PageRegistry(pages, defaultPage); - } - - } -} \ No newline at end of file diff --git a/BotPages.Core/Pages/PageResult.cs b/BotPages.Core/Pages/PageResult.cs deleted file mode 100644 index 0f918e2..0000000 --- a/BotPages.Core/Pages/PageResult.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Результат обработки страницы: текст, файлы, кнопки или навигация. - /// - public sealed class PageResult - { - /// - /// Параметры перехода страницы, на которую нужно перейти. - /// - public PageNavigate? NavigateTo { get; init; } - - /// - /// Текст сообщения (опционально). - /// - public PageMessage? Message { get; init; } - - /// - /// Файлы для отправки (опционально). - /// - public IReadOnlyList? Files { get; init; } - - /// - /// Кнопки (inline или reply), которые должны быть отображены пользователю. - /// - public IReadOnlyList? Actions { get; init; } - } -} diff --git a/BotPages.Core/Pages/PageResultBuilder.cs b/BotPages.Core/Pages/PageResultBuilder.cs deleted file mode 100644 index 5d38847..0000000 --- a/BotPages.Core/Pages/PageResultBuilder.cs +++ /dev/null @@ -1,98 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Билдер для удобного создания . - /// Мутабельный, но итоговый объект иммутабелен. - /// - public sealed class PageResultBuilder - { - private PageNavigate? _navigateTo; - private PageMessage? _message; - private List? _files; - private List? _actions; - - /// - /// Устанавливает текст сообщения. - /// - public PageResultBuilder WithText(string text, MessageFormat format) - => WithText(new PageMessage() - { - Text = text, - Format = format, - }); - - /// - /// Устанавливает текст сообщения. - /// - public PageResultBuilder WithText(string text) - => WithText(new PageMessage() - { - Text = text, - Format = MessageFormat.Plain, - }); - - /// - /// Устанавливает текст сообщения. - /// - public PageResultBuilder WithText(PageMessage message) - { - _message = message; - return this; - } - - /// - /// Добавляет клавиатуру (набор кнопок). - /// - public PageResultBuilder WithKeyboard(IEnumerable actions) - { - _actions = actions?.ToList(); - return this; - } - - /// - /// Добавляет файлы. - /// - public PageResultBuilder WithFiles(IEnumerable files) - { - _files = files?.ToList(); - return this; - } - - /// - /// Устанавливает навигацию на другую страницу. - /// - public PageResultBuilder WithNavigate(string pageId, object? args = null, bool replace = true) - => WithNavigate(new PageNavigate() - { - PageId = pageId, - Args = args, - Replace = replace, - }); - - /// - /// Устанавливает навигацию на другую страницу. - /// - public PageResultBuilder WithNavigate(PageNavigate navigate) - { - _navigateTo = navigate; - return this; - } - - /// - /// Собирает итоговый иммутабельный . - /// - public PageResult Build() => new PageResult - { - Message = _message, - Actions = _actions, - Files = _files, - NavigateTo = _navigateTo, - }; - - /// - /// Создаёт новый пустой билдер. - /// - public static PageResultBuilder Empty() => new PageResultBuilder(); - } - -} \ No newline at end of file diff --git a/BotPages.Core/Pages/PageStateAttribute.cs b/BotPages.Core/Pages/PageStateAttribute.cs new file mode 100644 index 0000000..5aef2d5 --- /dev/null +++ b/BotPages.Core/Pages/PageStateAttribute.cs @@ -0,0 +1,16 @@ +namespace BotPages.Core; + +/// +/// Атрибут для свойств страницы, которые должны сохраняться в StateStorage. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class StatefullAttribute : Attribute +{ + /// + /// Ключ из Storage + /// + public string Key { get; } + + /// + public StatefullAttribute(string key) => Key = key; +} \ No newline at end of file diff --git a/BotPages.Core/Pages/SingletonPage.cs b/BotPages.Core/Pages/SingletonPage.cs new file mode 100644 index 0000000..f743de1 --- /dev/null +++ b/BotPages.Core/Pages/SingletonPage.cs @@ -0,0 +1,7 @@ +namespace BotPages.Core; + +/// +/// Базовый класс страницы без состояния. +/// Создается один экземпляр на все приложение. +/// +public abstract class SingletonPage : Page { } diff --git a/BotPages.Core/Pages/StatefullPage.cs b/BotPages.Core/Pages/StatefullPage.cs new file mode 100644 index 0000000..ae2fefe --- /dev/null +++ b/BotPages.Core/Pages/StatefullPage.cs @@ -0,0 +1,54 @@ +using System.Reflection; + +namespace BotPages.Core; + +/// +/// Базовый класс страницы с состоянием и аргументами. +/// +public abstract class StatefullPage : StatefullPage +{ + /// Вход на страницу с аргументами. + public virtual Task OnEnter(PageContext ctx, TArgs args, CancellationToken ct) + => base.OnEnter(ctx, ct); +} + +/// +/// Базовый класс страницы с состоянием. +/// Создается для каждого пользователя. +/// +public abstract class StatefullPage : Page +{ + /// + /// Загружает значения свойств из StateStorage. + /// + internal async Task LoadState(PageContext ctx, CancellationToken ct) + { + foreach (var prop in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var attr = prop.GetCustomAttribute(); + if (attr is null) continue; + + var value = await ctx.StateStorage.GetAsync(ctx.SessionKey, attr.Key, ct); + + if (value is not null) + { + prop.SetValue(this, value); + } + } + } + + /// + /// Сохраняет значения свойств в StateStorage. + /// + internal async Task SaveState(PageContext ctx, CancellationToken ct) + { + foreach (var prop in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var attr = prop.GetCustomAttribute(); + if (attr is null) continue; + + var value = prop.GetValue(this); + await ctx.StateStorage.SetAsync(ctx.SessionKey, attr.Key, value, ct); + } + } +} \ No newline at end of file diff --git a/BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs b/BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs deleted file mode 100644 index 8ea40e9..0000000 --- a/BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Middleware обработки ошибок для надёжности. - /// - public sealed class ErrorHandlingMiddleware : IUpdateMiddleware - { - /// - /// Перехватывает исключения и отправляет сообщение об ошибке пользователю. - /// - public async Task InvokeAsync(UpdateContext ctx, Func next, CancellationToken ct) - { - try - { - await next(); - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex}"); - await ctx.Client.SendTextAsync(ctx.Chat.Id, "Произошла ошибка. Попробуйте ещё раз. /start", null, ct); - } - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Pipeline/IRouter.cs b/BotPages.Core/Pipeline/IRouter.cs deleted file mode 100644 index 0801f1f..0000000 --- a/BotPages.Core/Pipeline/IRouter.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Маршрутизатор обновлений на страницы. - /// - public interface IRouter - { - /// - /// Определяет текущую страницу и вызывает её обработчик. - /// - Task RouteAsync(UpdateContext ctx, CancellationToken ct); - } -} \ No newline at end of file diff --git a/BotPages.Core/Pipeline/IUpdateMiddleware.cs b/BotPages.Core/Pipeline/IUpdateMiddleware.cs deleted file mode 100644 index dbeb077..0000000 --- a/BotPages.Core/Pipeline/IUpdateMiddleware.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Middleware обработки входящих обновлений. - /// - public interface IUpdateMiddleware - { - /// - /// Вызывает промежуточную логику, затем следующий обработчик или роутер. - /// - Task InvokeAsync(UpdateContext ctx, Func next, CancellationToken ct); - } -} \ No newline at end of file diff --git a/BotPages.Core/Pipeline/LoggingMiddleware.cs b/BotPages.Core/Pipeline/LoggingMiddleware.cs deleted file mode 100644 index 9779e37..0000000 --- a/BotPages.Core/Pipeline/LoggingMiddleware.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Middleware логирования входящих обновлений. - /// - public sealed class LoggingMiddleware : IUpdateMiddleware - { - /// - /// Логирует базовую информацию об обновлении и вызывает следующий этап. - /// - public async Task InvokeAsync(UpdateContext ctx, Func next, CancellationToken ct) - { - Console.WriteLine($"[{DateTime.UtcNow:O}] Update: chat={ctx.Chat.Id}, user={ctx.User.Id}, text={ctx.Text}"); - await next(); - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Pipeline/Pipeline.cs b/BotPages.Core/Pipeline/Pipeline.cs deleted file mode 100644 index 294e0b9..0000000 --- a/BotPages.Core/Pipeline/Pipeline.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace BotPages.Core -{ - - /// - /// Конвейер выполнения middleware и роутера. - /// - public sealed class Pipeline - { - private readonly IReadOnlyList _middlewares; - private readonly IRouter _router; - - /// - /// Создаёт конвейер обработки обновлений. - /// - public Pipeline(IEnumerable middlewares, IRouter router) - { - _middlewares = middlewares.ToList(); - _router = router; - } - - /// - /// Запускает выполнение конвейера для заданного контекста. - /// - public Task ExecuteAsync(UpdateContext ctx, CancellationToken ct) - { - var index = 0; - Task Next() => (index < _middlewares.Count) - ? _middlewares[index++].InvokeAsync(ctx, Next, ct) - : _router.RouteAsync(ctx, ct); - - return Next(); - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Pipeline/Router.cs b/BotPages.Core/Pipeline/Router.cs deleted file mode 100644 index de12a9f..0000000 --- a/BotPages.Core/Pipeline/Router.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Простой роутер: команды верхнего уровня и делегирование текущей странице. - /// - public sealed class Router : IRouter - { - private readonly IPageRegistry _pages; - - /// - /// Создаёт роутер страниц. - /// - public Router(IPageRegistry pages) => _pages = pages; - - /// - /// Определяет текущую страницу и вызывает её обработчик. - /// - public async Task RouteAsync(UpdateContext ctx, CancellationToken ct) - { - var text = ctx.Text ?? string.Empty; - - if (text.StartsWith("/start", StringComparison.Ordinal)) - { - await ctx.Nav.ReplaceAsync(_pages.DefaultPage.Id, null, ctx, ct); - return; - } - - var current = (await ctx.Nav.CurrentAsync(ctx, ct))?.PageId; - if (current is not null) - { - var pr = await _pages.Get(current).HandleAsync(ctx, ct); - await ctx.Nav.ApplyResultAsync(ctx, pr, ct); - return; - } - else - { - await ctx.Nav.ReplaceAsync(_pages.DefaultPage.Id, null, ctx, ct); - return; - } - - //TODO: Вынести в "дефолтный /start page" - await ctx.Client.SendTextAsync(ctx.Chat.Id, "Не понимаю. Нажмите /start.", null, ct); - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Pipeline/ThrottleMiddleware.cs b/BotPages.Core/Pipeline/ThrottleMiddleware.cs deleted file mode 100644 index daaf763..0000000 --- a/BotPages.Core/Pipeline/ThrottleMiddleware.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace BotPages.Core -{ - - /// - /// Middleware троттлинга для ограничений нагрузки. - /// - public sealed class ThrottleMiddleware : IUpdateMiddleware - { - private readonly TimeSpan _delay; - - /// - /// Создаёт middleware троттлинга. - /// - public ThrottleMiddleware(TimeSpan delay) => _delay = delay; - - /// - /// Добавляет искусственную задержку перед продолжением обработки. - /// - public async Task InvokeAsync(UpdateContext ctx, Func next, CancellationToken ct) - { - await Task.Delay(_delay, ct); - await next(); - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Routing/CommandsRegistry.cs b/BotPages.Core/Routing/CommandsRegistry.cs new file mode 100644 index 0000000..627f15f --- /dev/null +++ b/BotPages.Core/Routing/CommandsRegistry.cs @@ -0,0 +1,59 @@ +namespace BotPages.Core.Routing; + +using System.Text.RegularExpressions; + +/// +/// Реестр команд, доступных из любого места. +/// +internal sealed class CommandsRegistry +{ + private readonly List<(Regex pattern, Func handler)> _commands = new(); + + /// + /// Зарегистрировать команду, ведущую на страницу. + /// + public CommandsRegistry Map(string commandTemplate) where TPage : Page + { + var pattern = ToRegex(commandTemplate); + _commands.Add((pattern, (ctx, ct) => ctx.Navigation.GoToAsync(ctx, ct))); + return this; + } + + /// + /// Зарегистрировать команду с кастомным обработчиком. + /// + public CommandsRegistry Map(string commandTemplate, Func handler) + { + var pattern = ToRegex(commandTemplate); + _commands.Add((pattern, handler)); + return this; + } + + /// + /// Попробовать выполнить команду. + /// + public bool TryDispatch(PageContext ctx, string command, CancellationToken ct, out Task? task) + { + foreach (var (pattern, handler) in _commands) + { + if (pattern.IsMatch(command)) + { + task = handler(ctx, ct); + return true; + } + } + task = null; + return false; + } + + private static Regex ToRegex(string template) + { + // Простейшее преобразование шаблона: "/open {page} {id?}" -> Regex + var escaped = Regex.Escape(template) + .Replace("\\{", "{").Replace("\\}", "}"); + var pattern = "^" + escaped + .Replace("{page}", "(?\\S+)") + .Replace("{id?}", "(?\\S+)?") + "$"; + return new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); + } +} diff --git a/BotPages.Core/Routing/RouteAttribute.cs b/BotPages.Core/Routing/RouteAttribute.cs new file mode 100644 index 0000000..66261a3 --- /dev/null +++ b/BotPages.Core/Routing/RouteAttribute.cs @@ -0,0 +1,18 @@ +namespace BotPages.Core.Routing; + +/// +/// Атрибут для декларативного указания маршрута страницы. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class RouteAttribute : Attribute +{ + /// + /// Шаблон маршрута. + /// + public string Template { get; } + + /// + /// Создать атрибут маршрута. + /// + public RouteAttribute(string template) => Template = template; +} diff --git a/BotPages.Core/Routing/RoutesRegistry.cs b/BotPages.Core/Routing/RoutesRegistry.cs new file mode 100644 index 0000000..ae02a71 --- /dev/null +++ b/BotPages.Core/Routing/RoutesRegistry.cs @@ -0,0 +1,29 @@ +namespace BotPages.Core.Routing; + +/// +/// Реестр маршрутов страниц. +/// +internal sealed class RoutesRegistry +{ + private readonly Dictionary _routes = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Зарегистрировать маршрут для страницы. + /// + public void Map(string template) where TPage : Page + { + if (_routes.ContainsKey(template)) + throw new InvalidOperationException($"Route '{template}' is already mapped."); + _routes[template] = typeof(TPage); + } + + /// + /// Найти страницу по маршруту. + /// + public Type? Resolve(string template) => _routes.TryGetValue(template, out var t) ? t : null; + + /// + /// Получить снимок всех маршрутов. + /// + public IReadOnlyDictionary Snapshot() => _routes; +} diff --git a/BotPages.Core/Storage/InMemoryStateStorage.cs b/BotPages.Core/Storage/InMemoryStateStorage.cs new file mode 100644 index 0000000..39cf041 --- /dev/null +++ b/BotPages.Core/Storage/InMemoryStateStorage.cs @@ -0,0 +1,32 @@ +using BotPages.Core.Abstractions; +using System.Collections.Concurrent; + +namespace BotPages.Core.Storage; + +/// +/// Памятное хранилище состояния. +/// +public sealed class InMemoryStateStorage : IStateStorage +{ + private readonly ConcurrentDictionary> _store = new(); + + /// + public Task GetAsync(CompositeSessionKey session, string key, CancellationToken ct) + => Task.FromResult(_store.TryGetValue(session, out var dict) ? + dict.TryGetValue(key, out var obj) ? (T?)obj : default + : default); + + /// + public Task SetAsync(CompositeSessionKey session, string key, T state, CancellationToken ct) + { + _store[session][key] = state!; + return Task.CompletedTask; + } + + /// + public Task RemoveAsync(CompositeSessionKey session, string key, CancellationToken ct) + => Task.FromResult(_store.TryGetValue(session, out var dict) ? dict.TryRemove(key, out _) : true); + + public Task ClearAsync(CompositeSessionKey session, CancellationToken ct) + => Task.FromResult(_store.TryRemove(session, out _)); +} \ No newline at end of file diff --git a/BotPages.Core/Transport/DefaultFileService.cs b/BotPages.Core/Transport/DefaultFileService.cs deleted file mode 100644 index 0665cbe..0000000 --- a/BotPages.Core/Transport/DefaultFileService.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Транспорт-независимая реализация отправки пачек через клиент. - /// - public sealed class DefaultFileService : IFileService - { - /// - /// Заглушка загрузки файла (реализуется в адаптере транспорта). - /// - public Task DownloadAsync(string fileId, CancellationToken ct) - { - throw new System.NotImplementedException("DownloadAsync должен быть реализован конкретным транспортным адаптером."); - } - - /// - /// Загружает несколько файлов по идентификаторам. - /// - public async Task> DownloadManyAsync(IEnumerable fileIds, CancellationToken ct) - { - var res = new List(); - foreach (var id in fileIds) - res.Add(await DownloadAsync(id, ct)); - return res; - } - - /// - /// Отправляет один файл через клиент. - /// - public Task SendAsync(IChatClient client, long chatId, FileDescriptor file, CancellationToken ct) - { - return client.SendFilesAsync(chatId, new[] { file }, ct); - } - - /// - /// Отправляет несколько файлов через клиент. - /// - public Task SendManyAsync(IChatClient client, long chatId, IEnumerable files, CancellationToken ct) - { - return client.SendFilesAsync(chatId, files, ct); - } - } -} \ No newline at end of file diff --git a/BotPages.Core/Transport/IChatClient.cs b/BotPages.Core/Transport/IChatClient.cs deleted file mode 100644 index a599d4d..0000000 --- a/BotPages.Core/Transport/IChatClient.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Универсальный клиент для отправки сообщений и файлов в чат. - /// Адаптеры реализуют этот интерфейс. - /// - public interface IChatClient - { - /// - /// Идентификатор клиента. - /// - string Id { get; init; } - - /// - /// Отправляет текстовое сообщение. - /// Может сопровождаться клавиатурой (inline или reply). - /// - /// Идентификатор чата. - /// Сообщение. - /// Кнопки для отображения (опционально). - /// Токен отмены. - Task SendTextAsync(long chatId, PageMessage message, IEnumerable? actions, CancellationToken ct); - - /// - /// Отправляет файлы в чат. - /// - /// Идентификатор чата. - /// Файлы для отправки. - /// Токен отмены. - Task SendFilesAsync(long chatId, IEnumerable files, CancellationToken ct); - } -} \ No newline at end of file diff --git a/BotPages.Core/Transport/IFileService.cs b/BotPages.Core/Transport/IFileService.cs deleted file mode 100644 index f5bd9f7..0000000 --- a/BotPages.Core/Transport/IFileService.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace BotPages.Core -{ - /// - /// Сервис работы с файлами: загрузка и отправка пакетами. - /// - public interface IFileService - { - /// - /// Загружает файл по идентификатору транспорта. - /// - Task DownloadAsync(string fileId, CancellationToken ct); - - /// - /// Загружает несколько файлов по их идентификаторам. - /// - Task> DownloadManyAsync(IEnumerable fileIds, CancellationToken ct); - - /// - /// Отправляет один файл в чат. - /// - Task SendAsync(IChatClient client, long chatId, FileDescriptor file, CancellationToken ct); - - /// - /// Отправляет несколько файлов в чат. - /// - Task SendManyAsync(IChatClient client, long chatId, IEnumerable files, CancellationToken ct); - } -} \ No newline at end of file diff --git a/BotPages.Telegram/BotPages.Telegram.csproj b/BotPages.Telegram/BotPages.Telegram.csproj index 0a7cc5c..772f4ab 100644 --- a/BotPages.Telegram/BotPages.Telegram.csproj +++ b/BotPages.Telegram/BotPages.Telegram.csproj @@ -1,10 +1,17 @@  - - - net8.0 - enable - enable - + + net8.0 + enable + true + BotPages.Telegram + BotPages + 0.1.0 + FrigaT + MIT + Адаптер для Telegram Bot API. Работает поверх BotPages.Core. + Copyright © 2025 FrigaT + https://git.frigat.duckdns.org/FrigaT/BotPages + diff --git a/BotPages.Telegram/TelegramAdapter.cs b/BotPages.Telegram/TelegramAdapter.cs new file mode 100644 index 0000000..3e02733 --- /dev/null +++ b/BotPages.Telegram/TelegramAdapter.cs @@ -0,0 +1,248 @@ +using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Context; +using BotPages.Core.Logging; +using BotPages.Core.Messaging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace BotPages.Telegram; + + +/// +/// Адаптер для Telegram на базе Telegram.Bot. +/// Реализует отправку текста, кнопок, файлов, альбомов и прогресса. +/// +public sealed class TelegramAdapter : IMessengerAdapter +{ + private readonly ILogger _logger; + private TelegramBotClient? _client; + + /// Создать адаптер Telegram. + public TelegramAdapter(ILogger logger) => _logger = logger; + + /// + /// Запустить polling для приема обновлений от Telegram. + /// + public async Task StartPollingAsync(string token, Func onUpdate, CancellationToken ct) + { + _client = new TelegramBotClient(token); + + _client.StartReceiving( + updateHandler: async (_, update, ct2) => + { + var mapped = TelegramUpdateMapper.Map(update, _client); + if (mapped is not null) + await onUpdate(mapped); + }, + + errorHandler: async (_, ex, ct2) => + { + _logger.Log(LogLevel.Warn, "Telegram error.", ex); + await Task.CompletedTask; + }, + + cancellationToken: ct + ); + + + var me = await _client.GetMe(); + _logger.Log(LogLevel.Info, $"Telegram started: @{me.Username}"); + + return; + } + + /// + public async Task SendTextAsync(PageContext ctx, string text, MessageFormat format, + IEnumerable>? inline, + IEnumerable>? reply, CancellationToken ct) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, "Telegram client is not initialized."); + return; + } + + ReplyMarkup? markup = null; + + if (inline is not null && inline.Any()) + { + markup = new InlineKeyboardMarkup( + inline.Select(row => row.Select(b => new InlineKeyboardButton(b.Label, b.Value)).ToArray()) + .ToArray() + ); + } + else if (reply is not null && reply.Any()) + { + markup = new ReplyKeyboardMarkup( + reply.Select(row => row.Select(b => new KeyboardButton(b.Label)).ToArray()).ToArray() + ) + { + ResizeKeyboard = true + }; + } + + var parseMode = ParseMode.None; + + switch (format) + { + case MessageFormat.Html: + { + parseMode = ParseMode.Html; + break; + } + case MessageFormat.Plain: + { + parseMode = ParseMode.None; + break; + } + case MessageFormat.Markdown: + { + parseMode = ParseMode.MarkdownV2; + break; + } + default: + { + _logger.Log(LogLevel.Warn, $"MessageFormat '{format}' not supported. Degraded to plain text."); + break; + } + } + + // Длина сообщения + if (text.Length > ctx.Update.Chat.Capabilities.MaxMessageLength) + { + _logger.Log(LogLevel.Warn, $"Message too long ({text.Length}). Truncated to {ctx.Update.Chat.Capabilities.MaxMessageLength}."); + text = text.Substring(0, ctx.Update.Chat.Capabilities.MaxMessageLength); + } + + await _client.SendMessage( + chatId: long.Parse(ctx.Update.Chat.Id), + text: text, + parseMode: parseMode, + replyMarkup: markup, + cancellationToken: ct + ); + } + + /// + public async Task SendFileAsync(PageContext ctx, FileDescriptor file, string? caption, CancellationToken ct) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, "Telegram client is not initialized."); + return; + } + + var chatId = long.Parse(ctx.Update.Chat.Id); + + // Получаем поток, если он задан + Stream? stream = null; + if (file.GetStreamAsync is not null) + { + stream = await file.GetStreamAsync(ct); + stream.Position = 0; + } + + InputFile inputFile; + + if (stream is not null && stream != Stream.Null) + { + inputFile = new InputFileStream(stream, file.Name); + } + else if (file.Id.StartsWith("http://") || file.Id.StartsWith("https://")) + { + inputFile = new InputFileUrl(file.Id); + } + else + { + inputFile = new InputFileId(file.Id); + } + + // В зависимости от FileKind выбираем подходящий метод + switch (file.Kind) + { + case FileKind.Photo: + await _client.SendPhoto(chatId, inputFile, caption ?? "", cancellationToken: ct); + break; + case FileKind.Video: + await _client.SendVideo(chatId, inputFile, caption: caption ?? "", cancellationToken: ct); + break; + case FileKind.Audio: + await _client.SendAudio(chatId, inputFile, caption ?? "", cancellationToken: ct); + break; + default: + await _client.SendDocument(chatId, inputFile, caption ?? "", cancellationToken: ct); + break; + } + } + + /// + public IAlbumBuilder CreateAlbumBuilder(PageContext ctx) => new TelegramAlbumBuilder(this, ctx, _logger, _client); + + /// + public async Task StartProgressAsync(PageContext ctx, string title, CancellationToken ct) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, "Telegram client is not initialized."); + return null; + } + + string text = "0%"; + if (!string.IsNullOrEmpty(title)) + { + text = title + Environment.NewLine + text; + } + + var message = await _client.SendMessage( + chatId: long.Parse(ctx.Update.Chat.Id), + text: text, + cancellationToken: ct + ); + + return message.Id.ToString(); + } + + /// + public async Task UpdateProgressAsync(PageContext ctx, string messageId, string title, int percent, CancellationToken ct) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, "Telegram client is not initialized."); + return; + } + + percent = Math.Clamp(percent, 0, 100); + + string text = $"{percent}%"; + if (!string.IsNullOrEmpty(title)) + { + text = title + Environment.NewLine + text; + } + + try + { + await _client.EditMessageText( + messageId: int.Parse(messageId), + chatId: long.Parse(ctx.Update.Chat.Id), + text: text, + cancellationToken: ct + ); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Critical, ex.Message, ex); + } + } + + /// + public Task OnLeaveAsync(PageContext ctx, CancellationToken ct) => Task.CompletedTask; +} \ No newline at end of file diff --git a/BotPages.Telegram/TelegramAlbumBuilder.cs b/BotPages.Telegram/TelegramAlbumBuilder.cs new file mode 100644 index 0000000..2dc5f20 --- /dev/null +++ b/BotPages.Telegram/TelegramAlbumBuilder.cs @@ -0,0 +1,105 @@ +using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Logging; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Telegram.Bot; +using Telegram.Bot.Types; + +namespace BotPages.Telegram; + + +/// +/// Внутренний билдер альбомов (медиагрупп) для Telegram. +/// +public sealed class TelegramAlbumBuilder : IAlbumBuilder +{ + private readonly TelegramAdapter _adapter; + private readonly PageContext _ctx; + private readonly ILogger _logger; + private readonly TelegramBotClient? _client; + private readonly List<(FileDescriptor file, string? caption)> _items = new(); + + /// Создать билдер альбома. + public TelegramAlbumBuilder(TelegramAdapter adapter, PageContext ctx, ILogger logger, TelegramBotClient? client) + { + _adapter = adapter; + _ctx = ctx; + _logger = logger; + _client = client; + } + + /// + public IAlbumBuilder Add(FileDescriptor file, string? caption = null) + { + _items.Add((file, caption)); + return this; + } + + /// + public async Task SendAsync(CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, "Telegram client is not initialized."); + return; + } + + var chatId = long.Parse(_ctx.Update.Chat.Id); + + if (!_ctx.Update.Chat.Capabilities.SupportsAlbums) + { + _logger.Log(LogLevel.Warn, "Albums not supported. Degraded to sequential sends."); + foreach (var (file, caption) in _items) + await _adapter.SendFileAsync(_ctx, file, caption, ct); + return; + } + + var media = new List(); + foreach (var (file, caption) in _items) + { + Stream? stream = null; + if (file.GetStreamAsync is not null) + { + stream = await file.GetStreamAsync(ct); + } + + InputFile inputFile; + + if (stream is not null && stream != Stream.Null) + { + inputFile = new InputFileStream(stream, file.Name); + } + else if (file.Id.StartsWith("http://") || file.Id.StartsWith("https://")) + { + inputFile = new InputFileUrl(file.Id); + } + else + { + inputFile = new InputFileId(file.Id); + } + + IAlbumInputMedia? m = file.Kind switch + { + FileKind.Audio => new InputMediaAudio(inputFile) { Caption = caption ?? "" }, + FileKind.Document => new InputMediaDocument(inputFile) { Caption = caption ?? "" }, + FileKind.Photo => new InputMediaPhoto(inputFile) { Caption = caption ?? "" }, + FileKind.Video => new InputMediaVideo(inputFile) { Caption = caption ?? "" }, + _ => null + }; + + if (m is not null) media.Add(m); + else + { + // Telegram не поддерживает document в альбомах — деградация + _logger.Log(LogLevel.Warn, $"Document '{file.Kind}' in album not supported. Sending document separately."); + await _adapter.SendFileAsync(_ctx, file, caption, ct); + } + } + + if (media.Count > 0) + await _client.SendMediaGroup(chatId, media, cancellationToken: ct); + } +} \ No newline at end of file diff --git a/BotPages.Telegram/TelegramClientAdapter.cs b/BotPages.Telegram/TelegramClientAdapter.cs deleted file mode 100644 index 83625f6..0000000 --- a/BotPages.Telegram/TelegramClientAdapter.cs +++ /dev/null @@ -1,91 +0,0 @@ -using BotPages.Core; -using Telegram.Bot; -using Telegram.Bot.Types; -using Telegram.Bot.Types.Enums; -using Telegram.Bot.Types.ReplyMarkups; -using static System.Net.Mime.MediaTypeNames; - -namespace BotPages.Telegram -{ - /// - /// Адаптер клиента для Telegram Bot API. - /// - public sealed class TelegramClientAdapter : IChatClient - { - private readonly ITelegramBotClient _bot; - - public string Id { get; init; } = nameof(TelegramClientAdapter); - - /// - /// Создаёт адаптер на основе ITelegramBotClient. - /// - public TelegramClientAdapter(ITelegramBotClient bot) => _bot = bot; - - /// - /// Отправляет текстовое сообщение с опциональной клавиатурой. - /// - public Task SendTextAsync(long chatId, PageMessage message, IEnumerable? actions, CancellationToken ct) - { - ReplyMarkup? replyMarkup = null; - - if (actions is { }) - { - var inlineGroups = actions - .Where(a => a.Placement == ActionPlacement.Inline) - .GroupBy(a => a.Row) - .OrderBy(g => g.Key) - .Select(g => g.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, a.Value)).ToArray()) - .ToArray(); - - var replyGroups = actions - .Where(a => a.Placement == ActionPlacement.Reply) - .GroupBy(a => a.Row) - .OrderBy(g => g.Key) - .Select(g => g.Select(a => new KeyboardButton(a.Label)).ToArray()) - .ToArray(); - - if (inlineGroups.Any()) - replyMarkup = new InlineKeyboardMarkup(inlineGroups); - else if (replyGroups.Any()) - replyMarkup = new ReplyKeyboardMarkup(replyGroups) { ResizeKeyboard = true }; - } - - var parseMode = message.Format switch - { - MessageFormat.Markdown => ParseMode.MarkdownV2, - MessageFormat.Html => ParseMode.Html, - _ => ParseMode.None, - }; - - - return _bot.SendMessage(new ChatId(chatId), - message.Text, - parseMode: parseMode, - replyMarkup: replyMarkup, - disableNotification: message.IsSilent, - cancellationToken: ct - ); - } - - - /// - /// Отправляет файлы как документы (по одному или пачкой). - /// - public async Task SendFilesAsync(long chatId, IEnumerable files, CancellationToken ct) - { - foreach (var f in files) - { - if (f.Content is not null) - { - var input = new InputFileStream(f.Content, f.Name); - await _bot.SendDocument(chatId, input, cancellationToken: ct); - } - else - { - // Если контент не загружен, и есть FileId — отправляем по Id - await _bot.SendDocument(chatId, new InputFileId(f.Id), cancellationToken: ct); - } - } - } - } -} \ No newline at end of file diff --git a/BotPages.Telegram/TelegramFileService.cs b/BotPages.Telegram/TelegramFileService.cs deleted file mode 100644 index bb3294c..0000000 --- a/BotPages.Telegram/TelegramFileService.cs +++ /dev/null @@ -1,59 +0,0 @@ -using BotPages.Core; -using Telegram.Bot; - -namespace BotPages.Telegram -{ - /// - /// FileService для Telegram: загрузка по FileId. - /// - public sealed class TelegramFileService : IFileService - { - private readonly ITelegramBotClient _bot; - - /// - /// Создаёт файловый сервис для Telegram. - /// - public TelegramFileService(ITelegramBotClient bot) => _bot = bot; - - /// - /// Загружает файл по идентификатору Telegram FileId. - /// - public async Task DownloadAsync(string fileId, CancellationToken ct) - { - var file = await _bot.GetFile(fileId, ct); - var ms = new MemoryStream(); - await _bot.DownloadFile(file.FilePath!, ms, ct); - ms.Position = 0; - var name = System.IO.Path.GetFileName(file.FilePath!); - return new FileDescriptor(file.FileId, name, "application/octet-stream", ms); - } - - /// - /// Загружает несколько файлов по их идентификаторам. - /// - public async Task> DownloadManyAsync(IEnumerable fileIds, CancellationToken ct) - { - var res = new List(); - foreach (var id in fileIds) - res.Add(await DownloadAsync(id, ct)); - return res; - } - - /// - /// Отправляет один файл в чат. - /// - public async Task SendAsync(IChatClient client, long chatId, FileDescriptor file, CancellationToken ct) - { - await client.SendFilesAsync(chatId, new[] { file }, ct); - } - - /// - /// Отправляет несколько файлов в чат. - /// - public Task SendManyAsync(IChatClient client, long chatId, IEnumerable files, CancellationToken ct) - { - return client.SendFilesAsync(chatId, files, ct); - } - } - -} \ No newline at end of file diff --git a/BotPages.Telegram/TelegramUpdateMapper.cs b/BotPages.Telegram/TelegramUpdateMapper.cs index 60d1dd7..0e73fff 100644 --- a/BotPages.Telegram/TelegramUpdateMapper.cs +++ b/BotPages.Telegram/TelegramUpdateMapper.cs @@ -1,46 +1,157 @@ using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Context; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; using Telegram.Bot; using Telegram.Bot.Types; -namespace BotPages.Telegram +namespace BotPages.Telegram; + +/// +/// Утилита для маппинга Telegram Update → UpdateContext. +/// +public static class TelegramUpdateMapper { /// - /// Утилиты для извлечения контекста из Telegram Update. + /// Маппинг Telegram Update в UpdateContext BotPages. /// - public static class TelegramUpdateMapper + public static UpdateContext Map(Update update, TelegramBotClient client) { - /// - /// Преобразует Telegram Update в универсальный UpdateContext. - /// - public static UpdateContext Map(ITelegramBotClient bot, INavigationService nav, IStateStore store, Update update) + var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat; + var user = update.Message?.From ?? update.CallbackQuery?.From; + + var userContext = new UserContext { - var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat; - var user = update.Message?.From ?? update.CallbackQuery?.From; + Id = user?.Id.ToString() ?? "unknown", + DisplayName = user?.Username, + }; - var text = update.Message?.Text ?? update.CallbackQuery?.Data; - - var files = new List(); - if (update.Message?.Document is { } doc) + var chatContext = new ChatContext + { + Id = chat?.Id.ToString() ?? "unknown", + Title = chat?.Title, + Capabilities = new Capabilities { - files.Add(new FileDescriptor(doc.FileId, doc.FileName ?? "file", doc.MimeType ?? "application/octet-stream")); + SupportsInlineButtons = true, + SupportsReplyButtons = true, + SupportsAlbums = true, + SupportsFormattingMarkdown = true, + SupportsFormattingHtml = true, + MaxMessageLength = 4096, } - if (update.Message?.Photo is { } photos && photos.Count() > 0) + }; + + string? text = null; + UpdateKind kind = UpdateKind.None; + var files = new List(); + + if (update.Message is { } msg) + { + + if (msg.Text is not null) { - var largest = photos.OrderBy(p => p.FileSize).Last(); - files.Add(new FileDescriptor(largest.FileId, "photo.jpg", "image/jpeg")); + text = msg.Text; + kind |= UpdateKind.Text; } - return new UpdateContext + if (msg.Photo is { Length: > 0 }) { - Client = new TelegramClientAdapter(bot), - Chat = new ChatContext { Id = chat!.Id, Title = chat.Title }, - User = new UserContext { Id = user!.Id, DisplayName = $"{user.FirstName} {user.LastName}" }, - Text = text, - IncomingFiles = files, - RawUpdate = update, - Nav = nav, - State = store - }; + foreach (var p in msg.Photo) + { + files.Add(new FileDescriptor + { + Id = p.FileId, + Name = "photo", + Extension = "jpg", + Size = p.FileSize ?? 0, + Kind = FileKind.Photo, + GetStreamAsync = GetStreamAsync(client, p.FileId), + }); + } + } + + if (msg.Document is not null) + { + files.Add(new FileDescriptor + { + Id = msg.Document.FileId, + Name = msg.Document.FileName ?? "document", + Extension = System.IO.Path.GetExtension(msg.Document.FileName) ?? "bin", + Size = msg.Document.FileSize ?? 0, + Kind = FileKind.Document, + Mime = msg.Document.MimeType, + GetStreamAsync = GetStreamAsync(client, msg.Document.FileId), + }); + } + + if (msg.Audio is not null) + { + files.Add(new FileDescriptor + { + Id = msg.Audio.FileId, + Name = msg.Audio.FileName ?? "audio", + Extension = System.IO.Path.GetExtension(msg.Audio.FileName) ?? "mp3", + Size = msg.Audio.FileSize ?? 0, + Kind = FileKind.Audio, + Mime = msg.Audio.MimeType, + GetStreamAsync = GetStreamAsync(client, msg.Audio.FileId), + }); + } + + if (msg.Video is not null) + { + files.Add(new FileDescriptor + { + Id = msg.Video.FileId, + Name = "video", + Extension = "mp4", + Size = msg.Video.FileSize ?? 0, + Kind = FileKind.Video, + Mime = msg.Video.MimeType, + GetStreamAsync = GetStreamAsync(client, msg.Video.FileId), + }); + } + + if (files.Count > 0) + { + kind |= UpdateKind.File; + } } + + if (update.CallbackQuery is { } cb) + { + kind |= UpdateKind.Button; + text = cb.Data; + } + + + return new UpdateContext + { + MessengerType = "Telegram", + User = userContext, + Chat = chatContext, + Text = text, + Kind = kind, + Files = files + }; + } + + private static Func> GetStreamAsync(TelegramBotClient client, string fileId) + { + + Func> getStreamAsync = async _ => + { + var file = await client.GetFile(fileId); + var stream = new MemoryStream(); + await client.DownloadFile(file, stream); + stream.Position = 0; + return stream; + }; + + return getStreamAsync; } } \ No newline at end of file diff --git a/Demo/Pages/ConfirmPage.cs b/Demo/Pages/ConfirmPage.cs new file mode 100644 index 0000000..c9b526a --- /dev/null +++ b/Demo/Pages/ConfirmPage.cs @@ -0,0 +1,25 @@ +using BotPages.Core; +using BotPages.Core.Messaging; + +namespace Demo.Pages; + +/// +/// Страница подтверждения заявки. +/// +public sealed class ConfirmPage : SingletonPage +{ + public override Task OnEnter(PageContext ctx, CancellationToken ct) + => new MessageBuilder(ctx) + .Text("Подтвердите заявку ✅") + .Inline("Отправить", "submit") + .Inline("Отмена", "cancel") + .SendAsync(ct); + + public override Task OnButton(PageContext ctx, string payload, CancellationToken ct) + => payload switch + { + "submit" => ctx.Navigation.GoToAsync(ctx, ct), + "cancel" => ctx.Navigation.GoToAsync(ctx, ct), + _ => Task.CompletedTask + }; +} diff --git a/Demo/Pages/DetailsPage.cs b/Demo/Pages/DetailsPage.cs new file mode 100644 index 0000000..d55eb88 --- /dev/null +++ b/Demo/Pages/DetailsPage.cs @@ -0,0 +1,33 @@ +using BotPages.Core; +using BotPages.Core.Messaging; + +namespace Demo.Pages; + +/// +/// Страница ввода деталей заявки. +/// +public sealed class DetailsPage : StatefullPage +{ + public override Task OnEnter(PageContext ctx, DetailsArgs args, CancellationToken ct) + => new MessageBuilder(ctx) + .Text($"Заголовок: {args.Title}\nДобавьте детали или нажмите Далее.") + .Inline(new InlineButton("Далее", "next"), new InlineButton("Назад", "back")) + .Reply("Отмена") + .SendAsync(ct); + + public override Task OnButton(PageContext ctx, string payload, CancellationToken ct) + => payload switch + { + "next" => ctx.Navigation.GoToAsync(ctx, ct), + "back" => ctx.Navigation.GoToAsync(ctx, ct), + _ => Task.CompletedTask + }; +} + +/// +/// Аргументы для страницы DetailsPage. +/// +public sealed class DetailsArgs +{ + public string Title { get; set; } = ""; +} \ No newline at end of file diff --git a/Demo/Pages/FileSendPage.cs b/Demo/Pages/FileSendPage.cs new file mode 100644 index 0000000..06861f0 --- /dev/null +++ b/Demo/Pages/FileSendPage.cs @@ -0,0 +1,29 @@ +using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Messaging; + +namespace Demo.Pages; + +public sealed class FileSendPage : SingletonPage +{ + public override Task OnEnter(PageContext ctx, CancellationToken ct) + { + var content = "Hello from BotPages! This file is generated on the fly."; + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); + + var demoFile = new FileDescriptor + { + Id = "", // не используется при отправке нового файла + Name = "demo.txt", + Extension = "txt", + Size = stream.Length, + Kind = FileKind.Document, + GetStreamAsync = _ => Task.FromResult(stream) + }; + + return new MessageBuilder(ctx) + .Text("Вот пример отправки нового файла 📎", MessageFormat.Markdown) + .File(demoFile, "Демонстрационный файл") + .SendAsync(ct); + } +} diff --git a/Demo/Pages/FilesPage.cs b/Demo/Pages/FilesPage.cs index 5c5e125..ca1553c 100644 --- a/Demo/Pages/FilesPage.cs +++ b/Demo/Pages/FilesPage.cs @@ -1,39 +1,36 @@ using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Messaging; -namespace Demo.Pages +namespace Demo.Pages; + +/// +/// Страница загрузки файлов. +/// +public sealed class FilesPage : Page { - public sealed class FilesPage : Page + public override Task OnEnter(PageContext ctx, CancellationToken ct) + => new MessageBuilder(ctx) + .Text("Пришлите файлы для заявки 📎", MessageFormat.Markdown) + .Reply("Пропустить") + .SendAsync(ct); + + public override async Task OnFile(PageContext ctx, List files, CancellationToken ct) { - public static string Id => nameof(FilesPage); - - public override Task EnterAsync(UpdateContext ctx, CancellationToken ct) + foreach (var file in files) { - var actions = new[] - { - new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Reply, Row = 0 } - }; - - return Task.FromResult( - PageResultBuilder.Empty() - .WithText("📂 Здесь можно загрузить или отправить файл.") - .WithKeyboard(actions) - .Build() - ); + await ctx.SendFileAsync(file, $"Файл '{file.Name}' получен и отправлен обратно.", ct); } - public override async Task HandleAsync(UpdateContext ctx, CancellationToken ct) - { - if (ctx.Text == "⬅️ Назад") - return PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build(); - - if (ctx.IncomingFiles?.Count > 0) - { - await ctx.Client.SendFilesAsync(ctx.Chat.Id, ctx.IncomingFiles, ct); - return PageResultBuilder.Empty().WithText("Файл получен и отправлен обратно.").Build(); - } - - return PageResultBuilder.Empty().WithText("Пришлите файл или нажмите 'Назад'.").Build(); - } + await new MessageBuilder(ctx) + .Text($"Получено файлов: {files.Count}", MessageFormat.Plain) + .Inline("Далее", "next") + .SendAsync(ct); } + public override Task OnButton(PageContext ctx, string payload, CancellationToken ct) + => ctx.Navigation.GoToAsync(ctx, ct); + + public override Task OnText(PageContext ctx, string text, CancellationToken ct) + => ctx.Navigation.GoToAsync(ctx, ct); } diff --git a/Demo/Pages/InlinePage.cs b/Demo/Pages/InlinePage.cs deleted file mode 100644 index cd4285e..0000000 --- a/Demo/Pages/InlinePage.cs +++ /dev/null @@ -1,32 +0,0 @@ -using BotPages.Core; - -namespace Demo.Pages -{ - public sealed class InlinePage : Page - { - public override string Id => nameof(InlinePage); - - public override Task EnterAsync(UpdateContext ctx, CancellationToken ct) - { - var actions = new[] - { - new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Inline, Row = 0 } - }; - - return Task.FromResult( - PageResultBuilder.Empty() - .WithText("Это страница с Inline‑кнопками.") - .WithKeyboard(actions) - .Build() - ); - } - - public override Task HandleAsync(UpdateContext ctx, CancellationToken ct) - { - if (ctx.Text == "back") - return Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build()); - - return Task.FromResult(PageResultBuilder.Empty().WithText("Нажмите кнопку 'Назад'.").Build()); - } - } -} diff --git a/Demo/Pages/MainPage.cs b/Demo/Pages/MainPage.cs deleted file mode 100644 index 98f5e9f..0000000 --- a/Demo/Pages/MainPage.cs +++ /dev/null @@ -1,51 +0,0 @@ -using BotPages.Core; - -namespace Demo.Pages -{ - public sealed class MainPage : Page - { - public override string Id => nameof(MainPage); - - public override Task EnterAsync(UpdateContext ctx, CancellationToken ct) - { - var actions = new[] - { - new PageAction(MainPageButtons.Inline) { Placement = ActionPlacement.Reply, Row = 0 }, - new PageAction(MainPageButtons.Reply) { Placement = ActionPlacement.Reply, Row = 1 }, - new PageAction(MainPageButtons.Files) { Placement = ActionPlacement.Reply, Row = 2 }, - }; - - return Task.FromResult( - PageResultBuilder.Empty() - .WithText("🏠 Главная страница.\nВыберите куда перейти:") - .WithKeyboard(actions) - .Build() - ); - } - - public override Task HandleAsync(UpdateContext ctx, CancellationToken ct) - { - var button = ActionExtensions.FromActionLabel(ctx.Text); - - return button switch - { - MainPageButtons.Inline => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(InlinePage)).Build()), - MainPageButtons.Reply => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(ReplyPage)).Build()), - MainPageButtons.Files => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(FilesPage)).Build()), - _ => Task.FromResult(PageResultBuilder.Empty().WithText("Выберите действие с кнопок.").Build()) - }; - } - } - - public enum MainPageButtons - { - [Action("📌 Inline")] - Inline, - - [Action("⌨️ Reply")] - Reply, - - [Action("📂 Файлы")] - Files, - } -} diff --git a/Demo/Pages/ReplyPage.cs b/Demo/Pages/ReplyPage.cs deleted file mode 100644 index 8418bbd..0000000 --- a/Demo/Pages/ReplyPage.cs +++ /dev/null @@ -1,33 +0,0 @@ -using BotPages.Core; - -namespace Demo.Pages -{ - public sealed class ReplyPage : Page - { - public override string Id => nameof(ReplyPage); - - public override Task EnterAsync(UpdateContext ctx, CancellationToken ct) - { - var actions = new[] - { - new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Reply, Row = 0 } - }; - - return Task.FromResult( - PageResultBuilder.Empty() - .WithText("Это страница с Reply‑клавиатурой.") - .WithKeyboard(actions) - .Build() - ); - } - - public override Task HandleAsync(UpdateContext ctx, CancellationToken ct) - { - if (ctx.Text == "⬅️ Назад") - return Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build()); - - return Task.FromResult(PageResultBuilder.Empty().WithText("Нажмите кнопку 'Назад'.").Build()); - } - } - -} diff --git a/Demo/Pages/SubmitPage.cs b/Demo/Pages/SubmitPage.cs new file mode 100644 index 0000000..d4f820a --- /dev/null +++ b/Demo/Pages/SubmitPage.cs @@ -0,0 +1,37 @@ +using BotPages.Core; +using BotPages.Core.Messaging; + +namespace Demo.Pages; + +/// +/// Финальная страница отправки заявки. +/// +public sealed class SubmitPage : SingletonPage +{ + public override async Task OnEnter(PageContext ctx, CancellationToken ct) + { + var progress = new MessageBuilder(ctx); + + await progress + .Progress("Отправка заявки", 7) + .SendAsync(ct); + + int i = 7; + do + { + i += 25; + Thread.Sleep(TimeSpan.FromMilliseconds(200)); + await progress + .Progress("Отправка заявки", i) + .SendAsync(ct); + } + while (i < 100); + + await ctx.Navigation.GoToHome(ctx, ct); + } + + public override Task OnLeave(PageContext ctx, CancellationToken ct) + { + return new MessageBuilder(ctx).Text("Заявка отправлена").SendAsync(ct); + } +} \ No newline at end of file diff --git a/Demo/Pages/TitlePage.cs b/Demo/Pages/TitlePage.cs new file mode 100644 index 0000000..479550d --- /dev/null +++ b/Demo/Pages/TitlePage.cs @@ -0,0 +1,18 @@ +using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Messaging; + +namespace Demo.Pages; +/// +/// Страница ввода заголовка заявки. +/// +public sealed class TitlePage : SingletonPage +{ + public override Task OnEnter(PageContext ctx, CancellationToken ct) + => new MessageBuilder(ctx) + .Text("Введите заголовок заявки:", MessageFormat.Markdown) + .SendAsync(ct); + + public override Task OnText(PageContext ctx, string text, CancellationToken ct) + => ctx.Navigation.GoToAsync(ctx, new DetailsArgs { Title = text }, ct); +} \ No newline at end of file diff --git a/Demo/Pages/WelcomePage.cs b/Demo/Pages/WelcomePage.cs new file mode 100644 index 0000000..14db29c --- /dev/null +++ b/Demo/Pages/WelcomePage.cs @@ -0,0 +1,56 @@ +using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Messaging; + +namespace Demo.Pages; + +/// +/// Стартовая страница демо‑бота. +/// +public sealed class WelcomePage : SingletonPage +{ + public override Task OnEnter(PageContext ctx, CancellationToken ct) + => new MessageBuilder(ctx) + .Text("Добро пожаловать! 🚀") + .Reply(WelcomePageButtons.CreateRequest) + .Reply(WelcomePageButtons.Help) + .Reply(WelcomePageButtons.SendFile) + .SendAsync(ct); + + public override Task OnText(PageContext ctx, string text, CancellationToken ct) + { + var button = ButtonExtensions.FromButtonLabel(text); + + switch (button) + { + case WelcomePageButtons.CreateRequest: + { + return ctx.Navigation.GoToAsync(ctx, ct); + } + + case WelcomePageButtons.Help: + { + return new MessageBuilder(ctx).Text("Здесь будет справка.", MessageFormat.Plain).SendAsync(ct); + } + + case WelcomePageButtons.SendFile: + { + return ctx.Navigation.GoToAsync(ctx, ct); + } + } + + return base.OnText(ctx, text, ct); + } +} + +public enum WelcomePageButtons +{ + [Button("Создать заявку")] + CreateRequest, + + [Button("Помощь")] + Help, + + [Button("Отправка файла")] + SendFile, +} \ No newline at end of file diff --git a/Demo/Program.cs b/Demo/Program.cs index bc37d4b..f414227 100644 --- a/Demo/Program.cs +++ b/Demo/Program.cs @@ -1,7 +1,10 @@ using BotPages.Core; +using BotPages.Core.Abstractions; +using BotPages.Core.Logging; +using BotPages.Core.Middleware; +using BotPages.Core.Storage; using BotPages.Telegram; using Demo.Pages; -using Telegram.Bot; namespace Demo { @@ -9,57 +12,30 @@ namespace Demo { public static async Task Main(string[] args) { - // Токен Telegram бота - var token = Environment.GetEnvironmentVariable("TELEGRAM_TOKEN") ?? throw new InvalidOperationException("TELEGRAM_TOKEN not set"); + var token = Environment.GetEnvironmentVariable("TELEGRAM_TOKEN") + ?? throw new InvalidOperationException("TELEGRAM_TOKEN not set"); - // Инициализация Telegram клиента - var botClient = new TelegramBotClient(token); - var chatClient = new TelegramClientAdapter(botClient); + var logger = new ConsoleLogger(); + var state = new InMemoryStateStorage(); + var telegram = new TelegramAdapter(logger); + var factory = new MultiAdapterFactory() + .Register("Telegram", telegram); - // Регистрируем страницы - var pages = new IPage[] - { - new MainPage(), - new InlinePage(), - new ReplyPage(), - new FilesPage() - }; - var registry = new PageRegistry(pages, pages[0]); + var app = new BotPagesApp(factory, state, logger) + .AddDefaultPage() + .MapCommand("/start") + .AddMiddleware(new ErrorHandlingMiddleware(logger)) + .AddMiddleware(new LoggingMiddleware(logger)); - // Навигация и состояние - IStateStore store = new InMemoryStateStore(); - INavigationService nav = new NavigationService(registry, store); - var router = new Router(registry); + using var cts = new CancellationTokenSource(); - var middleware = new IUpdateMiddleware[] - { - new LoggingMiddleware(), //логирование вызова в консоль - new ErrorHandlingMiddleware(), //обработчик ошибок - //new ThrottleMiddleware(TimeSpan.FromMilliseconds(150)), //задержка в 150мс перед ответом - }; + await telegram.StartPollingAsync(token, + update => app.HandleUpdateAsync(update, CancellationToken.None), + cts.Token); - var pipeline = new Pipeline(middleware, router); - - botClient.StartReceiving( - async (bot, update, ct) => - { - var ctx = TelegramUpdateMapper.Map(bot, nav, store, update); - - await pipeline.ExecuteAsync(ctx, ct); - }, - (bot, error, ct) => - { - Console.WriteLine($"⚠️ Ошибка Telegram: {error}"); - return Task.CompletedTask; - } - ); - - var me = await botClient.GetMe(); - - Console.WriteLine($"BotPages Demo (@{me.Username}) запущен. Нажмите Enter для выхода."); - Console.ReadLine(); + Console.ReadKey(); + cts.Cancel(); } - } } diff --git a/TZ.md b/TZ.md new file mode 100644 index 0000000..f76bd8b --- /dev/null +++ b/TZ.md @@ -0,0 +1,163 @@ +# Техническое задание +## Проект: BotPages + +### 1. Общая информация +- **Название:** BotPages +- **Цель:** Создание библиотеки (NuGet‑пакета) для управления страницами в ботах (Telegram, VK, Discord, WhatsApp и др.), позволяющей строить логику без жёсткой привязки к конкретному API. +- **Платформа:** .NET 8 +- **Артефакты:** + - `BotPages.Core` — ядро, независимое от транспорта. + - `BotPages.Telegram` — адаптер для Telegram. + - В будущем: адаптеры для VK, Discord, WhatsApp. +- **Демо:** `Demo.exe` — демонстрация работы (создание заявки через несколько страниц, кнопки, файлы). + +--- + +### 2. Основные понятия +- **Page** — класс, отвечающий за состояние экрана бота. + - `Page` — базовый класс. + - `Page` — страница с аргументами. + - `ModalPage` / `ModalPage` — модальная страница (перехватывает ввод, блокирует переходы). +- **Контекст:** + - `UserContext` — данные пользователя (UserId, MessengerType). + - `ChatContext` — данные чата (ChatId, Title, ThreadId?, ленивое обновление). + - `PageContext` — объединяет UserContext, ChatContext, состояние, навигацию, файлы. +- **Состояние:** + - `IStateStorage` — универсальный интерфейс хранения. + - Базовая реализация: InMemory. + - Ключ: `CompositeSessionKey(MessengerType:string, ChatId, UserId?)`. + - История состояний: опционально (None, LastN, TimeWindow, Full). + +--- + +### 3. Жизненный цикл страницы +- Методы (все async, с `CancellationToken`): + - `OnEnter(ctx)` — вход. + - `OnUpdate(ctx, update)` — общий обработчик. + - `OnText(ctx, text)` — текстовые сообщения. + - `OnFile(ctx, file)` — файлы. + - `OnButton(ctx, action)` — кнопки. + - `OnLeave(ctx)` — выход. + - `OnError(ctx, exception)` — ошибки. + - `OnTimeout(ctx, timeoutInfo)` — таймауты. +- Прогресс фоновых операций: + - `StartProgress()`, `UpdateProgress(percent)` + событие обновления. + +--- + +### 4. Навигация +- Императивный API: + ```csharp + ctx.GoTo(); + ctx.GoTo(new ConfirmationArgs { PhotoId = photoId }); + ctx.GoTo(args => { args.Photo = photoId; }); + ctx.ReplaceWith(); + ctx.Back(); + ``` +- Декларативный роутинг: + - Атрибуты `[Route("order/create")]`. + - Реестр `routes.Map("order/create")`. + - Проверка конфликтов при старте. +- Стек навигации: отдельный пакет `BotPages.Navigation.Stack`. + +--- + +### 5. Команды +- Команды имеют приоритет над событиями страниц. +- Поддержка шаблонов: + ```csharp + app.Commands.Map("/start").To(); + app.Commands.Map("/open {page} {id?}") + .To(ctx => ctx.GoToByName(page, new { id })); + ``` +- Возможность указать страницу, которая открывается при вводе команды. + +--- + +### 6. Файлы +- **FileDescriptor:** + - `Id`, `Name`, `Extension`, `Size`, `Mime`, `Type(enum)`, `SourceMessenger`, `GetStreamAsync()`, `Checksum?`. +- Отправка: + ```csharp + await ctx.Chat.SendFileAsync(file, caption: "Документ"); + await ctx.Chat.Files.BeginAlbum().Add(file1).Add(file2).SendAsync(); + ``` +- Альбомы, сжатие — на уровне адаптера. +- Метаданные файлов — временно в контексте, экспорт в состояние вручную. + +--- + +### 7. Адаптеры +- Интерфейс `IMessengerAdapter`: + - `SendMessage`, `EditMessage`, `DeleteMessage`, `SendFile`, `SendAlbum`. + - `ReceiveUpdate`. + - `GetChat`, `GetFileStream`. + - `AnswerCallback`, `SetTypingIndicator`. +- Telegram: первая реализация. +- Поддержка **Webhook** и **Polling** (выбор конфигом). + +--- + +### 8. Capabilities +- В `ChatContext.Capabilities`: + - `SupportsInlineButtons`, `SupportsReplyButtons`, `SupportsAlbums`, `SupportsFormattingMarkdown`, `SupportsFormattingHtml`, `MaxMessageLength`. +- Разработчик может проверять возможности. +- Адаптер всегда деградирует и логирует `Warn`. + +--- + +### 9. Логирование +- Уровни: `Info`, `Warn`, `Critical`. +- Примеры: + - Info: вход на страницу, прогресс. + - Warn: деградация возможностей. + - Critical: падение хранилища. + +--- + +### 10. Middleware +- Интерфейс: + ```csharp + public interface IUpdateMiddleware + { + Task InvokeAsync(UpdateContext ctx, Func next, CancellationToken ct); + } + ``` +- Пример: + ```csharp + app.AddMiddleware(); + app.AddMiddleware(params); + ``` +- Порядок регистрации = порядок выполнения. +- Middleware только для входящих обновлений. + +--- + +### 11. Дефолтная страница +- Конфигурация: + ```csharp + app.UseDefaultPage(); + ``` +- Одна дефолтная страница для всех мессенджеров. +- Условия не нужны. + +--- + +### 12. Demo.exe +- Сценарий: создание заявки. +- Страницы: + 1. **StartPage** — приветствие, кнопка «Создать заявку». + 2. **TitlePage** — ввод текста. + 3. **DetailsPage** — доп. поля, inline/reply кнопки. + 4. **FilesPage** — загрузка файлов, просмотр списка. + 5. **ConfirmPage** — подтверждение заявки. + 6. **SubmitPage** — отправка, итоговое сообщение. + +--- + +### 13. TODO (будущие версии) +- Поддержка модальных страниц со стеком. +- Расширенные Capabilities (rate limits, threads). +- View‑DSL как надстройка над контекстом. +- Cross‑transport identity. +- Расширенные таймауты и политики отката.