Добавьте файлы проекта.
This commit is contained in:
9
BotPages.Core/BotPages.Core.csproj
Normal file
9
BotPages.Core/BotPages.Core.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
18
BotPages.Core/Context/ChatContext.cs
Normal file
18
BotPages.Core/Context/ChatContext.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Описывает чат/конверсацию для универсального контекста.
|
||||
/// </summary>
|
||||
public sealed class ChatContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Уникальный идентификатор чата/диалога.
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Человеко-читаемое имя чата (если доступно).
|
||||
/// </summary>
|
||||
public string? Title { get; init; }
|
||||
}
|
||||
}
|
||||
12
BotPages.Core/Context/FileDescriptor.cs
Normal file
12
BotPages.Core/Context/FileDescriptor.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Универсальный дескриптор файла для операций загрузки/отправки.
|
||||
/// </summary>
|
||||
public sealed record FileDescriptor(
|
||||
string Id,
|
||||
string Name,
|
||||
string MimeType,
|
||||
Stream? Content = null
|
||||
);
|
||||
}
|
||||
48
BotPages.Core/Context/UpdateContext.cs
Normal file
48
BotPages.Core/Context/UpdateContext.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Универсальный контекст обновления, независимый от транспорта.
|
||||
/// </summary>
|
||||
public sealed class UpdateContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Клиент транспорта для отправки сообщений/файлов.
|
||||
/// </summary>
|
||||
public required IChatClient Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Контекст чата.
|
||||
/// </summary>
|
||||
public required ChatContext Chat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Контекст пользователя.
|
||||
/// </summary>
|
||||
public required UserContext User { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения или полезная нагрузка колбэка, если доступна.
|
||||
/// </summary>
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Список полученных файлов (если транспорт поддерживает).
|
||||
/// </summary>
|
||||
public IReadOnlyList<FileDescriptor>? IncomingFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Сырой объект обновления транспорта (например, Telegram.Update).
|
||||
/// </summary>
|
||||
public object? RawUpdate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Сервис навигации страниц.
|
||||
/// </summary>
|
||||
public required INavigationService Nav { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Хранилище состояния пользователя.
|
||||
/// </summary>
|
||||
public required IStateStore State { get; init; }
|
||||
}
|
||||
}
|
||||
18
BotPages.Core/Context/UserContext.cs
Normal file
18
BotPages.Core/Context/UserContext.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Описывает пользователя для универсального контекста.
|
||||
/// </summary>
|
||||
public sealed class UserContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Уникальный идентификатор пользователя в транспортном слое.
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя пользователя (если доступно).
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
}
|
||||
}
|
||||
38
BotPages.Core/Navigation/INavigationService.cs
Normal file
38
BotPages.Core/Navigation/INavigationService.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Сервис навигации по страницам.
|
||||
/// </summary>
|
||||
public interface INavigationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Выполняет push новой страницы и вызывает её Enter.
|
||||
/// </summary>
|
||||
Task PushAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет replace текущей страницы и вызывает Enter новой.
|
||||
/// </summary>
|
||||
Task ReplaceAsync(string pageId, object? args, UpdateContext ctx, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращается назад по стеку и вызывает Enter предыдущей.
|
||||
/// </summary>
|
||||
Task PopAsync(UpdateContext ctx, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Применяет декларативный результат страницы (навигация, текст, файлы).
|
||||
/// </summary>
|
||||
Task ApplyResultAsync(UpdateContext ctx, PageResult result, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущую запись стека.
|
||||
/// </summary>
|
||||
Task<NavEntry?> CurrentAsync(UpdateContext ctx, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает весь стек навигации.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NavEntry>> StackAsync(UpdateContext ctx, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
18
BotPages.Core/Navigation/IStateStore.cs
Normal file
18
BotPages.Core/Navigation/IStateStore.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Простое in-memory хранилище состояния пользователя.
|
||||
/// </summary>
|
||||
public interface IStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Получает состояние пользователя.
|
||||
/// </summary>
|
||||
Task<UserState> GetAsync(long userId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет состояние пользователя.
|
||||
/// </summary>
|
||||
Task SaveAsync(UserState state, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
33
BotPages.Core/Navigation/InMemoryStateStore.cs
Normal file
33
BotPages.Core/Navigation/InMemoryStateStore.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация хранилища состояния для прототипирования.
|
||||
/// </summary>
|
||||
public sealed class InMemoryStateStore : IStateStore
|
||||
{
|
||||
private readonly Dictionary<long, UserState> _store = new();
|
||||
|
||||
/// <summary>
|
||||
/// Получает состояние пользователя, создавая новое при отсутствии.
|
||||
/// </summary>
|
||||
public Task<UserState> GetAsync(long userId, CancellationToken ct)
|
||||
{
|
||||
if (!_store.TryGetValue(userId, out var st))
|
||||
{
|
||||
st = new UserState { UserId = userId };
|
||||
_store[userId] = st;
|
||||
}
|
||||
return Task.FromResult(st);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет состояние пользователя.
|
||||
/// </summary>
|
||||
public Task SaveAsync(UserState state, CancellationToken ct)
|
||||
{
|
||||
_store[state.UserId] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
108
BotPages.Core/Navigation/NavigationService.cs
Normal file
108
BotPages.Core/Navigation/NavigationService.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Реализация сервиса навигации страниц.
|
||||
/// </summary>
|
||||
public sealed class NavigationService : INavigationService
|
||||
{
|
||||
private readonly IPageRegistry _pages;
|
||||
private readonly IStateStore _store;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт сервис навигации.
|
||||
/// </summary>
|
||||
public NavigationService(IPageRegistry pages, IStateStore store)
|
||||
{
|
||||
_pages = pages;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет push новой страницы и вызывает её Enter.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет replace текущей страницы и вызывает Enter новой.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращается назад по стеку и вызывает Enter предыдущей.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Применяет декларативный результат страницы (навигация, текст, файлы).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текущую запись стека.
|
||||
/// </summary>
|
||||
public async Task<NavEntry?> CurrentAsync(UpdateContext ctx, CancellationToken ct)
|
||||
{
|
||||
var state = await _store.GetAsync(ctx.User.Id, ct);
|
||||
return state.Stack.Count == 0 ? null : state.Stack[^1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает весь стек навигации.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<NavEntry>> StackAsync(UpdateContext ctx, CancellationToken ct)
|
||||
{
|
||||
var state = await _store.GetAsync(ctx.User.Id, ct);
|
||||
return state.Stack.AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
||||
23
BotPages.Core/Navigation/UserState.cs
Normal file
23
BotPages.Core/Navigation/UserState.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Состояние пользователя: навигационный стек и общий словарь данных.
|
||||
/// </summary>
|
||||
public sealed class UserState
|
||||
{
|
||||
/// <summary>
|
||||
/// Идентификатор пользователя.
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Навигационный стек страниц.
|
||||
/// </summary>
|
||||
public List<NavEntry> Stack { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Общая сумка данных, доступная на всех страницах.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> Bag { get; } = new();
|
||||
}
|
||||
}
|
||||
20
BotPages.Core/Pages/ActionPlacement.cs
Normal file
20
BotPages.Core/Pages/ActionPlacement.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Тип размещения кнопки.
|
||||
/// </summary>
|
||||
public enum ActionPlacement
|
||||
{
|
||||
/// <summary>
|
||||
/// Inline‑кнопка (под сообщением).
|
||||
/// </summary>
|
||||
Inline,
|
||||
|
||||
/// <summary>
|
||||
/// Reply‑кнопка (заменяет системную клавиатуру).
|
||||
/// </summary>
|
||||
Reply
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
28
BotPages.Core/Pages/IPage.cs
Normal file
28
BotPages.Core/Pages/IPage.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Контракт страницы: экран диалога с жизненным циклом.
|
||||
/// </summary>
|
||||
public interface IPage
|
||||
{
|
||||
/// <summary>
|
||||
/// Статический идентификатор страницы.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Вызывается при входе на страницу (рендер, приветствие).
|
||||
/// </summary>
|
||||
Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Обработка входящего события/сообщения на странице.
|
||||
/// </summary>
|
||||
Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Вызывается при выходе со страницы (очистка, финализация).
|
||||
/// </summary>
|
||||
Task ExitAsync(UpdateContext ctx, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
26
BotPages.Core/Pages/IPageRegistry.cs
Normal file
26
BotPages.Core/Pages/IPageRegistry.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Реестр страниц с доступом по идентификатору.
|
||||
/// </summary>
|
||||
public interface IPageRegistry
|
||||
{
|
||||
IPage DefaultPage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает страницу по идентификатору.
|
||||
/// </summary>
|
||||
IPage Get(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Пытается получить страницу по идентификатору.
|
||||
/// </summary>
|
||||
bool TryGet(string id, out IPage? page);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает все зарегистрированные страницы.
|
||||
/// </summary>
|
||||
IEnumerable<IPage> All();
|
||||
IPage GetOrDefault(string id);
|
||||
}
|
||||
}
|
||||
7
BotPages.Core/Pages/NavEntry.cs
Normal file
7
BotPages.Core/Pages/NavEntry.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Запись навигационного стека: страница и её аргументы.
|
||||
/// </summary>
|
||||
public sealed record NavEntry(string PageId, object? Args = null);
|
||||
}
|
||||
32
BotPages.Core/Pages/Page.cs
Normal file
32
BotPages.Core/Pages/Page.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Базовая реализация страницы без обязательных переопределений.
|
||||
/// </summary>
|
||||
public abstract class Page : IPage
|
||||
{
|
||||
/// <summary>
|
||||
/// Идентификатор страницы.
|
||||
/// </summary>
|
||||
public virtual string Id => GetType().Name;
|
||||
|
||||
/// <summary>
|
||||
/// Виртуальный метод входа; по умолчанию ничего не делает.
|
||||
/// </summary>
|
||||
public virtual Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct) =>
|
||||
Task.FromResult(new PageResult());
|
||||
|
||||
/// <summary>
|
||||
/// Абстрактная обработка событий; обязателен к реализации.
|
||||
/// </summary>
|
||||
public abstract Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Виртуальный метод выхода; по умолчанию ничего не делает.
|
||||
/// </summary>
|
||||
public virtual Task ExitAsync(UpdateContext ctx, CancellationToken ct) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
28
BotPages.Core/Pages/PageAction.cs
Normal file
28
BotPages.Core/Pages/PageAction.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Универсальное действие (кнопка), которое может быть отображено в разных клиентах.
|
||||
/// </summary>
|
||||
public sealed class PageAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Текст кнопки, отображаемый пользователю.
|
||||
/// </summary>
|
||||
public string Label { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Значение (payload), которое будет передано в <see cref="UpdateContext.Text"/> при нажатии.
|
||||
/// </summary>
|
||||
public string Value { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Тип кнопки: inline или reply.
|
||||
/// </summary>
|
||||
public ActionPlacement Placement { get; init; } = ActionPlacement.Inline;
|
||||
|
||||
/// <summary>
|
||||
/// Номер ряда для макета (0 — первая строка).
|
||||
/// </summary>
|
||||
public int Row { get; init; } = 0;
|
||||
}
|
||||
}
|
||||
39
BotPages.Core/Pages/PageMessage.cs
Normal file
39
BotPages.Core/Pages/PageMessage.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
/// <summary>
|
||||
/// Параметры сообщения.
|
||||
/// </summary>
|
||||
public sealed class PageMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Текст сообщения.
|
||||
/// </summary>
|
||||
public required string Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Формат сообщения (Plain/Markdown/Html).
|
||||
/// </summary>
|
||||
public MessageFormat Format { get; init; } = MessageFormat.Plain;
|
||||
|
||||
/// <summary>
|
||||
/// Отправить сообщение без уведомления (тихий режим).
|
||||
/// </summary>
|
||||
public bool IsSilent { get; init; } = false;
|
||||
|
||||
public static implicit operator PageMessage(string text)
|
||||
=> new PageMessage { Text = text, Format = MessageFormat.Plain };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Тип форматирования сообщения.
|
||||
/// </summary>
|
||||
public enum MessageFormat
|
||||
{
|
||||
/// <summary>Обычный текст без форматирования.</summary>
|
||||
Plain,
|
||||
|
||||
/// <summary>Markdown.</summary>
|
||||
Markdown,
|
||||
|
||||
/// <summary>HTML.</summary>
|
||||
Html,
|
||||
}
|
||||
21
BotPages.Core/Pages/PageNavigate.cs
Normal file
21
BotPages.Core/Pages/PageNavigate.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
/// <summary>
|
||||
/// Параметры навигации на другую страницу.
|
||||
/// </summary>
|
||||
public sealed class PageNavigate
|
||||
{
|
||||
/// <summary>
|
||||
/// Идентификатор страницы, на которую нужно перейти.
|
||||
/// </summary>
|
||||
public required string PageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Дополнительные аргументы для навигации.
|
||||
/// </summary>
|
||||
public object? Args { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Заменить текущую навигацию на новую.
|
||||
/// </summary>
|
||||
public bool Replace { get; init; }
|
||||
}
|
||||
84
BotPages.Core/Pages/PageRegistry.cs
Normal file
84
BotPages.Core/Pages/PageRegistry.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Базовая реализация реестра страниц на словаре.
|
||||
/// </summary>
|
||||
public sealed class PageRegistry : IPageRegistry
|
||||
{
|
||||
private readonly Dictionary<string, IPage> _pages = new(StringComparer.Ordinal);
|
||||
private readonly IPage _defaultPage;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт реестр из набора страниц.
|
||||
/// </summary>
|
||||
public PageRegistry(IEnumerable<IPage> pages) : this(pages, pages.First())
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт реестр из набора страниц.
|
||||
/// </summary>
|
||||
public PageRegistry(IEnumerable<IPage> pages, IPage defaultPage)
|
||||
{
|
||||
foreach (var p in pages) _pages[p.Id] = p;
|
||||
_defaultPage = defaultPage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает страницу по идентификатору.
|
||||
/// </summary>
|
||||
public IPage Get(string id) => _pages[id];
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает страницу по идентификатору. Если страницы нет, возвращает дефолтную.
|
||||
/// </summary>
|
||||
public IPage GetOrDefault(string id)
|
||||
=> _pages.TryGetValue(id, out var page) ? page : _defaultPage;
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает дефолтную страницу.
|
||||
/// </summary>
|
||||
public IPage DefaultPage => _defaultPage;
|
||||
|
||||
/// <summary>
|
||||
/// Пытается получить страницу по идентификатору.
|
||||
/// </summary>
|
||||
public bool TryGet(string id, out IPage? page) => _pages.TryGetValue(id, out page);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает все зарегистрированные страницы.
|
||||
/// </summary>
|
||||
public IEnumerable<IPage> All() => _pages.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт реестр страниц из всех сборок приложения.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
28
BotPages.Core/Pages/PageResult.cs
Normal file
28
BotPages.Core/Pages/PageResult.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Результат обработки страницы: текст, файлы, кнопки или навигация.
|
||||
/// </summary>
|
||||
public sealed class PageResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Параметры перехода страницы, на которую нужно перейти.
|
||||
/// </summary>
|
||||
public PageNavigate? NavigateTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения (опционально).
|
||||
/// </summary>
|
||||
public PageMessage? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Файлы для отправки (опционально).
|
||||
/// </summary>
|
||||
public IReadOnlyList<FileDescriptor>? Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Кнопки (inline или reply), которые должны быть отображены пользователю.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PageAction>? Actions { get; init; }
|
||||
}
|
||||
}
|
||||
98
BotPages.Core/Pages/PageResultBuilder.cs
Normal file
98
BotPages.Core/Pages/PageResultBuilder.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Билдер для удобного создания <see cref="PageResult"/>.
|
||||
/// Мутабельный, но итоговый объект иммутабелен.
|
||||
/// </summary>
|
||||
public sealed class PageResultBuilder
|
||||
{
|
||||
private PageNavigate? _navigateTo;
|
||||
private PageMessage? _message;
|
||||
private List<FileDescriptor>? _files;
|
||||
private List<PageAction>? _actions;
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает текст сообщения.
|
||||
/// </summary>
|
||||
public PageResultBuilder WithText(string text, MessageFormat format)
|
||||
=> WithText(new PageMessage()
|
||||
{
|
||||
Text = text,
|
||||
Format = format,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает текст сообщения.
|
||||
/// </summary>
|
||||
public PageResultBuilder WithText(string text)
|
||||
=> WithText(new PageMessage()
|
||||
{
|
||||
Text = text,
|
||||
Format = MessageFormat.Plain,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает текст сообщения.
|
||||
/// </summary>
|
||||
public PageResultBuilder WithText(PageMessage message)
|
||||
{
|
||||
_message = message;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет клавиатуру (набор кнопок).
|
||||
/// </summary>
|
||||
public PageResultBuilder WithKeyboard(IEnumerable<PageAction> actions)
|
||||
{
|
||||
_actions = actions?.ToList();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет файлы.
|
||||
/// </summary>
|
||||
public PageResultBuilder WithFiles(IEnumerable<FileDescriptor> files)
|
||||
{
|
||||
_files = files?.ToList();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает навигацию на другую страницу.
|
||||
/// </summary>
|
||||
public PageResultBuilder WithNavigate(string pageId, object? args = null, bool replace = true)
|
||||
=> WithNavigate(new PageNavigate()
|
||||
{
|
||||
PageId = pageId,
|
||||
Args = args,
|
||||
Replace = replace,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает навигацию на другую страницу.
|
||||
/// </summary>
|
||||
public PageResultBuilder WithNavigate(PageNavigate navigate)
|
||||
{
|
||||
_navigateTo = navigate;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает итоговый иммутабельный <see cref="PageResult"/>.
|
||||
/// </summary>
|
||||
public PageResult Build() => new PageResult
|
||||
{
|
||||
Message = _message,
|
||||
Actions = _actions,
|
||||
Files = _files,
|
||||
NavigateTo = _navigateTo,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт новый пустой билдер.
|
||||
/// </summary>
|
||||
public static PageResultBuilder Empty() => new PageResultBuilder();
|
||||
}
|
||||
|
||||
}
|
||||
24
BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs
Normal file
24
BotPages.Core/Pipeline/ErrorHandlingMiddleware.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Middleware обработки ошибок для надёжности.
|
||||
/// </summary>
|
||||
public sealed class ErrorHandlingMiddleware : IUpdateMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Перехватывает исключения и отправляет сообщение об ошибке пользователю.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(UpdateContext ctx, Func<Task> next, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex}");
|
||||
await ctx.Client.SendTextAsync(ctx.Chat.Id, "Произошла ошибка. Попробуйте ещё раз. /start", null, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
BotPages.Core/Pipeline/IRouter.cs
Normal file
13
BotPages.Core/Pipeline/IRouter.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Маршрутизатор обновлений на страницы.
|
||||
/// </summary>
|
||||
public interface IRouter
|
||||
{
|
||||
/// <summary>
|
||||
/// Определяет текущую страницу и вызывает её обработчик.
|
||||
/// </summary>
|
||||
Task RouteAsync(UpdateContext ctx, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
13
BotPages.Core/Pipeline/IUpdateMiddleware.cs
Normal file
13
BotPages.Core/Pipeline/IUpdateMiddleware.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Middleware обработки входящих обновлений.
|
||||
/// </summary>
|
||||
public interface IUpdateMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Вызывает промежуточную логику, затем следующий обработчик или роутер.
|
||||
/// </summary>
|
||||
Task InvokeAsync(UpdateContext ctx, Func<Task> next, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
17
BotPages.Core/Pipeline/LoggingMiddleware.cs
Normal file
17
BotPages.Core/Pipeline/LoggingMiddleware.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Middleware логирования входящих обновлений.
|
||||
/// </summary>
|
||||
public sealed class LoggingMiddleware : IUpdateMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Логирует базовую информацию об обновлении и вызывает следующий этап.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(UpdateContext ctx, Func<Task> next, CancellationToken ct)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.UtcNow:O}] Update: chat={ctx.Chat.Id}, user={ctx.User.Id}, text={ctx.Text}");
|
||||
await next();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
BotPages.Core/Pipeline/Pipeline.cs
Normal file
34
BotPages.Core/Pipeline/Pipeline.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Конвейер выполнения middleware и роутера.
|
||||
/// </summary>
|
||||
public sealed class Pipeline
|
||||
{
|
||||
private readonly IReadOnlyList<IUpdateMiddleware> _middlewares;
|
||||
private readonly IRouter _router;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт конвейер обработки обновлений.
|
||||
/// </summary>
|
||||
public Pipeline(IEnumerable<IUpdateMiddleware> middlewares, IRouter router)
|
||||
{
|
||||
_middlewares = middlewares.ToList();
|
||||
_router = router;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запускает выполнение конвейера для заданного контекста.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
45
BotPages.Core/Pipeline/Router.cs
Normal file
45
BotPages.Core/Pipeline/Router.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Простой роутер: команды верхнего уровня и делегирование текущей странице.
|
||||
/// </summary>
|
||||
public sealed class Router : IRouter
|
||||
{
|
||||
private readonly IPageRegistry _pages;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт роутер страниц.
|
||||
/// </summary>
|
||||
public Router(IPageRegistry pages) => _pages = pages;
|
||||
|
||||
/// <summary>
|
||||
/// Определяет текущую страницу и вызывает её обработчик.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
BotPages.Core/Pipeline/ThrottleMiddleware.cs
Normal file
25
BotPages.Core/Pipeline/ThrottleMiddleware.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Middleware троттлинга для ограничений нагрузки.
|
||||
/// </summary>
|
||||
public sealed class ThrottleMiddleware : IUpdateMiddleware
|
||||
{
|
||||
private readonly TimeSpan _delay;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт middleware троттлинга.
|
||||
/// </summary>
|
||||
public ThrottleMiddleware(TimeSpan delay) => _delay = delay;
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет искусственную задержку перед продолжением обработки.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(UpdateContext ctx, Func<Task> next, CancellationToken ct)
|
||||
{
|
||||
await Task.Delay(_delay, ct);
|
||||
await next();
|
||||
}
|
||||
}
|
||||
}
|
||||
43
BotPages.Core/Transport/DefaultFileService.cs
Normal file
43
BotPages.Core/Transport/DefaultFileService.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Транспорт-независимая реализация отправки пачек через клиент.
|
||||
/// </summary>
|
||||
public sealed class DefaultFileService : IFileService
|
||||
{
|
||||
/// <summary>
|
||||
/// Заглушка загрузки файла (реализуется в адаптере транспорта).
|
||||
/// </summary>
|
||||
public Task<FileDescriptor> DownloadAsync(string fileId, CancellationToken ct)
|
||||
{
|
||||
throw new System.NotImplementedException("DownloadAsync должен быть реализован конкретным транспортным адаптером.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Загружает несколько файлов по идентификаторам.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<FileDescriptor>> DownloadManyAsync(IEnumerable<string> fileIds, CancellationToken ct)
|
||||
{
|
||||
var res = new List<FileDescriptor>();
|
||||
foreach (var id in fileIds)
|
||||
res.Add(await DownloadAsync(id, ct));
|
||||
return res;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет один файл через клиент.
|
||||
/// </summary>
|
||||
public Task SendAsync(IChatClient client, long chatId, FileDescriptor file, CancellationToken ct)
|
||||
{
|
||||
return client.SendFilesAsync(chatId, new[] { file }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет несколько файлов через клиент.
|
||||
/// </summary>
|
||||
public Task SendManyAsync(IChatClient client, long chatId, IEnumerable<FileDescriptor> files, CancellationToken ct)
|
||||
{
|
||||
return client.SendFilesAsync(chatId, files, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
BotPages.Core/Transport/IChatClient.cs
Normal file
27
BotPages.Core/Transport/IChatClient.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Универсальный клиент для отправки сообщений и файлов в чат.
|
||||
/// Адаптеры (Telegram, MAX и др.) реализуют этот интерфейс.
|
||||
/// </summary>
|
||||
public interface IChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Отправляет текстовое сообщение.
|
||||
/// Может сопровождаться клавиатурой (inline или reply).
|
||||
/// </summary>
|
||||
/// <param name="chatId">Идентификатор чата.</param>
|
||||
/// <param name="message">Сообщение.</param>
|
||||
/// <param name="actions">Кнопки для отображения (опционально).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
Task SendTextAsync(long chatId, PageMessage message, IEnumerable<PageAction>? actions, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет файлы в чат.
|
||||
/// </summary>
|
||||
/// <param name="chatId">Идентификатор чата.</param>
|
||||
/// <param name="files">Файлы для отправки.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
Task SendFilesAsync(long chatId, IEnumerable<FileDescriptor> files, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
28
BotPages.Core/Transport/IFileService.cs
Normal file
28
BotPages.Core/Transport/IFileService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Сервис работы с файлами: загрузка и отправка пакетами.
|
||||
/// </summary>
|
||||
public interface IFileService
|
||||
{
|
||||
/// <summary>
|
||||
/// Загружает файл по идентификатору транспорта.
|
||||
/// </summary>
|
||||
Task<FileDescriptor> DownloadAsync(string fileId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Загружает несколько файлов по их идентификаторам.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FileDescriptor>> DownloadManyAsync(IEnumerable<string> fileIds, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет один файл в чат.
|
||||
/// </summary>
|
||||
Task SendAsync(IChatClient client, long chatId, FileDescriptor file, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет несколько файлов в чат.
|
||||
/// </summary>
|
||||
Task SendManyAsync(IChatClient client, long chatId, IEnumerable<FileDescriptor> files, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user