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

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,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

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

View File

@@ -0,0 +1,12 @@
namespace BotPages.Core
{
/// <summary>
/// Универсальный дескриптор файла для операций загрузки/отправки.
/// </summary>
public sealed record FileDescriptor(
string Id,
string Name,
string MimeType,
Stream? Content = null
);
}

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,13 @@
namespace BotPages.Core
{
/// <summary>
/// Маршрутизатор обновлений на страницы.
/// </summary>
public interface IRouter
{
/// <summary>
/// Определяет текущую страницу и вызывает её обработчик.
/// </summary>
Task RouteAsync(UpdateContext ctx, CancellationToken ct);
}
}

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.7.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BotPages.Core\BotPages.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,89 @@
using BotPages.Core;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
using static System.Net.Mime.MediaTypeNames;
namespace BotPages.Telegram
{
/// <summary>
/// Адаптер клиента для Telegram Bot API.
/// </summary>
public sealed class TelegramClientAdapter : IChatClient
{
private readonly ITelegramBotClient _bot;
/// <summary>
/// Создаёт адаптер на основе ITelegramBotClient.
/// </summary>
public TelegramClientAdapter(ITelegramBotClient bot) => _bot = bot;
/// <summary>
/// Отправляет текстовое сообщение с опциональной клавиатурой.
/// </summary>
public Task SendTextAsync(long chatId, PageMessage message, IEnumerable<PageAction>? actions, CancellationToken ct)
{
ReplyMarkup? replyMarkup = null;
if (actions is { })
{
var inlineGroups = actions
.Where(a => a.Placement == ActionPlacement.Inline)
.GroupBy(a => a.Row)
.OrderBy(g => g.Key)
.Select(g => g.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, a.Value)).ToArray())
.ToArray();
var replyGroups = actions
.Where(a => a.Placement == ActionPlacement.Reply)
.GroupBy(a => a.Row)
.OrderBy(g => g.Key)
.Select(g => g.Select(a => new KeyboardButton(a.Label)).ToArray())
.ToArray();
if (inlineGroups.Any())
replyMarkup = new InlineKeyboardMarkup(inlineGroups);
else if (replyGroups.Any())
replyMarkup = new ReplyKeyboardMarkup(replyGroups) { ResizeKeyboard = true };
}
var parseMode = message.Format switch
{
MessageFormat.Markdown => ParseMode.MarkdownV2,
MessageFormat.Html => ParseMode.Html,
_ => ParseMode.None,
};
return _bot.SendMessage(new ChatId(chatId),
message.Text,
parseMode: parseMode,
replyMarkup: replyMarkup,
disableNotification: message.IsSilent,
cancellationToken: ct
);
}
/// <summary>
/// Отправляет файлы как документы (по одному или пачкой).
/// </summary>
public async Task SendFilesAsync(long chatId, IEnumerable<FileDescriptor> files, CancellationToken ct)
{
foreach (var f in files)
{
if (f.Content is not null)
{
var input = new InputFileStream(f.Content, f.Name);
await _bot.SendDocument(chatId, input, cancellationToken: ct);
}
else
{
// Если контент не загружен, и есть FileId — отправляем по Id
await _bot.SendDocument(chatId, new InputFileId(f.Id), cancellationToken: ct);
}
}
}
}
}

View File

@@ -0,0 +1,59 @@
using BotPages.Core;
using Telegram.Bot;
namespace BotPages.Telegram
{
/// <summary>
/// FileService для Telegram: загрузка по FileId.
/// </summary>
public sealed class TelegramFileService : IFileService
{
private readonly ITelegramBotClient _bot;
/// <summary>
/// Создаёт файловый сервис для Telegram.
/// </summary>
public TelegramFileService(ITelegramBotClient bot) => _bot = bot;
/// <summary>
/// Загружает файл по идентификатору Telegram FileId.
/// </summary>
public async Task<FileDescriptor> DownloadAsync(string fileId, CancellationToken ct)
{
var file = await _bot.GetFile(fileId, ct);
var ms = new MemoryStream();
await _bot.DownloadFile(file.FilePath!, ms, ct);
ms.Position = 0;
var name = System.IO.Path.GetFileName(file.FilePath!);
return new FileDescriptor(file.FileId, name, "application/octet-stream", ms);
}
/// <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 async Task SendAsync(IChatClient client, long chatId, FileDescriptor file, CancellationToken ct)
{
await 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);
}
}
}

View File

@@ -0,0 +1,46 @@
using BotPages.Core;
using Telegram.Bot;
using Telegram.Bot.Types;
namespace BotPages.Telegram
{
/// <summary>
/// Утилиты для извлечения контекста из Telegram Update.
/// </summary>
public static class TelegramUpdateMapper
{
/// <summary>
/// Преобразует Telegram Update в универсальный UpdateContext.
/// </summary>
public static UpdateContext Map(ITelegramBotClient bot, INavigationService nav, IStateStore store, Update update)
{
var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat;
var user = update.Message?.From ?? update.CallbackQuery?.From;
var text = update.Message?.Text ?? update.CallbackQuery?.Data;
var files = new List<FileDescriptor>();
if (update.Message?.Document is { } doc)
{
files.Add(new FileDescriptor(doc.FileId, doc.FileName ?? "file", doc.MimeType ?? "application/octet-stream"));
}
if (update.Message?.Photo is { } photos && photos.Count() > 0)
{
var largest = photos.OrderBy(p => p.FileSize).Last();
files.Add(new FileDescriptor(largest.FileId, "photo.jpg", "image/jpeg"));
}
return new UpdateContext
{
Client = new TelegramClientAdapter(bot),
Chat = new ChatContext { Id = chat!.Id, Title = chat.Title },
User = new UserContext { Id = user!.Id, DisplayName = $"{user.FirstName} {user.LastName}" },
Text = text,
IncomingFiles = files,
RawUpdate = update,
Nav = nav,
State = store
};
}
}
}

