Доработано управление жизненным циклом
All checks were successful
CI / build-test (push) Successful in 38s
Release / pack-and-publish (release) Successful in 39s

This commit is contained in:
2026-02-07 03:47:09 +03:00
parent 5dc071c750
commit 47921b1621
4 changed files with 200 additions and 38 deletions

View File

@@ -5,12 +5,13 @@ using BotPages.Core.Context;
using BotPages.Core.Logging; using BotPages.Core.Logging;
using BotPages.Core.Routing; using BotPages.Core.Routing;
using System.Reflection; using System.Reflection;
using System.Threading;
/// <summary> /// <summary>
/// Основное приложение BotPages. /// Основное приложение BotPages.
/// Управляет маршрутизацией, командами, middleware и страницами. /// Управляет маршрутизацией, командами, middleware и страницами.
/// </summary> /// </summary>
public sealed class BotPagesApp public sealed class BotPagesApp : IDisposable, IAsyncDisposable
{ {
private readonly IMessengerAdapterFactory _adapterFactory; private readonly IMessengerAdapterFactory _adapterFactory;
private readonly List<IPageMiddleware> _middlewares = new(); private readonly List<IPageMiddleware> _middlewares = new();
@@ -19,12 +20,21 @@ public sealed class BotPagesApp
private readonly IStateStorage _state; private readonly IStateStorage _state;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly NavigationService _navigation; private readonly NavigationService _navigation;
private CancellationTokenSource? _internalCts;
private bool _isRunning;
private bool _disposed;
/// <summary> /// <summary>
/// Серсвис логирования. /// Серсвис логирования.
/// </summary> /// </summary>
public ILogger Logger => _logger; public ILogger Logger => _logger;
/// <summary>
/// ТОкен отмены приложения. Отменяется при вызове Stop() или при отмене внешнего токена, переданного в RunAsync.
/// </summary>
public CancellationToken CancellationToken => _internalCts?.Token ?? CancellationToken.None;
/// <summary> /// <summary>
/// Создать приложение BotPages. /// Создать приложение BotPages.
/// </summary> /// </summary>
@@ -177,14 +187,14 @@ public sealed class BotPagesApp
/// <summary> /// <summary>
/// Обработать входящее обновление. /// Обработать входящее обновление.
/// </summary> /// </summary>
public async Task HandleUpdateAsync(UpdateContext update, CancellationToken ct) public async Task HandleUpdateAsync(UpdateContext update)
{ {
var ctx = await CreatePageContextAsync(update, ct); var ctx = await CreatePageContextAsync(update);
// Команды выше событий страниц // Команды выше событий страниц
if (update.Kind == UpdateKind.Text && update.Text is not null && update.Text.StartsWith("/")) if (update.Kind == UpdateKind.Text && update.Text is not null && update.Text.StartsWith("/"))
{ {
if (_commands.TryDispatch(ctx, update.Text, ct, out var dispatched) && dispatched is not null) if (_commands.TryDispatch(ctx, update.Text, this.CancellationToken, out var dispatched) && dispatched is not null)
{ {
await dispatched; await dispatched;
return; return;
@@ -194,7 +204,7 @@ public sealed class BotPagesApp
// Конвейер middleware // Конвейер middleware
var pipeline = BuildPipeline(ctx, async () => var pipeline = BuildPipeline(ctx, async () =>
{ {
await DispatchToPageAsync(ctx, update, ct); await DispatchToPageAsync(ctx, update);
}); });
await pipeline(); await pipeline();
@@ -203,10 +213,8 @@ public sealed class BotPagesApp
/// <summary> /// <summary>
/// Создать контекст страницы для текущего обновления. /// Создать контекст страницы для текущего обновления.
/// </summary> /// </summary>
private async Task<PageContext> CreatePageContextAsync(UpdateContext update, CancellationToken ct) private async Task<PageContext> CreatePageContextAsync(UpdateContext update)
{ {
_currentCt = ct;
var sessionKey = CompositeSessionKey.FromUpdate(update); var sessionKey = CompositeSessionKey.FromUpdate(update);
var ctx = new PageContext var ctx = new PageContext
@@ -228,43 +236,40 @@ public sealed class BotPagesApp
foreach (var mw in _middlewares.AsEnumerable().Reverse()) foreach (var mw in _middlewares.AsEnumerable().Reverse())
{ {
var prev = next; var prev = next;
next = () => mw.InvokeAsync(ctx, prev, _currentCt); next = () => mw.InvokeAsync(ctx, prev, this.CancellationToken);
} }
return next; return next;
} }
// Технические поля для конвейера
private CancellationToken _currentCt;
/// <summary> /// <summary>
/// Отправить обновление на текущую страницу. /// Отправить обновление на текущую страницу.
/// </summary> /// </summary>
private async Task DispatchToPageAsync(PageContext ctx, UpdateContext update, CancellationToken ct) private async Task DispatchToPageAsync(PageContext ctx, UpdateContext update)
{ {
var page = ResolveCurrentPage(ctx); var page = ResolveCurrentPage(ctx);
if (page is null) if (page is null)
{ {
await ctx.Navigation.GoToHomeAsync(ctx, ct); await ctx.Navigation.GoToHomeAsync(ctx, this.CancellationToken);
return; return;
} }
try try
{ {
await page.OnUpdate(ctx, update, ct); await page.OnUpdate(ctx, update, this.CancellationToken);
if (update.Kind.HasFlag(UpdateKind.Text) && update.Text is not null) await page.OnText(ctx, update.Text, ct); 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, ct); 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, ct); 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, ct); 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, ct); if (update.Kind.HasFlag(UpdateKind.Delete) && update.DeleteInfo is not null) await page.OnDelete(ctx, update.DeleteInfo, this.CancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Log(LogLevel.Critical, "Unhandled page error.", ex); _logger.Log(LogLevel.Critical, "Unhandled page error.", ex);
await page.OnError(ctx, ex, ct); await page.OnError(ctx, ex, this.CancellationToken);
} }
} }
@@ -274,16 +279,166 @@ public sealed class BotPagesApp
private Page? ResolveCurrentPage(PageContext ctx) private Page? ResolveCurrentPage(PageContext ctx)
=> _navigation.ResolveCurrentPage(ctx); => _navigation.ResolveCurrentPage(ctx);
/// <summary>
/// Запустить приложение и синхронно ожидать его завершения.
/// </summary>
/// <param name="ct">Токен отмены для остановки приложения извне</param>
public void Run(CancellationToken ct = default)
{
RunAsync(ct).Wait();
}
/// <summary> /// <summary>
/// Сборка и запуск приложения. /// Сборка и запуск приложения.
/// </summary> /// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns> /// <returns></returns>
public async Task Build(CancellationToken cancellationToken) 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) foreach (var adapter in _adapterFactory.AllAdapters)
{ {
await adapter.StartAdapterAsync(update => HandleUpdateAsync(update, cancellationToken), _commands.Commands, cancellationToken); 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}");
}
} }
} }
} }

