diff --git a/BotPages.Core/BotPages.Core.csproj b/BotPages.Core/BotPages.Core.csproj
new file mode 100644
index 0000000..fa71b7a
--- /dev/null
+++ b/BotPages.Core/BotPages.Core.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/BotPages.Core/Context/ChatContext.cs b/BotPages.Core/Context/ChatContext.cs
new file mode 100644
index 0000000..a263e51
--- /dev/null
+++ b/BotPages.Core/Context/ChatContext.cs
@@ -0,0 +1,18 @@
+namespace BotPages.Core
+{
+ ///
+ /// Описывает чат/конверсацию для универсального контекста.
+ ///
+ public sealed class ChatContext
+ {
+ ///
+ /// Уникальный идентификатор чата/диалога.
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// Человеко-читаемое имя чата (если доступно).
+ ///
+ public string? Title { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/BotPages.Core/Context/FileDescriptor.cs b/BotPages.Core/Context/FileDescriptor.cs
new file mode 100644
index 0000000..9222fc1
--- /dev/null
+++ b/BotPages.Core/Context/FileDescriptor.cs
@@ -0,0 +1,12 @@
+namespace BotPages.Core
+{
+ ///
+ /// Универсальный дескриптор файла для операций загрузки/отправки.
+ ///
+ public sealed record FileDescriptor(
+ string Id,
+ string Name,
+ string MimeType,
+ Stream? Content = null
+ );
+}
\ No newline at end of file
diff --git a/BotPages.Core/Context/UpdateContext.cs b/BotPages.Core/Context/UpdateContext.cs
new file mode 100644
index 0000000..4a687cb
--- /dev/null
+++ b/BotPages.Core/Context/UpdateContext.cs
@@ -0,0 +1,48 @@
+namespace BotPages.Core
+{
+ ///
+ /// Универсальный контекст обновления, независимый от транспорта.
+ ///
+ public sealed class UpdateContext
+ {
+ ///
+ /// Клиент транспорта для отправки сообщений/файлов.
+ ///
+ public required IChatClient Client { get; init; }
+
+ ///
+ /// Контекст чата.
+ ///
+ public required ChatContext Chat { get; init; }
+
+ ///
+ /// Контекст пользователя.
+ ///
+ public required UserContext User { get; init; }
+
+ ///
+ /// Текст сообщения или полезная нагрузка колбэка, если доступна.
+ ///
+ 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
diff --git a/BotPages.Core/Context/UserContext.cs b/BotPages.Core/Context/UserContext.cs
new file mode 100644
index 0000000..16bc1ec
--- /dev/null
+++ b/BotPages.Core/Context/UserContext.cs
@@ -0,0 +1,18 @@
+namespace BotPages.Core
+{
+ ///
+ /// Описывает пользователя для универсального контекста.
+ ///
+ public sealed class UserContext
+ {
+ ///
+ /// Уникальный идентификатор пользователя в транспортном слое.
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// Отображаемое имя пользователя (если доступно).
+ ///
+ public string? DisplayName { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/BotPages.Core/Navigation/INavigationService.cs b/BotPages.Core/Navigation/INavigationService.cs
new file mode 100644
index 0000000..8a9fa97
--- /dev/null
+++ b/BotPages.Core/Navigation/INavigationService.cs
@@ -0,0 +1,38 @@
+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
new file mode 100644
index 0000000..e003b36
--- /dev/null
+++ b/BotPages.Core/Navigation/IStateStore.cs
@@ -0,0 +1,18 @@
+namespace BotPages.Core
+{
+ ///
+ /// Простое in-memory хранилище состояния пользователя.
+ ///
+ public interface IStateStore
+ {
+ ///
+ /// Получает состояние пользователя.
+ ///
+ Task GetAsync(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
new file mode 100644
index 0000000..6ea0af1
--- /dev/null
+++ b/BotPages.Core/Navigation/InMemoryStateStore.cs
@@ -0,0 +1,33 @@
+namespace BotPages.Core
+{
+
+ ///
+ /// In-memory реализация хранилища состояния для прототипирования.
+ ///
+ public sealed class InMemoryStateStore : IStateStore
+ {
+ private readonly Dictionary _store = new();
+
+ ///
+ /// Получает состояние пользователя, создавая новое при отсутствии.
+ ///
+ public Task GetAsync(long userId, CancellationToken ct)
+ {
+ if (!_store.TryGetValue(userId, out var st))
+ {
+ st = new UserState { UserId = userId };
+ _store[userId] = st;
+ }
+ return Task.FromResult(st);
+ }
+
+ ///
+ /// Сохраняет состояние пользователя.
+ ///
+ public Task SaveAsync(UserState state, CancellationToken ct)
+ {
+ _store[state.UserId] = state;
+ return Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/BotPages.Core/Navigation/NavigationService.cs b/BotPages.Core/Navigation/NavigationService.cs
new file mode 100644
index 0000000..745c28f
--- /dev/null
+++ b/BotPages.Core/Navigation/NavigationService.cs
@@ -0,0 +1,108 @@
+namespace BotPages.Core
+{
+ ///
+ /// Реализация сервиса навигации страниц.
+ ///
+ public sealed class NavigationService : INavigationService
+ {
+ 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.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.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.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.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.User.Id, ct);
+ return state.Stack.AsReadOnly();
+ }
+ }
+}
\ No newline at end of file
diff --git a/BotPages.Core/Navigation/UserState.cs b/BotPages.Core/Navigation/UserState.cs
new file mode 100644
index 0000000..84826eb
--- /dev/null
+++ b/BotPages.Core/Navigation/UserState.cs
@@ -0,0 +1,23 @@
+namespace BotPages.Core
+{
+ ///
+ /// Состояние пользователя: навигационный стек и общий словарь данных.
+ ///
+ public sealed class UserState
+ {
+ ///
+ /// Идентификатор пользователя.
+ ///
+ public long UserId { get; init; }
+
+ ///
+ /// Навигационный стек страниц.
+ ///
+ public List Stack { get; } = new();
+
+ ///
+ /// Общая сумка данных, доступная на всех страницах.
+ ///
+ public Dictionary Bag { get; } = new();
+ }
+}
\ No newline at end of file
diff --git a/BotPages.Core/Pages/ActionPlacement.cs b/BotPages.Core/Pages/ActionPlacement.cs
new file mode 100644
index 0000000..a409999
--- /dev/null
+++ b/BotPages.Core/Pages/ActionPlacement.cs
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..7534da9
--- /dev/null
+++ b/BotPages.Core/Pages/IPage.cs
@@ -0,0 +1,28 @@
+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
new file mode 100644
index 0000000..c26239c
--- /dev/null
+++ b/BotPages.Core/Pages/IPageRegistry.cs
@@ -0,0 +1,26 @@
+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/NavEntry.cs b/BotPages.Core/Pages/NavEntry.cs
new file mode 100644
index 0000000..2d24451
--- /dev/null
+++ b/BotPages.Core/Pages/NavEntry.cs
@@ -0,0 +1,7 @@
+namespace BotPages.Core
+{
+ ///
+ /// Запись навигационного стека: страница и её аргументы.
+ ///
+ public sealed record NavEntry(string PageId, object? Args = null);
+}
\ No newline at end of file
diff --git a/BotPages.Core/Pages/Page.cs b/BotPages.Core/Pages/Page.cs
new file mode 100644
index 0000000..d8e1d88
--- /dev/null
+++ b/BotPages.Core/Pages/Page.cs
@@ -0,0 +1,32 @@
+namespace BotPages.Core
+{
+ ///
+ /// Базовая реализация страницы без обязательных переопределений.
+ ///
+ 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;
+ }
+
+
+}
\ No newline at end of file
diff --git a/BotPages.Core/Pages/PageAction.cs b/BotPages.Core/Pages/PageAction.cs
new file mode 100644
index 0000000..95b142f
--- /dev/null
+++ b/BotPages.Core/Pages/PageAction.cs
@@ -0,0 +1,28 @@
+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.Inline;
+
+ ///
+ /// Номер ряда для макета (0 — первая строка).
+ ///
+ public int Row { get; init; } = 0;
+ }
+}
\ No newline at end of file
diff --git a/BotPages.Core/Pages/PageMessage.cs b/BotPages.Core/Pages/PageMessage.cs
new file mode 100644
index 0000000..d0a8998
--- /dev/null
+++ b/BotPages.Core/Pages/PageMessage.cs
@@ -0,0 +1,39 @@
+
+///
+/// Параметры сообщения.
+///
+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
new file mode 100644
index 0000000..f1cd3ee
--- /dev/null
+++ b/BotPages.Core/Pages/PageNavigate.cs
@@ -0,0 +1,21 @@
+
+///
+/// Параметры навигации на другую страницу.
+///
+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
new file mode 100644
index 0000000..aff9c39
--- /dev/null
+++ b/BotPages.Core/Pages/PageRegistry.cs
@@ -0,0 +1,84 @@
+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
new file mode 100644
index 0000000..0f918e2
--- /dev/null
+++ b/BotPages.Core/Pages/PageResult.cs
@@ -0,0 +1,28 @@
+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
new file mode 100644
index 0000000..5d38847
--- /dev/null
+++ b/BotPages.Core/Pages/PageResultBuilder.cs
@@ -0,0 +1,98 @@
+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/Pipeline/ErrorHandlingMiddleware.cs b/BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs
new file mode 100644
index 0000000..8ea40e9
--- /dev/null
+++ b/BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 0000000..0801f1f
--- /dev/null
+++ b/BotPages.Core/Pipeline/IRouter.cs
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000..dbeb077
--- /dev/null
+++ b/BotPages.Core/Pipeline/IUpdateMiddleware.cs
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000..9779e37
--- /dev/null
+++ b/BotPages.Core/Pipeline/LoggingMiddleware.cs
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000..294e0b9
--- /dev/null
+++ b/BotPages.Core/Pipeline/Pipeline.cs
@@ -0,0 +1,34 @@
+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
new file mode 100644
index 0000000..de12a9f
--- /dev/null
+++ b/BotPages.Core/Pipeline/Router.cs
@@ -0,0 +1,45 @@
+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
new file mode 100644
index 0000000..daaf763
--- /dev/null
+++ b/BotPages.Core/Pipeline/ThrottleMiddleware.cs
@@ -0,0 +1,25 @@
+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/Transport/DefaultFileService.cs b/BotPages.Core/Transport/DefaultFileService.cs
new file mode 100644
index 0000000..0665cbe
--- /dev/null
+++ b/BotPages.Core/Transport/DefaultFileService.cs
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 0000000..96c99da
--- /dev/null
+++ b/BotPages.Core/Transport/IChatClient.cs
@@ -0,0 +1,27 @@
+namespace BotPages.Core
+{
+ ///
+ /// Универсальный клиент для отправки сообщений и файлов в чат.
+ /// Адаптеры (Telegram, MAX и др.) реализуют этот интерфейс.
+ ///
+ public interface IChatClient
+ {
+ ///
+ /// Отправляет текстовое сообщение.
+ /// Может сопровождаться клавиатурой (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
new file mode 100644
index 0000000..f5bd9f7
--- /dev/null
+++ b/BotPages.Core/Transport/IFileService.cs
@@ -0,0 +1,28 @@
+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
new file mode 100644
index 0000000..0a7cc5c
--- /dev/null
+++ b/BotPages.Telegram/BotPages.Telegram.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BotPages.Telegram/TelegramClientAdapter.cs b/BotPages.Telegram/TelegramClientAdapter.cs
new file mode 100644
index 0000000..ebe3855
--- /dev/null
+++ b/BotPages.Telegram/TelegramClientAdapter.cs
@@ -0,0 +1,89 @@
+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;
+
+ ///
+ /// Создаёт адаптер на основе 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
new file mode 100644
index 0000000..bb3294c
--- /dev/null
+++ b/BotPages.Telegram/TelegramFileService.cs
@@ -0,0 +1,59 @@
+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
new file mode 100644
index 0000000..60d1dd7
--- /dev/null
+++ b/BotPages.Telegram/TelegramUpdateMapper.cs
@@ -0,0 +1,46 @@
+using BotPages.Core;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+
+namespace BotPages.Telegram
+{
+ ///
+ /// Утилиты для извлечения контекста из Telegram Update.
+ ///
+ public static class TelegramUpdateMapper
+ {
+ ///
+ /// Преобразует 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 text = update.Message?.Text ?? update.CallbackQuery?.Data;
+
+ var files = new List();
+ if (update.Message?.Document is { } doc)
+ {
+ files.Add(new FileDescriptor(doc.FileId, doc.FileName ?? "file", doc.MimeType ?? "application/octet-stream"));
+ }
+ if (update.Message?.Photo is { } photos && photos.Count() > 0)
+ {
+ var largest = photos.OrderBy(p => p.FileSize).Last();
+ files.Add(new FileDescriptor(largest.FileId, "photo.jpg", "image/jpeg"));
+ }
+
+ return new UpdateContext
+ {
+ 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
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/BotPages.slnx b/BotPages.slnx
new file mode 100644
index 0000000..f78380c
--- /dev/null
+++ b/BotPages.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/BotPages/BotPages.csproj b/BotPages/BotPages.csproj
new file mode 100644
index 0000000..e78b7ef
--- /dev/null
+++ b/BotPages/BotPages.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/Demo/Demo.csproj b/Demo/Demo.csproj
new file mode 100644
index 0000000..21c86ce
--- /dev/null
+++ b/Demo/Demo.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Demo/Pages/FilesPage.cs b/Demo/Pages/FilesPage.cs
new file mode 100644
index 0000000..5c5e125
--- /dev/null
+++ b/Demo/Pages/FilesPage.cs
@@ -0,0 +1,39 @@
+using BotPages.Core;
+
+namespace Demo.Pages
+{
+ public sealed class FilesPage : Page
+ {
+ public static string Id => nameof(FilesPage);
+
+ 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("📂 Здесь можно загрузить или отправить файл.")
+ .WithKeyboard(actions)
+ .Build()
+ );
+ }
+
+ 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();
+ }
+ }
+
+}
diff --git a/Demo/Pages/InlinePage.cs b/Demo/Pages/InlinePage.cs
new file mode 100644
index 0000000..ac46fdd
--- /dev/null
+++ b/Demo/Pages/InlinePage.cs
@@ -0,0 +1,35 @@
+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
new file mode 100644
index 0000000..a609c92
--- /dev/null
+++ b/Demo/Pages/MainPage.cs
@@ -0,0 +1,38 @@
+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 { Label = "📌 Inline", Value = "inline", Placement = ActionPlacement.Reply, Row = 0 },
+ new PageAction { Label = "⌨️ Reply", Value = "reply", Placement = ActionPlacement.Reply, Row = 1 },
+ new PageAction { Label = "📂 Файлы", Value = "files", Placement = ActionPlacement.Reply, Row = 2 }
+ };
+
+ return Task.FromResult(
+ PageResultBuilder.Empty()
+ .WithText("🏠 Главная страница.\nВыберите куда перейти:")
+ .WithKeyboard(actions)
+ .Build()
+ );
+ }
+
+ public override Task HandleAsync(UpdateContext ctx, CancellationToken ct)
+ {
+ return ctx.Text switch
+ {
+ "📌 Inline" => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(InlinePage)).Build()),
+ "⌨️ Reply" => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(ReplyPage)).Build()),
+ "📂 Файлы" => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(FilesPage)).Build()),
+ _ => Task.FromResult(PageResultBuilder.Empty().WithText("Выберите действие с кнопок.").Build())
+ };
+ }
+ }
+
+}
diff --git a/Demo/Pages/ReplyPage.cs b/Demo/Pages/ReplyPage.cs
new file mode 100644
index 0000000..8418bbd
--- /dev/null
+++ b/Demo/Pages/ReplyPage.cs
@@ -0,0 +1,33 @@
+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/Program.cs b/Demo/Program.cs
new file mode 100644
index 0000000..bc37d4b
--- /dev/null
+++ b/Demo/Program.cs
@@ -0,0 +1,65 @@
+using BotPages.Core;
+using BotPages.Telegram;
+using Demo.Pages;
+using Telegram.Bot;
+
+namespace Demo
+{
+ internal class Program
+ {
+ public static async Task Main(string[] args)
+ {
+ // Токен Telegram бота
+ 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 pages = new IPage[]
+ {
+ new MainPage(),
+ new InlinePage(),
+ new ReplyPage(),
+ new FilesPage()
+ };
+ var registry = new PageRegistry(pages, pages[0]);
+
+ // Навигация и состояние
+ IStateStore store = new InMemoryStateStore();
+ INavigationService nav = new NavigationService(registry, store);
+ var router = new Router(registry);
+
+ var middleware = new IUpdateMiddleware[]
+ {
+ new LoggingMiddleware(), //логирование вызова в консоль
+ new ErrorHandlingMiddleware(), //обработчик ошибок
+ //new ThrottleMiddleware(TimeSpan.FromMilliseconds(150)), //задержка в 150мс перед ответом
+ };
+
+ 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();
+ }
+
+ }
+}
diff --git a/Demo/Properties/launchSettings.json b/Demo/Properties/launchSettings.json
new file mode 100644
index 0000000..4d221e8
--- /dev/null
+++ b/Demo/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "Demo": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "TELEGRAM_TOKEN": "7992309062:AAHkb4wnFi8w7H4V0zvUW_LJg55jtzuhahU"
+ }
+ }
+ }
+}
\ No newline at end of file