Доработано управление жизненным циклом
This commit is contained in:
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user