Files
BotPages/BotPages.Core/BotPagesApp.cs
FrigaT 47921b1621
All checks were successful
CI / build-test (push) Successful in 38s
Release / pack-and-publish (release) Successful in 39s
Доработано управление жизненным циклом
2026-02-07 03:47:09 +03:00

444 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Context;
using BotPages.Core.Logging;
using BotPages.Core.Routing;
using System.Reflection;
using System.Threading;
/// <summary>
/// Основное приложение BotPages.
/// Управляет маршрутизацией, командами, middleware и страницами.
/// </summary>
public sealed class BotPagesApp : IDisposable, IAsyncDisposable
{
private readonly IMessengerAdapterFactory _adapterFactory;
private readonly List<IPageMiddleware> _middlewares = new();
private readonly RoutesRegistry _routes = new();
private readonly CommandsRegistry _commands = new();
private readonly IStateStorage _state;
private readonly ILogger _logger;
private readonly NavigationService _navigation;
private CancellationTokenSource? _internalCts;
private bool _isRunning;
private bool _disposed;
/// <summary>
/// Серсвис логирования.
/// </summary>
public ILogger Logger => _logger;
/// <summary>
/// ТОкен отмены приложения. Отменяется при вызове Stop() или при отмене внешнего токена, переданного в RunAsync.
/// </summary>
public CancellationToken CancellationToken => _internalCts?.Token ?? CancellationToken.None;
/// <summary>
/// Создать приложение BotPages.
/// </summary>
public BotPagesApp(IStateStorage state, ILogger logger)
{
_state = state;
_logger = logger;
_navigation = new NavigationService(_routes);
_adapterFactory = new MultiAdapterFactory();
}
/// <summary>
/// Добавить адаптер с указанием ID.
/// </summary>
/// <exception cref="ArgumentException">Если адаптер с таким ID уже существует.</exception>
public BotPagesApp AddAdapter(string adapterId, IMessengerAdapterSetup adapter)
{
_adapterFactory.Register(adapterId, adapter);
return this;
}
/// <summary>
/// Добавить адаптер с автоматическим ID.
/// </summary>
public BotPagesApp AddAdapter(IMessengerAdapterSetup adapter)
{
_adapterFactory.Register(adapter);
return this;
}
/// <summary>
/// Проверить, существует ли адаптер с указанным ID.
/// </summary>
public bool HasAdapter(string adapterId) => _adapterFactory.Contains(adapterId);
/// <summary>
/// Получить адаптер по ID.
/// </summary>
public IMessengerAdapter GetAdapter(string adapterId) => _adapterFactory.Resolve(adapterId);
/// <summary>
/// Получить все адаптеры определенного типа.
/// </summary>
public IReadOnlyList<IMessengerAdapter> GetAdaptersByType(string adapterType)
=> _adapterFactory.GetAdaptersByType(adapterType);
/// <summary>
/// Установить страницу по умолчанию.
/// </summary>
public BotPagesApp AddDefaultPage<TPage>() where TPage : SingletonPage
{
_navigation.AddDefaultPage<TPage>();
return this;
}
/// <summary>
/// Добавить middleware.
/// </summary>
public BotPagesApp AddMiddleware<TMiddleware>(TMiddleware instance) where TMiddleware : IPageMiddleware
{
_middlewares.Add(instance);
return this;
}
/// <summary>
/// Зарегистрировать команду, ведущую на страницу.
/// </summary>
public BotPagesApp MapCommand<TPage>(string commandTemplate) where TPage : Page
{
_commands.Map<TPage>(commandTemplate, false, null);
return this;
}
/// <summary>
/// Зарегистрировать команду, ведущую на страницу.
/// </summary>
public BotPagesApp MapCommand<TPage>(string commandTemplate, bool publish, string description) where TPage : Page
{
_commands.Map<TPage>(commandTemplate, publish, description);
return this;
}
/// <summary>
/// Зарегистрировать команду с кастомным обработчиком.
/// </summary>
public BotPagesApp MapCommand(string template, CommandHandler handler)
{
_commands.Map(template, handler, false, null);
return this;
}
/// <summary>
/// Зарегистрировать команду с кастомным обработчиком.
/// </summary>
public BotPagesApp MapCommand(string template, CommandHandler handler, bool publish, string description)
{
_commands.Map(template, handler, publish, description);
return this;
}
/// <summary>
/// Зарегистрировать маршрут для страницы.
/// </summary>
public BotPagesApp MapRoute<TPage>(string template) where TPage : Page
{
_routes.Map<TPage>(template);
return this;
}
/// <summary>
/// Зарегистрировать все маршруты для страницы.
/// Маршрутом является <see cref="RouteAttribute"/>.
/// Так же берется полное название класса.
/// </summary>
public BotPagesApp AutoMapRoute()
{
// Берём все загруженные сборки в текущем AppDomain
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
// Находим все типы, которые наследуются от Page
var pageTypes = assemblies
.SelectMany(a =>
{
try
{
return a.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
// Если часть типов не загрузилась — берём только успешные
return ex.Types.Where(t => t != null)!;
}
})
.Where(t => t != null
&& t.IsClass
&& !t.IsAbstract
&& t.IsSubclassOf(typeof(Page)))
.ToList();
// Выводим полные имена
foreach (var type in pageTypes)
{
_routes.Map(type);
}
return this;
}
/// <summary>
/// Обработать входящее обновление.
/// </summary>
public async Task HandleUpdateAsync(UpdateContext update)
{
var ctx = await CreatePageContextAsync(update);
// Команды выше событий страниц
if (update.Kind == UpdateKind.Text && update.Text is not null && update.Text.StartsWith("/"))
{
if (_commands.TryDispatch(ctx, update.Text, this.CancellationToken, out var dispatched) && dispatched is not null)
{
await dispatched;
return;
}
}
// Конвейер middleware
var pipeline = BuildPipeline(ctx, async () =>
{
await DispatchToPageAsync(ctx, update);
});
await pipeline();
}
/// <summary>
/// Создать контекст страницы для текущего обновления.
/// </summary>
private async Task<PageContext> CreatePageContextAsync(UpdateContext update)
{
var sessionKey = CompositeSessionKey.FromUpdate(update);
var ctx = new PageContext
{
Update = update,
SessionKey = sessionKey,
StateStorage = _state,
Navigation = _navigation,
Adapter = _adapterFactory.Resolve(update.Adapter.Id),
AdapterFactory = _adapterFactory,
};
return await Task.FromResult(ctx);
}
private Func<Task> BuildPipeline(PageContext ctx, Func<Task> terminal)
{
Func<Task> next = terminal;
foreach (var mw in _middlewares.AsEnumerable().Reverse())
{
var prev = next;
next = () => mw.InvokeAsync(ctx, prev, this.CancellationToken);
}
return next;
}
/// <summary>
/// Отправить обновление на текущую страницу.
/// </summary>
private async Task DispatchToPageAsync(PageContext ctx, UpdateContext update)
{
var page = ResolveCurrentPage(ctx);
if (page is null)
{
await ctx.Navigation.GoToHomeAsync(ctx, this.CancellationToken);
return;
}
try
{
await page.OnUpdate(ctx, update, this.CancellationToken);
if (update.Kind.HasFlag(UpdateKind.Text) && update.Text is not null) await page.OnText(ctx, update.Text, this.CancellationToken);
if (update.Kind.HasFlag(UpdateKind.Button) && update.Text is not null) await page.OnButton(ctx, update.Text, this.CancellationToken);
if (update.Kind.HasFlag(UpdateKind.File) && update.Files.Count > 0) await page.OnFile(ctx, update.Files, this.CancellationToken);
if (update.Kind.HasFlag(UpdateKind.Pin) && update.PinInfo is not null) await page.OnPin(ctx, update.PinInfo, this.CancellationToken);
if (update.Kind.HasFlag(UpdateKind.Delete) && update.DeleteInfo is not null) await page.OnDelete(ctx, update.DeleteInfo, this.CancellationToken);
}
catch (Exception ex)
{
_logger.Log(LogLevel.Critical, "Unhandled page error.", ex);
await page.OnError(ctx, ex, this.CancellationToken);
}
}
/// <summary>
/// Определить текущую страницу.
/// </summary>
private Page? ResolveCurrentPage(PageContext ctx)
=> _navigation.ResolveCurrentPage(ctx);
/// <summary>
/// Запустить приложение и синхронно ожидать его завершения.
/// </summary>
/// <param name="ct">Токен отмены для остановки приложения извне</param>
public void Run(CancellationToken ct = default)
{
RunAsync(ct).Wait();
}
/// <summary>
/// Сборка и запуск приложения.
/// </summary>
/// <returns></returns>
public async Task RunAsync() => await RunAsync(new CancellationTokenSource().Token);
/// <summary>
/// Сборка и запуск приложения.
/// </summary>
/// <returns></returns>
public async Task RunAsync(CancellationToken ct = default)
{
if (_isRunning)
throw new InvalidOperationException("Application is already running.");
if (_disposed)
throw new ObjectDisposedException(nameof(BotPagesApp));
_internalCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
_logger.Log(LogLevel.Info, "Starting BotPages application...");
var adapterTasks = new List<Task>();
foreach (var adapter in _adapterFactory.AllAdapters)
{
var adapterTask = adapter.StartAdapterAsync(HandleUpdateAsync, _commands.Commands, CancellationToken);
adapterTasks.Add(adapterTask);
}
await Task.WhenAll(adapterTasks);
_isRunning = true;
_logger.Log(LogLevel.Info, "BotPages application started successfully.");
}
catch (Exception ex)
{
_internalCts?.Dispose();
_internalCts = null;
_logger.Log(LogLevel.Critical, "Failed to start BotPages application.", ex);
throw;
}
_isRunning = true;
}
/// <summary>
/// Остановить работу приложения.
/// </summary>
public void Stop()
{
if (!_isRunning || _disposed)
return;
_logger.Log(LogLevel.Info, "Stopping BotPages application...");
_internalCts?.Cancel();
}
/// <summary>
/// Синхронно ожидать завершения работы приложения.
/// </summary>
/// <exception cref="InvalidOperationException">Если приложение не запущено</exception>
public void Wait()
{
if (!_isRunning)
throw new InvalidOperationException("Application is not running.");
try
{
// Ждем, пока токен не будет отменен
WaitHandle.WaitAny([_internalCts.Token.WaitHandle]);
}
catch (AggregateException ex) when (ex.InnerException is OperationCanceledException)
{
// Игнорируем отмену операции
}
finally
{
_isRunning = false;
}
}
/// <summary>
/// Асинхронно ожидать завершения работы приложения.
/// Ожидает, пока не будет вызван <see cref="Stop"/> или внешний <see cref="CancellationTokenSource"/> не будет отменен.
/// </summary>
public async Task WaitAsync()
{
if (!_isRunning || _internalCts == null)
return;
try
{
var tcs = new TaskCompletionSource<bool>();
using (_internalCts.Token.Register(() => tcs.TrySetResult(true)))
{
await tcs.Task;
}
}
catch (ObjectDisposedException)
{
// Токен уже освобожден
}
finally
{
_isRunning = false;
}
}
/// <inheritdoc/>
public void Dispose()
{
DisposeAsync().AsTask().Wait();
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
// Останавливаем приложение, если оно запущено
if (_isRunning)
{
Stop();
}
_internalCts?.Dispose();
foreach (var adapter in _adapterFactory.AllAdapters)
{
try
{
if (adapter is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else if (adapter is IDisposable disposable)
{
disposable.Dispose();
}
}
catch (Exception ex)
{
_logger.Log(LogLevel.Warn, $"Error disposing adapter {adapter.Id}: {ex.Message}");
}
}
}
}