diff --git a/BotPages.Core/BotPagesApp.cs b/BotPages.Core/BotPagesApp.cs
index ce3bcc9..9e699d5 100644
--- a/BotPages.Core/BotPagesApp.cs
+++ b/BotPages.Core/BotPagesApp.cs
@@ -5,12 +5,13 @@ using BotPages.Core.Context;
using BotPages.Core.Logging;
using BotPages.Core.Routing;
using System.Reflection;
+using System.Threading;
///
/// Основное приложение BotPages.
/// Управляет маршрутизацией, командами, middleware и страницами.
///
-public sealed class BotPagesApp
+public sealed class BotPagesApp : IDisposable, IAsyncDisposable
{
private readonly IMessengerAdapterFactory _adapterFactory;
private readonly List _middlewares = new();
@@ -19,12 +20,21 @@ public sealed class BotPagesApp
private readonly IStateStorage _state;
private readonly ILogger _logger;
private readonly NavigationService _navigation;
+ private CancellationTokenSource? _internalCts;
+ private bool _isRunning;
+ private bool _disposed;
///
/// Серсвис логирования.
///
public ILogger Logger => _logger;
+ ///
+ /// ТОкен отмены приложения. Отменяется при вызове Stop() или при отмене внешнего токена, переданного в RunAsync.
+ ///
+ public CancellationToken CancellationToken => _internalCts?.Token ?? CancellationToken.None;
+
+
///
/// Создать приложение BotPages.
///
@@ -177,14 +187,14 @@ public sealed class BotPagesApp
///
/// Обработать входящее обновление.
///
- 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 (_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;
return;
@@ -194,7 +204,7 @@ public sealed class BotPagesApp
// Конвейер middleware
var pipeline = BuildPipeline(ctx, async () =>
{
- await DispatchToPageAsync(ctx, update, ct);
+ await DispatchToPageAsync(ctx, update);
});
await pipeline();
@@ -203,10 +213,8 @@ public sealed class BotPagesApp
///
/// Создать контекст страницы для текущего обновления.
///
- private async Task CreatePageContextAsync(UpdateContext update, CancellationToken ct)
+ private async Task CreatePageContextAsync(UpdateContext update)
{
- _currentCt = ct;
-
var sessionKey = CompositeSessionKey.FromUpdate(update);
var ctx = new PageContext
@@ -228,43 +236,40 @@ public sealed class BotPagesApp
foreach (var mw in _middlewares.AsEnumerable().Reverse())
{
var prev = next;
- next = () => mw.InvokeAsync(ctx, prev, _currentCt);
+ next = () => mw.InvokeAsync(ctx, prev, this.CancellationToken);
}
return next;
}
- // Технические поля для конвейера
- private CancellationToken _currentCt;
-
///
/// Отправить обновление на текущую страницу.
///
- private async Task DispatchToPageAsync(PageContext ctx, UpdateContext update, CancellationToken ct)
+ private async Task DispatchToPageAsync(PageContext ctx, UpdateContext update)
{
var page = ResolveCurrentPage(ctx);
if (page is null)
{
- await ctx.Navigation.GoToHomeAsync(ctx, ct);
+ await ctx.Navigation.GoToHomeAsync(ctx, this.CancellationToken);
return;
}
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.Button) && update.Text is not null) await page.OnButton(ctx, update.Text, ct);
- if (update.Kind.HasFlag(UpdateKind.File) && update.Files.Count > 0) await page.OnFile(ctx, update.Files, ct);
- if (update.Kind.HasFlag(UpdateKind.Pin) && update.PinInfo is not null) await page.OnPin(ctx, update.PinInfo, ct);
- if (update.Kind.HasFlag(UpdateKind.Delete) && update.DeleteInfo is not null) await page.OnDelete(ctx, update.DeleteInfo, 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, 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, ct);
+ await page.OnError(ctx, ex, this.CancellationToken);
}
}
@@ -274,16 +279,166 @@ public sealed class BotPagesApp
private Page? ResolveCurrentPage(PageContext ctx)
=> _navigation.ResolveCurrentPage(ctx);
+
+ ///
+ /// Запустить приложение и синхронно ожидать его завершения.
+ ///
+ /// Токен отмены для остановки приложения извне
+ public void Run(CancellationToken ct = default)
+ {
+ RunAsync(ct).Wait();
+ }
+
///
/// Сборка и запуск приложения.
///
- ///
///
- public async Task Build(CancellationToken cancellationToken)
+ public async Task RunAsync() => await RunAsync(new CancellationTokenSource().Token);
+
+ ///
+ /// Сборка и запуск приложения.
+ ///
+ ///
+ 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();
+ 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;
+ }
+
+ ///
+ /// Остановить работу приложения.
+ ///
+ public void Stop()
+ {
+ if (!_isRunning || _disposed)
+ return;
+
+ _logger.Log(LogLevel.Info, "Stopping BotPages application...");
+ _internalCts?.Cancel();
+ }
+
+ ///
+ /// Синхронно ожидать завершения работы приложения.
+ ///
+ /// Если приложение не запущено
+ 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;
+ }
+ }
+
+ ///
+ /// Асинхронно ожидать завершения работы приложения.
+ /// Ожидает, пока не будет вызван или внешний не будет отменен.
+ ///
+ public async Task WaitAsync()
+ {
+ if (!_isRunning || _internalCts == null)
+ return;
+
+ try
+ {
+ var tcs = new TaskCompletionSource();
+
+ using (_internalCts.Token.Register(() => tcs.TrySetResult(true)))
+ {
+ await tcs.Task;
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ // Токен уже освобожден
+ }
+ finally
+ {
+ _isRunning = false;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ DisposeAsync().AsTask().Wait();
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+
+ // Останавливаем приложение, если оно запущено
+ if (_isRunning)
+ {
+ Stop();
+ }
+
+ _internalCts?.Dispose();
+
foreach (var adapter in _adapterFactory.AllAdapters)
{
- await adapter.StartAdapterAsync(update => HandleUpdateAsync(update, cancellationToken), _commands.Commands, cancellationToken);
+ 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}");
+ }
}
}
}
\ No newline at end of file
diff --git a/BotPages.Telegram/TelegramAdapter.cs b/BotPages.Telegram/TelegramAdapter.cs
index 833d7fa..2608a06 100644
--- a/BotPages.Telegram/TelegramAdapter.cs
+++ b/BotPages.Telegram/TelegramAdapter.cs
@@ -402,6 +402,7 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup
{
MessageFormat.Html => ParseMode.Html,
MessageFormat.Markdown => ParseMode.MarkdownV2,
+ MessageFormat.Plain => ParseMode.None,
_ => ParseMode.None,
};
}
diff --git a/BotPages.Telegram/TelegramUpdateMapper.cs b/BotPages.Telegram/TelegramUpdateMapper.cs
index 49bb787..eec941b 100644
--- a/BotPages.Telegram/TelegramUpdateMapper.cs
+++ b/BotPages.Telegram/TelegramUpdateMapper.cs
@@ -198,15 +198,21 @@ public static class TelegramUpdateMapper
private static Func> GetStreamAsync(TelegramBotClient client, string fileId)
{
- Func> getStreamAsync = async _ =>
+ return async ct =>
{
- var file = await client.GetFile(fileId);
- var stream = new MemoryStream();
- await client.DownloadFile(file, stream);
- stream.Position = 0;
- return stream;
+ try
+ {
+ var file = await client.GetFile(fileId, ct);
+ var stream = new MemoryStream();
+ await client.DownloadFile(file.FilePath!, stream, ct);
+ stream.Position = 0;
+ return stream;
+ }
+ catch (Exception ex)
+ {
+ return Stream.Null;
+ throw;
+ }
};
-
- return getStreamAsync;
}
}
\ No newline at end of file
diff --git a/Demo/Program.cs b/Demo/Program.cs
index b54d23f..28c85aa 100644
--- a/Demo/Program.cs
+++ b/Demo/Program.cs
@@ -17,7 +17,6 @@ namespace Demo
var logger = new ConsoleLogger();
var state = new InMemoryStateStorage();
- using var cts = new CancellationTokenSource();
// Можно использовать команды для открытия страниц с роутингом
// /open Welcome
@@ -48,19 +47,20 @@ namespace Demo
.AutoMapRoute()
.AddMiddleware(new ErrorHandlingMiddleware(logger))
.AddMiddleware(new LoggingMiddleware(logger))
- .AddTelegramAdapter(token, "Telegram")
- .Build(cts.Token);
+ .AddTelegramAdapter(token, "Telegram");
+ 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.WriteLine("Cancel key pressed");
- cts.Cancel();
+ app.Stop();
e.Cancel = true;
};
- app.Wait();
+ await app.WaitAsync();
}
}
}