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