From 7f69eab54542e6220751923ac4c61a1bf83cdca9 Mon Sep 17 00:00:00 2001 From: FrigaT Date: Tue, 2 Dec 2025 15:57:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotPages.Core/BotPages.Core.csproj | 9 ++ BotPages.Core/Context/ChatContext.cs | 18 +++ BotPages.Core/Context/FileDescriptor.cs | 12 ++ BotPages.Core/Context/UpdateContext.cs | 48 ++++++++ BotPages.Core/Context/UserContext.cs | 18 +++ .../Navigation/INavigationService.cs | 38 ++++++ BotPages.Core/Navigation/IStateStore.cs | 18 +++ .../Navigation/InMemoryStateStore.cs | 33 ++++++ BotPages.Core/Navigation/NavigationService.cs | 108 ++++++++++++++++++ BotPages.Core/Navigation/UserState.cs | 23 ++++ BotPages.Core/Pages/ActionPlacement.cs | 20 ++++ BotPages.Core/Pages/IPage.cs | 28 +++++ BotPages.Core/Pages/IPageRegistry.cs | 26 +++++ BotPages.Core/Pages/NavEntry.cs | 7 ++ BotPages.Core/Pages/Page.cs | 32 ++++++ BotPages.Core/Pages/PageAction.cs | 28 +++++ BotPages.Core/Pages/PageMessage.cs | 39 +++++++ BotPages.Core/Pages/PageNavigate.cs | 21 ++++ BotPages.Core/Pages/PageRegistry.cs | 84 ++++++++++++++ BotPages.Core/Pages/PageResult.cs | 28 +++++ BotPages.Core/Pages/PageResultBuilder.cs | 98 ++++++++++++++++ .../Pipeline/ErrorHandlingMiddleware.cs | 24 ++++ BotPages.Core/Pipeline/IRouter.cs | 13 +++ BotPages.Core/Pipeline/IUpdateMiddleware.cs | 13 +++ BotPages.Core/Pipeline/LoggingMiddleware.cs | 17 +++ BotPages.Core/Pipeline/Pipeline.cs | 34 ++++++ BotPages.Core/Pipeline/Router.cs | 45 ++++++++ BotPages.Core/Pipeline/ThrottleMiddleware.cs | 25 ++++ BotPages.Core/Transport/DefaultFileService.cs | 43 +++++++ BotPages.Core/Transport/IChatClient.cs | 27 +++++ BotPages.Core/Transport/IFileService.cs | 28 +++++ BotPages.Telegram/BotPages.Telegram.csproj | 17 +++ BotPages.Telegram/TelegramClientAdapter.cs | 89 +++++++++++++++ BotPages.Telegram/TelegramFileService.cs | 59 ++++++++++ BotPages.Telegram/TelegramUpdateMapper.cs | 46 ++++++++ BotPages.slnx | 6 + BotPages/BotPages.csproj | 14 +++ Demo/Demo.csproj | 14 +++ Demo/Pages/FilesPage.cs | 39 +++++++ Demo/Pages/InlinePage.cs | 35 ++++++ Demo/Pages/MainPage.cs | 38 ++++++ Demo/Pages/ReplyPage.cs | 33 ++++++ Demo/Program.cs | 65 +++++++++++ Demo/Properties/launchSettings.json | 10 ++ 44 files changed, 1470 insertions(+) create mode 100644 BotPages.Core/BotPages.Core.csproj create mode 100644 BotPages.Core/Context/ChatContext.cs create mode 100644 BotPages.Core/Context/FileDescriptor.cs create mode 100644 BotPages.Core/Context/UpdateContext.cs create mode 100644 BotPages.Core/Context/UserContext.cs create mode 100644 BotPages.Core/Navigation/INavigationService.cs create mode 100644 BotPages.Core/Navigation/IStateStore.cs create mode 100644 BotPages.Core/Navigation/InMemoryStateStore.cs create mode 100644 BotPages.Core/Navigation/NavigationService.cs create mode 100644 BotPages.Core/Navigation/UserState.cs create mode 100644 BotPages.Core/Pages/ActionPlacement.cs create mode 100644 BotPages.Core/Pages/IPage.cs create mode 100644 BotPages.Core/Pages/IPageRegistry.cs create mode 100644 BotPages.Core/Pages/NavEntry.cs create mode 100644 BotPages.Core/Pages/Page.cs create mode 100644 BotPages.Core/Pages/PageAction.cs create mode 100644 BotPages.Core/Pages/PageMessage.cs create mode 100644 BotPages.Core/Pages/PageNavigate.cs create mode 100644 BotPages.Core/Pages/PageRegistry.cs create mode 100644 BotPages.Core/Pages/PageResult.cs create mode 100644 BotPages.Core/Pages/PageResultBuilder.cs create mode 100644 BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs create mode 100644 BotPages.Core/Pipeline/IRouter.cs create mode 100644 BotPages.Core/Pipeline/IUpdateMiddleware.cs create mode 100644 BotPages.Core/Pipeline/LoggingMiddleware.cs create mode 100644 BotPages.Core/Pipeline/Pipeline.cs create mode 100644 BotPages.Core/Pipeline/Router.cs create mode 100644 BotPages.Core/Pipeline/ThrottleMiddleware.cs create mode 100644 BotPages.Core/Transport/DefaultFileService.cs create mode 100644 BotPages.Core/Transport/IChatClient.cs create mode 100644 BotPages.Core/Transport/IFileService.cs create mode 100644 BotPages.Telegram/BotPages.Telegram.csproj create mode 100644 BotPages.Telegram/TelegramClientAdapter.cs create mode 100644 BotPages.Telegram/TelegramFileService.cs create mode 100644 BotPages.Telegram/TelegramUpdateMapper.cs create mode 100644 BotPages.slnx create mode 100644 BotPages/BotPages.csproj create mode 100644 Demo/Demo.csproj create mode 100644 Demo/Pages/FilesPage.cs create mode 100644 Demo/Pages/InlinePage.cs create mode 100644 Demo/Pages/MainPage.cs create mode 100644 Demo/Pages/ReplyPage.cs create mode 100644 Demo/Program.cs create mode 100644 Demo/Properties/launchSettings.json 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