diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..cf5375e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: + - main + - dev + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + + - name: Build + run: dotnet build src/Max.BotClient/Max.BotClient.csproj -c Release + + - name: Test + run: dotnet test tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj -c Release --no-build + + publish: + runs-on: ubuntu-latest + needs: build-and-test + if: github.ref == 'refs/heads/main' + environment: main + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + + - name: Pack + run: dotnet pack src/Max.BotClient/Max.BotClient.csproj -c Release -o ./artifacts + + - name: Push to NuGet + run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs index f2f68af..1b929c6 100644 --- a/src/Max.BotClient/Max.BotClient.ApiExtensions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiExtensions.cs @@ -72,5 +72,18 @@ public static async Task ProcessApi( ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body); await botClient.SendRequest(method, path, body, cancellationToken); } + + internal static async Task PollingProcessApi( + this IBotClientInternal botClient, + HttpMethod method, + string basePath, + Func createParams, + CancellationToken cancellationToken = default + ) where TParams : class + { + ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body); + var dto = await botClient.PollingSendRequest(method, path, body, cancellationToken); + return dto.ToResult(); + } } } diff --git a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs index 31ec905..1498e7b 100644 --- a/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs +++ b/src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs @@ -90,5 +90,25 @@ public static async Task Unsubscribe( return (response.Updates, response.Marker); } + + private static async Task<(Update[], long?)> GetUpdatesFromPolling( + this IBotClientInternal botClient, + int? limit = null, + int? timeout = null, + long? marker = null, + Types.UpdateType[]? types = null, + CancellationToken cancellationToken = default + ) + { + var response = await botClient.PollingProcessApi( + HttpMethod.Get, + "/updates", + () => new GetUpdatesParams + { Limit = limit, Timeout = timeout, Marker = marker, UpdateTypes = types }, + cancellationToken + ); + + return (response.Updates, response.Marker); + } } -} +} \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.DependencyInjection.cs b/src/Max.BotClient/Max.BotClient.DependencyInjection.cs new file mode 100644 index 0000000..e0350d9 --- /dev/null +++ b/src/Max.BotClient/Max.BotClient.DependencyInjection.cs @@ -0,0 +1,44 @@ +#if NET10_0_OR_GREATER +using System; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Max.BotClient +{ + public static class BotClientServiceCollectionExtensions + { + /// + /// Регистрирует и в DI-контейнере. + /// + /// Коллекция сервисов. + /// Токен бота. + /// Дополнительная настройка параметров (RetryCount, RetryDelaySeconds и др.). + public static IServiceCollection AddMaxBotClient( + this IServiceCollection services, + string token, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new BotClientOptions(token); + configure?.Invoke(options); + + services.TryAddSingleton(options); + + services.AddHttpClient("MaxBotClient"); + + services.TryAddSingleton(sp => + { + var factory = sp.GetRequiredService(); + var httpClient = factory.CreateClient("MaxBotClient"); + return new BotClient(options, httpClient); + }); + + services.TryAddSingleton(sp => (BotClient)sp.GetRequiredService()); + + return services; + } + } +} +#endif \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.Polling.cs b/src/Max.BotClient/Max.BotClient.Polling.cs index 3552cfb..bea90c3 100644 --- a/src/Max.BotClient/Max.BotClient.Polling.cs +++ b/src/Max.BotClient/Max.BotClient.Polling.cs @@ -42,19 +42,19 @@ public static partial class BotClientApiMethods /// Обработчик ошибок (необязательно). /// Опции polling. /// Токен отмены. - public static void StartReceiving( + public static Task StartReceiving( this IBotClient botClient, Func updateHandler, Func? errorHandler = null, ReceiverOptions? options = null, CancellationToken cancellationToken = default - ) => Task.Run(() => + ) => Task.Run(() => botClient.ReceiveAsync( - updateHandler, - errorHandler, - options, + updateHandler, + errorHandler, + options, cancellationToken - ), + ), cancellationToken ); @@ -83,7 +83,7 @@ public static async Task ReceiveAsync( { try { - var (_, newMarker) = await botClient.GetUpdates( + var (_, newMarker) = await botClient.Update( limit: 1, timeout: 0, marker: null, @@ -92,14 +92,21 @@ public static async Task ReceiveAsync( ); marker = newMarker; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } catch (Exception ex) { - if (errorHandler != null) - await errorHandler(botClient, ex, cancellationToken); + try + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + catch + { + // Не позволяем errorHandler убить polling loop + } } } @@ -109,7 +116,7 @@ public static async Task ReceiveAsync( try { - var result = await botClient.GetUpdates( + var result = await botClient.Update( limit: options.Limit, timeout: options.Timeout, marker: marker, @@ -120,14 +127,31 @@ public static async Task ReceiveAsync( updates = result.Item1; marker = result.Item2 ?? marker; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception ex) { - if (errorHandler != null) - await errorHandler(botClient, ex, cancellationToken); + try + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + catch + { + // Не позволяем errorHandler убить polling loop + } + + // Backoff перед повторной попыткой, чтобы не забивать API при длительном сбое + try + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + catch (OperationCanceledException) + { + break; + } continue; } @@ -138,17 +162,51 @@ public static async Task ReceiveAsync( { await updateHandler(botClient, update, cancellationToken); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } catch (Exception ex) { - if (errorHandler != null) - await errorHandler(botClient, ex, cancellationToken); + try + { + if (errorHandler != null) + await errorHandler(botClient, ex, cancellationToken); + } + catch + { + // Не позволяем errorHandler убить polling loop + } } } } } + + private static Task<(Update[], long?)> Update( + this IBotClient botClient, + int? limit = null, + int? timeout = null, + long? marker = null, + Types.UpdateType[]? types = null, + CancellationToken cancellationToken = default + ) + { + if (botClient is IBotClientInternal internalBotClient) + return internalBotClient.GetUpdatesFromPolling( + limit, + timeout, + marker, + types, + cancellationToken + ); + + return botClient.GetUpdates( + limit, + timeout, + marker, + types, + cancellationToken + ); + } } -} +} \ No newline at end of file diff --git a/src/Max.BotClient/Max.BotClient.cs b/src/Max.BotClient/Max.BotClient.cs index 2c48f07..14c9175 100644 --- a/src/Max.BotClient/Max.BotClient.cs +++ b/src/Max.BotClient/Max.BotClient.cs @@ -19,10 +19,23 @@ Task SendRequest( ); } - public partial class BotClient : IBotClient + internal interface IBotClientInternal : IBotClient + { + Task PollingSendRequest( + HttpMethod method, + string path, + object? body = null, + CancellationToken cancellationToken = default + ); + } + + public partial class BotClient : IBotClientInternal, IDisposable { private readonly BotClientOptions _options; private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private HttpClient? _pollingHttpClient; + private bool _disposed; public string Token => _options.Token; public CancellationToken GlobalCancelToken { get; } @@ -36,6 +49,7 @@ public BotClient( _options = options ?? throw new ArgumentNullException(nameof(options)); GlobalCancelToken = cancellationToken; + _ownsHttpClient = httpClient == null; _httpClient = httpClient ?? new HttpClient(); _httpClient.BaseAddress = new Uri(_options.ApiUrl); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", _options.Token); @@ -54,6 +68,34 @@ public async Task SendRequest( string path, object? body = null, CancellationToken cancellationToken = default + ) => await SendRequestCore(_httpClient, method, path, body, cancellationToken); + + async Task IBotClientInternal.PollingSendRequest( + HttpMethod method, + string path, + object? body, + CancellationToken cancellationToken + ) + { + if (_pollingHttpClient == null) + { + _pollingHttpClient = new HttpClient + { + BaseAddress = new Uri(_options.ApiUrl), + Timeout = Timeout.InfiniteTimeSpan + }; + _pollingHttpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", _options.Token); + } + + return await SendRequestCore(_pollingHttpClient, method, path, body, cancellationToken); + } + + private async Task SendRequestCore( + HttpClient httpClient, + HttpMethod method, + string path, + object? body, + CancellationToken cancellationToken ) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(GlobalCancelToken, cancellationToken); @@ -78,7 +120,7 @@ public async Task SendRequest( request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } - using var response = await _httpClient.SendAsync(request, token); + using var response = await httpClient.SendAsync(request, token); var responseBody = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) @@ -98,5 +140,14 @@ public async Task SendRequest( throw lastException!; } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _pollingHttpClient?.Dispose(); + if (_ownsHttpClient) _httpClient.Dispose(); + } } } diff --git a/src/Max.BotClient/Max.BotClient.csproj b/src/Max.BotClient/Max.BotClient.csproj index b505e1b..d0ae1ab 100644 --- a/src/Max.BotClient/Max.BotClient.csproj +++ b/src/Max.BotClient/Max.BotClient.csproj @@ -1,15 +1,17 @@  - netstandard2.0 + netstandard2.0;net10.0 latest enable - 1.0.0 + 1.0.1 + Max.BotClient Deskri SDK for MAX Bot API (.NET Standard 2.0) MIT + https://github.com/Deskri/Max.BotClient https://github.com/Deskri/Max.BotClient max;bot;api;sdk README.md @@ -23,4 +25,9 @@ + + + + + diff --git a/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj b/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj index 3d1659c..a15db4d 100644 --- a/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj +++ b/tests/Max.BotClient.Tests/Max.BotClient.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 false enable latest