From 4aff8edbcd8e6e5fa13aec11e65d6f30bb39f613 Mon Sep 17 00:00:00 2001 From: FrigaT Date: Wed, 3 Dec 2025 07:15:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=20=20=D0=BC=D0=B5=D0=BD=D0=B5=D0=B4=D0=B6=D0=B5?= =?UTF-8?q?=D1=80=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BotPages.Core/Navigation/IStateStore.cs | 2 +- .../Navigation/InMemoryStateStore.cs | 16 +-- .../{Pages => Navigation}/NavEntry.cs | 0 BotPages.Core/Navigation/NavigationService.cs | 10 +- BotPages.Core/Navigation/UserState.cs | 7 +- BotPages.Core/Pages/ActionAttribute.cs | 20 ++++ BotPages.Core/Pages/ActionExtensions.cs | 48 +++++++++ BotPages.Core/Pages/PageAction.cs | 14 ++- BotPages.Core/Transport/IChatClient.cs | 7 +- BotPages.Telegram/TelegramClientAdapter.cs | 2 + BotPages.slnx | 4 +- Demo/Pages/InlinePage.cs | 3 - Demo/Pages/MainPage.cs | 27 +++-- README.md | 101 +++++++++++++++++- 14 files changed, 234 insertions(+), 27 deletions(-) rename BotPages.Core/{Pages => Navigation}/NavEntry.cs (100%) create mode 100644 BotPages.Core/Pages/ActionAttribute.cs create mode 100644 BotPages.Core/Pages/ActionExtensions.cs diff --git a/BotPages.Core/Navigation/IStateStore.cs b/BotPages.Core/Navigation/IStateStore.cs index e003b36..fa82868 100644 --- a/BotPages.Core/Navigation/IStateStore.cs +++ b/BotPages.Core/Navigation/IStateStore.cs @@ -8,7 +8,7 @@ /// /// Получает состояние пользователя. /// - Task GetAsync(long userId, CancellationToken ct); + Task GetAsync(string transportId, long userId, CancellationToken ct); /// /// Сохраняет состояние пользователя. diff --git a/BotPages.Core/Navigation/InMemoryStateStore.cs b/BotPages.Core/Navigation/InMemoryStateStore.cs index 6ea0af1..1d5f81d 100644 --- a/BotPages.Core/Navigation/InMemoryStateStore.cs +++ b/BotPages.Core/Navigation/InMemoryStateStore.cs @@ -6,17 +6,21 @@ /// public sealed class InMemoryStateStore : IStateStore { - private readonly Dictionary _store = new(); + private readonly Dictionary<(string chatClientId, long userId), UserState> _store = new(); /// /// Получает состояние пользователя, создавая новое при отсутствии. /// - public Task GetAsync(long userId, CancellationToken ct) + public Task GetAsync(string chatClientId, long userId, CancellationToken ct) { - if (!_store.TryGetValue(userId, out var st)) + if (!_store.TryGetValue((chatClientId, userId), out var st)) { - st = new UserState { UserId = userId }; - _store[userId] = st; + st = new UserState + { + UserId = userId, + ChatClientId = chatClientId, + }; + _store[(chatClientId, userId)] = st; } return Task.FromResult(st); } @@ -26,7 +30,7 @@ /// public Task SaveAsync(UserState state, CancellationToken ct) { - _store[state.UserId] = state; + _store[(state.ChatClientId, state.UserId)] = state; return Task.CompletedTask; } } diff --git a/BotPages.Core/Pages/NavEntry.cs b/BotPages.Core/Navigation/NavEntry.cs similarity index 100% rename from BotPages.Core/Pages/NavEntry.cs rename to BotPages.Core/Navigation/NavEntry.cs diff --git a/BotPages.Core/Navigation/NavigationService.cs b/BotPages.Core/Navigation/NavigationService.cs index 745c28f..86b4c26 100644 --- a/BotPages.Core/Navigation/NavigationService.cs +++ b/BotPages.Core/Navigation/NavigationService.cs @@ -22,7 +22,7 @@ /// public async Task PushAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct) { - var state = await _store.GetAsync(ctx.User.Id, ct); + var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); state.Stack.Add(new NavEntry(pageId, args)); await _store.SaveAsync(state, ct); @@ -35,7 +35,7 @@ /// public async Task ReplaceAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct) { - var state = await _store.GetAsync(ctx.User.Id, ct); + var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); if (state.Stack.Count > 0) state.Stack[^1] = new NavEntry(pageId, args); else state.Stack.Add(new NavEntry(pageId, args)); await _store.SaveAsync(state, ct); @@ -49,7 +49,7 @@ /// public async Task PopAsync(UpdateContext ctx, CancellationToken ct) { - var state = await _store.GetAsync(ctx.User.Id, ct); + var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); if (state.Stack.Count == 0) return; var currentId = state.Stack[^1].PageId; @@ -92,7 +92,7 @@ /// public async Task CurrentAsync(UpdateContext ctx, CancellationToken ct) { - var state = await _store.GetAsync(ctx.User.Id, ct); + var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); return state.Stack.Count == 0 ? null : state.Stack[^1]; } @@ -101,7 +101,7 @@ /// public async Task> StackAsync(UpdateContext ctx, CancellationToken ct) { - var state = await _store.GetAsync(ctx.User.Id, ct); + var state = await _store.GetAsync(ctx.Client.Id, ctx.User.Id, ct); return state.Stack.AsReadOnly(); } } diff --git a/BotPages.Core/Navigation/UserState.cs b/BotPages.Core/Navigation/UserState.cs index 84826eb..1a98e4d 100644 --- a/BotPages.Core/Navigation/UserState.cs +++ b/BotPages.Core/Navigation/UserState.cs @@ -8,7 +8,12 @@ /// /// Идентификатор пользователя. /// - public long UserId { get; init; } + public required long UserId { get; init; } + + /// + /// Идентификатор клиента чата. + /// + public required string ChatClientId { get; init; } /// /// Навигационный стек страниц. diff --git a/BotPages.Core/Pages/ActionAttribute.cs b/BotPages.Core/Pages/ActionAttribute.cs new file mode 100644 index 0000000..c391b33 --- /dev/null +++ b/BotPages.Core/Pages/ActionAttribute.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BotPages.Core +{ + public class ActionAttribute : Attribute + { + public ActionAttribute(string label) + { + Label = label; + } + + public string Label { get; } + } +} diff --git a/BotPages.Core/Pages/ActionExtensions.cs b/BotPages.Core/Pages/ActionExtensions.cs new file mode 100644 index 0000000..19bc4f2 --- /dev/null +++ b/BotPages.Core/Pages/ActionExtensions.cs @@ -0,0 +1,48 @@ +using System.Reflection; + +namespace BotPages.Core +{ + public static class ActionExtensions + { + private static readonly Dictionary> _cache = new(); + + public static string GetActionLabel(this T value) + where T : Enum + { + var fieldName = value.ToString(); + var field = typeof(T).GetField(fieldName, BindingFlags.Public | BindingFlags.Static); + return field?.GetCustomAttribute()?.Label ?? fieldName; + } + + public static T? FromActionLabel(string? value) where T : struct, Enum + { + if (value == null) return null; + + var type = typeof(T); + if (!_cache.TryGetValue(type, out var map)) + { + map = new Dictionary(); + + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static); + + foreach (var field in fields) + { + var fieldValue = field.GetValue(null)!; + var fieldName = field.Name; + + var attr = field.GetCustomAttribute(); + + if (attr != null) + { + fieldName = attr.Label; + } + + map[fieldName] = fieldValue; + } + + } + + return map.TryGetValue(value, out var result) ? (T)result : null; + } + } +} diff --git a/BotPages.Core/Pages/PageAction.cs b/BotPages.Core/Pages/PageAction.cs index 95b142f..3236a28 100644 --- a/BotPages.Core/Pages/PageAction.cs +++ b/BotPages.Core/Pages/PageAction.cs @@ -18,11 +18,23 @@ /// /// Тип кнопки: inline или reply. /// - public ActionPlacement Placement { get; init; } = ActionPlacement.Inline; + public ActionPlacement Placement { get; init; } = ActionPlacement.Reply; /// /// Номер ряда для макета (0 — первая строка). /// public int Row { get; init; } = 0; + + public PageAction() + { + + } + + + public PageAction(Enum en) + { + Label = en.GetActionLabel(); + Value = en.ToString(); + } } } \ No newline at end of file diff --git a/BotPages.Core/Transport/IChatClient.cs b/BotPages.Core/Transport/IChatClient.cs index 96c99da..a599d4d 100644 --- a/BotPages.Core/Transport/IChatClient.cs +++ b/BotPages.Core/Transport/IChatClient.cs @@ -2,10 +2,15 @@ { /// /// Универсальный клиент для отправки сообщений и файлов в чат. - /// Адаптеры (Telegram, MAX и др.) реализуют этот интерфейс. + /// Адаптеры реализуют этот интерфейс. /// public interface IChatClient { + /// + /// Идентификатор клиента. + /// + string Id { get; init; } + /// /// Отправляет текстовое сообщение. /// Может сопровождаться клавиатурой (inline или reply). diff --git a/BotPages.Telegram/TelegramClientAdapter.cs b/BotPages.Telegram/TelegramClientAdapter.cs index ebe3855..83625f6 100644 --- a/BotPages.Telegram/TelegramClientAdapter.cs +++ b/BotPages.Telegram/TelegramClientAdapter.cs @@ -14,6 +14,8 @@ namespace BotPages.Telegram { private readonly ITelegramBotClient _bot; + public string Id { get; init; } = nameof(TelegramClientAdapter); + /// /// Создаёт адаптер на основе ITelegramBotClient. /// diff --git a/BotPages.slnx b/BotPages.slnx index 693a753..43a44ff 100644 --- a/BotPages.slnx +++ b/BotPages.slnx @@ -1,9 +1,11 @@ + + + - diff --git a/Demo/Pages/InlinePage.cs b/Demo/Pages/InlinePage.cs index ac46fdd..cd4285e 100644 --- a/Demo/Pages/InlinePage.cs +++ b/Demo/Pages/InlinePage.cs @@ -29,7 +29,4 @@ namespace Demo.Pages return Task.FromResult(PageResultBuilder.Empty().WithText("Нажмите кнопку 'Назад'.").Build()); } } - - - } diff --git a/Demo/Pages/MainPage.cs b/Demo/Pages/MainPage.cs index a609c92..98f5e9f 100644 --- a/Demo/Pages/MainPage.cs +++ b/Demo/Pages/MainPage.cs @@ -10,9 +10,9 @@ namespace Demo.Pages { 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 } + new PageAction(MainPageButtons.Inline) { Placement = ActionPlacement.Reply, Row = 0 }, + new PageAction(MainPageButtons.Reply) { Placement = ActionPlacement.Reply, Row = 1 }, + new PageAction(MainPageButtons.Files) { Placement = ActionPlacement.Reply, Row = 2 }, }; return Task.FromResult( @@ -25,14 +25,27 @@ namespace Demo.Pages public override Task HandleAsync(UpdateContext ctx, CancellationToken ct) { - return ctx.Text switch + var button = ActionExtensions.FromActionLabel(ctx.Text); + + return button 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()), + MainPageButtons.Inline => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(InlinePage)).Build()), + MainPageButtons.Reply => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(ReplyPage)).Build()), + MainPageButtons.Files => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(FilesPage)).Build()), _ => Task.FromResult(PageResultBuilder.Empty().WithText("Выберите действие с кнопок.").Build()) }; } } + public enum MainPageButtons + { + [Action("📌 Inline")] + Inline, + + [Action("⌨️ Reply")] + Reply, + + [Action("📂 Файлы")] + Files, + } } diff --git a/README.md b/README.md index 64fe801..94b7422 100644 --- a/README.md +++ b/README.md @@ -1 +1,100 @@ -# BotPages \ No newline at end of file +# BotPages.Core + +**BotPages.Core** — это универсальный фреймворк для построения ботов с декларативными страницами, навигацией и единым контекстом. +Поддерживает работу сразу с несколькими транспортами (например, Telegram и MAX), сохраняя чистую архитектуру и удобный developer experience. + +--- + +## ✨ Основные идеи + +- **PageResult** — декларативный результат обработки страницы (сообщение, файлы, кнопки, навигация). +- **PageMessage** — объект сообщения с поддержкой форматов (Plain/Markdown/HTML), флагов (`IsSilent`, `DisableWebPreview`). +- **PageNavigate** — объект навигации (переход на другую страницу, аргументы, режим Replace). +- **PageAction** — кнопки (inline/reply, ссылки, запрос контакта/локации, стили). +- **UpdateContext** — универсальный контекст обновления, независимый от транспорта, с полем `Transport` для разделения Telegram/MAX. +- **PageRegistry** — реестр страниц, умеет собирать их автоматически из сборки (`CreateFromAssembly`, `CreateFromApplication`). +- **IStateStore** — хранилище состояния пользователя, ключом является `(Transport, ChatId)`. + +--- + +## 🚀 Быстрый старт + +```csharp +// Создаём реестр страниц +var registry = PageRegistry.CreateFromApplication(defaultPageId: "main"); + +// Хранилище состояния +IStateStore store = new InMemoryStateStore(); + +// Навигация +INavigationService nav = new NavigationService(registry, store); + +// Запуск Telegram +var telegramBot = new TelegramBotClient("TELEGRAM_TOKEN"); +telegramBot.StartReceiving(async (bot, update, ct) => +{ + var ctx = TelegramUpdateMapper.Map(bot, nav, store, update); + await nav.HandleAsync(ctx, ct); +}); + +// Запуск MAX +var maxClient = new MaxClientAdapter("MAX_CONFIG"); +maxClient.OnUpdate(async (update, ct) => +{ + var ctx = MaxUpdateMapper.Map(maxClient, nav, store, update); + ctx.Transport = "max"; + await nav.HandleAsync(ctx, ct); +}); +``` + +--- + +## 📌 Пример страницы + +```csharp +public sealed class MainPage : IPage +{ + public string Id => "main"; + + public PageResult Handle(UpdateContext ctx) + { + return PageResult.Text("🏠 Главная страница", new[] + { + new PageAction { Label = "Перейти", Value = "inlinePage", Placement = ActionPlacement.Inline } + }); + } +} +``` + +--- + +## 🛠️ Возможности + +- ✅ Декларативные страницы (`PageResult`) +- ✅ Навигация (`PageNavigate`) +- ✅ Сообщения с форматами и флагами (`PageMessage`) +- ✅ Кнопки с расширенными параметрами (`PageAction`) +- ✅ Поддержка нескольких транспортов (Telegram) +- ✅ Автоматическая регистрация страниц (`PageRegistry.CreateFromApplication`) +- ✅ Хранение состояния по `(ChatClientId, ChatId)` + +--- + +## 📂 Структура проекта + +``` +BotPages/ + ├── BotPages.Core/ # Основные классы (PageResult, PageMessage, PageNavigate, UpdateContext) + ├── Demo/ # Пример страниц и запуск + ├── Adapters/ # Транспортные адаптеры (Telegram) + └── README.md +``` + +--- + +## 📖 TODO + +- [ ] Поддержка медиагрупп (альбомов) в Telegram +- [ ] Расширенные стили кнопок +- [ ] TTL для сообщений +- [ ] Плагины для сторонних транспортов \ No newline at end of file