Добавьте файлы проекта.

This commit is contained in:
2025-12-02 15:57:42 +03:00
parent cf107b62a3
commit 7f69eab545
44 changed files with 1470 additions and 0 deletions

View File

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

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

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

View File

@@ -0,0 +1,7 @@
namespace BotPages.Core
{
/// <summary>
/// Запись навигационного стека: страница и её аргументы.
/// </summary>
public sealed record NavEntry(string PageId, object? Args = null);
}

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

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

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

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

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

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

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