This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace BotPages.Core
|
||||
{
|
||||
public static class ActionExtensions
|
||||
{
|
||||
private static readonly Dictionary<Type, Dictionary<string, object>> _cache = new();
|
||||
|
||||
public static string GetActionLabel<T>(this T value)
|
||||
where T : Enum
|
||||
{
|
||||
var fieldName = value.ToString();
|
||||
var field = typeof(T).GetField(fieldName, BindingFlags.Public | BindingFlags.Static);
|
||||
return field?.GetCustomAttribute<ActionAttribute>()?.Label ?? fieldName;
|
||||
}
|
||||
|
||||
public static T? FromActionLabel<T>(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<string, object>();
|
||||
|
||||
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<ActionAttribute>();
|
||||
|
||||
if (attr != null)
|
||||
{
|
||||
fieldName = attr.Label;
|
||||
}
|
||||
|
||||
map[fieldName] = fieldValue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return map.TryGetValue(value, out var result) ? (T)result : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace BotPages.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Тип размещения кнопки.
|
||||
/// </summary>
|
||||
public enum ActionPlacement
|
||||
{
|
||||
/// <summary>
|
||||
/// Inline‑кнопка (под сообщением).
|
||||
/// </summary>
|
||||
Inline,
|
||||
|
||||
/// <summary>
|
||||
/// Reply‑кнопка (заменяет системную клавиатуру).
|
||||
/// </summary>
|
||||
Reply
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
namespace BotPages.Core
|
||||
using BotPages.Core.Abstractions;
|
||||
using BotPages.Core.Context;
|
||||
|
||||
namespace BotPages.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Базовый класс страницы.
|
||||
/// </summary>
|
||||
public abstract class Page
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
|
||||
/// <summary>Вход на страницу.</summary>
|
||||
public virtual Task OnEnter(PageContext ctx, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Общий обработчик обновлений.</summary>
|
||||
public virtual Task OnUpdate(PageContext ctx, UpdateContext update, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Обработка текста.</summary>
|
||||
public virtual Task OnText(PageContext ctx, string text, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Обработка файлов.</summary>
|
||||
public virtual Task OnFile(PageContext ctx, List<FileDescriptor> files, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Обработка кнопки.</summary>
|
||||
public virtual Task OnButton(PageContext ctx, string payload, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Выход со страницы.</summary>
|
||||
public virtual Task OnLeave(PageContext ctx, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Таймаут бездействия.</summary>
|
||||
public virtual Task OnTimeout(PageContext ctx, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Обработка ошибок.</summary>
|
||||
public virtual Task OnError(PageContext ctx, Exception ex, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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.Reply;
|
||||
|
||||
/// <summary>
|
||||
/// Номер ряда для макета (0 — первая строка).
|
||||
/// </summary>
|
||||
public int Row { get; init; } = 0;
|
||||
|
||||
public PageAction()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
public PageAction(Enum en)
|
||||
{
|
||||
Label = en.GetActionLabel();
|
||||
Value = en.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
|
||||
/// <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,
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
16
BotPages.Core/Pages/PageStateAttribute.cs
Normal file
16
BotPages.Core/Pages/PageStateAttribute.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace BotPages.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Атрибут для свойств страницы, которые должны сохраняться в StateStorage.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public sealed class StatefullAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ из Storage
|
||||
/// </summary>
|
||||
public string Key { get; }
|
||||
|
||||
///<inheritdoc/>
|
||||
public StatefullAttribute(string key) => Key = key;
|
||||
}
|
||||
7
BotPages.Core/Pages/SingletonPage.cs
Normal file
7
BotPages.Core/Pages/SingletonPage.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace BotPages.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Базовый класс страницы без состояния.
|
||||
/// Создается один экземпляр на все приложение.
|
||||
/// </summary>
|
||||
public abstract class SingletonPage : Page { }
|
||||
54
BotPages.Core/Pages/StatefullPage.cs
Normal file
54
BotPages.Core/Pages/StatefullPage.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace BotPages.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Базовый класс страницы с состоянием и аргументами.
|
||||
/// </summary>
|
||||
public abstract class StatefullPage<TArgs> : StatefullPage
|
||||
{
|
||||
/// <summary>Вход на страницу с аргументами.</summary>
|
||||
public virtual Task OnEnter(PageContext ctx, TArgs args, CancellationToken ct)
|
||||
=> base.OnEnter(ctx, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Базовый класс страницы с состоянием.
|
||||
/// Создается для каждого пользователя.
|
||||
/// </summary>
|
||||
public abstract class StatefullPage : Page
|
||||
{
|
||||
/// <summary>
|
||||
/// Загружает значения свойств из StateStorage.
|
||||
/// </summary>
|
||||
internal async Task LoadState(PageContext ctx, CancellationToken ct)
|
||||
{
|
||||
foreach (var prop in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
var attr = prop.GetCustomAttribute<StatefullAttribute>();
|
||||
if (attr is null) continue;
|
||||
|
||||
var value = await ctx.StateStorage.GetAsync<object>(ctx.SessionKey, attr.Key, ct);
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
prop.SetValue(this, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет значения свойств в StateStorage.
|
||||
/// </summary>
|
||||
internal async Task SaveState(PageContext ctx, CancellationToken ct)
|
||||
{
|
||||
foreach (var prop in GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
var attr = prop.GetCustomAttribute<StatefullAttribute>();
|
||||
if (attr is null) continue;
|
||||
|
||||
var value = prop.GetValue(this);
|
||||
await ctx.StateStorage.SetAsync(ctx.SessionKey, attr.Key, value, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user