Переработанная версия ядра
All checks were successful
CI / build-test (push) Successful in 42s

This commit is contained in:
2025-12-05 12:57:05 +03:00
parent ee175a35a0
commit d817417a69
81 changed files with 2335 additions and 1453 deletions

View File

@@ -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; }
}
}

View File

@@ -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;
}
}
}

View File

@@ -1,20 +0,0 @@
namespace BotPages.Core
{
/// <summary>
/// Тип размещения кнопки.
/// </summary>
public enum ActionPlacement
{
/// <summary>
/// Inlineкнопка (под сообщением).
/// </summary>
Inline,
/// <summary>
/// Replyкнопка (заменяет системную клавиатуру).
/// </summary>
Reply
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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,
}

View File

@@ -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; }
}

View File

@@ -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);
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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();
}
}

View 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;
}

View File

@@ -0,0 +1,7 @@
namespace BotPages.Core;
/// <summary>
/// Базовый класс страницы без состояния.
/// Создается один экземпляр на все приложение.
/// </summary>
public abstract class SingletonPage : Page { }

View 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);
}
}
}