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