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