View File

@@ -402,6 +402,7 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup
{ {
MessageFormat.Html => ParseMode.Html, MessageFormat.Html => ParseMode.Html,
MessageFormat.Markdown => ParseMode.MarkdownV2, MessageFormat.Markdown => ParseMode.MarkdownV2,
MessageFormat.Plain => ParseMode.None,
_ => ParseMode.None, _ => ParseMode.None,
}; };
} }

View File

@@ -198,15 +198,21 @@ public static class TelegramUpdateMapper
private static Func<CancellationToken, Task<Stream>> GetStreamAsync(TelegramBotClient client, string fileId) private static Func<CancellationToken, Task<Stream>> GetStreamAsync(TelegramBotClient client, string fileId)
{ {
Func<CancellationToken, Task<Stream>> getStreamAsync = async _ => return async ct =>
{ {
var file = await client.GetFile(fileId); try
{
var file = await client.GetFile(fileId, ct);
var stream = new MemoryStream(); var stream = new MemoryStream();
await client.DownloadFile(file, stream); await client.DownloadFile(file.FilePath!, stream, ct);
stream.Position = 0; stream.Position = 0;
return stream; return stream;
}
catch (Exception ex)
{
return Stream.Null;
throw;
}
}; };
return getStreamAsync;
} }
} }

View File

@@ -17,7 +17,6 @@ namespace Demo
var logger = new ConsoleLogger(); var logger = new ConsoleLogger();
var state = new InMemoryStateStorage(); var state = new InMemoryStateStorage();
using var cts = new CancellationTokenSource();
// Можно использовать команды для открытия страниц с роутингом // Можно использовать команды для открытия страниц с роутингом
// /open Welcome // /open Welcome
@@ -48,19 +47,20 @@ namespace Demo
.AutoMapRoute() .AutoMapRoute()
.AddMiddleware(new ErrorHandlingMiddleware(logger)) .AddMiddleware(new ErrorHandlingMiddleware(logger))
.AddMiddleware(new LoggingMiddleware(logger)) .AddMiddleware(new LoggingMiddleware(logger))
.AddTelegramAdapter(token, "Telegram") .AddTelegramAdapter(token, "Telegram");
.Build(cts.Token);
await app.RunAsync();
logger.Log(LogLevel.Info, "Bot is running. Press Ctrl+C to stop.");
Console.WriteLine("Bot is running. Press Ctrl+C to stop.");
Console.CancelKeyPress += (sender, e) => Console.CancelKeyPress += (sender, e) =>
{ {
Console.WriteLine("Cancel key pressed"); Console.WriteLine("Cancel key pressed");
cts.Cancel(); app.Stop();
e.Cancel = true; e.Cancel = true;
}; };
app.Wait(); await app.WaitAsync();
} }
} }
} }