6
BotPages.slnx Normal file
View File

@@ -0,0 +1,6 @@
<Solution>
<Project Path="BotPages.Core/BotPages.Core.csproj" />
<Project Path="BotPages.Telegram/BotPages.Telegram.csproj" />
<Project Path="BotPages/BotPages.csproj" />
<Project Path="Demo/Demo.csproj" />
</Solution>

14
BotPages/BotPages.csproj Normal file
View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BotPages.Core\BotPages.Core.csproj" />
<ProjectReference Include="..\BotPages.Telegram\BotPages.Telegram.csproj" />
</ItemGroup>
</Project>

14
Demo/Demo.csproj Normal file
View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BotPages\BotPages.csproj" />
</ItemGroup>
</Project>

39
Demo/Pages/FilesPage.cs Normal file
View File

@@ -0,0 +1,39 @@
using BotPages.Core;
namespace Demo.Pages
{
public sealed class FilesPage : Page
{
public static string Id => nameof(FilesPage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
{
var actions = new[]
{
new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Reply, Row = 0 }
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("📂 Здесь можно загрузить или отправить файл.")
.WithKeyboard(actions)
.Build()
);
}
public override async Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
if (ctx.Text == "⬅️ Назад")
return PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build();
if (ctx.IncomingFiles?.Count > 0)
{
await ctx.Client.SendFilesAsync(ctx.Chat.Id, ctx.IncomingFiles, ct);
return PageResultBuilder.Empty().WithText("Файл получен и отправлен обратно.").Build();
}
return PageResultBuilder.Empty().WithText("Пришлите файл или нажмите 'Назад'.").Build();
}
}
}

35
Demo/Pages/InlinePage.cs Normal file
View File

@@ -0,0 +1,35 @@
using BotPages.Core;
namespace Demo.Pages
{
public sealed class InlinePage : Page
{
public override string Id => nameof(InlinePage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
{
var actions = new[]
{
new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Inline, Row = 0 }
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("Это страница с Inlineкнопками.")
.WithKeyboard(actions)
.Build()
);
}
public override Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
if (ctx.Text == "back")
return Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build());
return Task.FromResult(PageResultBuilder.Empty().WithText("Нажмите кнопку 'Назад'.").Build());
}
}
}

38
Demo/Pages/MainPage.cs Normal file
View File

@@ -0,0 +1,38 @@
using BotPages.Core;
namespace Demo.Pages
{
public sealed class MainPage : Page
{
public override string Id => nameof(MainPage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
{
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 }
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("🏠 Главная страница.\nВыберите куда перейти:")
.WithKeyboard(actions)
.Build()
);
}
public override Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
return ctx.Text 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()),
_ => Task.FromResult(PageResultBuilder.Empty().WithText("Выберите действие с кнопок.").Build())
};
}
}
}

33
Demo/Pages/ReplyPage.cs Normal file
View File

@@ -0,0 +1,33 @@
using BotPages.Core;
namespace Demo.Pages
{
public sealed class ReplyPage : Page
{
public override string Id => nameof(ReplyPage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
{
var actions = new[]
{
new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Reply, Row = 0 }
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("Это страница с Replyклавиатурой.")
.WithKeyboard(actions)
.Build()
);
}
public override Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
if (ctx.Text == "⬅️ Назад")
return Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build());
return Task.FromResult(PageResultBuilder.Empty().WithText("Нажмите кнопку 'Назад'.").Build());
}
}
}

65
Demo/Program.cs Normal file
View File

@@ -0,0 +1,65 @@
using BotPages.Core;
using BotPages.Telegram;
using Demo.Pages;
using Telegram.Bot;
namespace Demo
{
internal class Program
{
public static async Task Main(string[] args)
{
// Токен Telegram бота
var token = Environment.GetEnvironmentVariable("TELEGRAM_TOKEN") ?? throw new InvalidOperationException("TELEGRAM_TOKEN not set");
// Инициализация Telegram клиента
var botClient = new TelegramBotClient(token);
var chatClient = new TelegramClientAdapter(botClient);
// Регистрируем страницы
var pages = new IPage[]
{
new MainPage(),
new InlinePage(),
new ReplyPage(),
new FilesPage()
};
var registry = new PageRegistry(pages, pages[0]);
// Навигация и состояние
IStateStore store = new InMemoryStateStore();
INavigationService nav = new NavigationService(registry, store);
var router = new Router(registry);
var middleware = new IUpdateMiddleware[]
{
new LoggingMiddleware(), //логирование вызова в консоль
new ErrorHandlingMiddleware(), //обработчик ошибок
//new ThrottleMiddleware(TimeSpan.FromMilliseconds(150)), //задержка в 150мс перед ответом
};
var pipeline = new Pipeline(middleware, router);
botClient.StartReceiving(
async (bot, update, ct) =>
{
var ctx = TelegramUpdateMapper.Map(bot, nav, store, update);
await pipeline.ExecuteAsync(ctx, ct);
},
(bot, error, ct) =>
{
Console.WriteLine($"⚠️ Ошибка Telegram: {error}");
return Task.CompletedTask;
}
);
var me = await botClient.GetMe();
Console.WriteLine($"BotPages Demo (@{me.Username}) запущен. Нажмите Enter для выхода.");
Console.ReadLine();
}
}
}

View File

@@ -0,0 +1,10 @@
{
"profiles": {
"Demo": {
"commandName": "Project",
"environmentVariables": {
"TELEGRAM_TOKEN": "7992309062:AAHkb4wnFi8w7H4V0zvUW_LJg55jtzuhahU"
}
}
}
}