diff --git a/Directory.Packages.props b/Directory.Packages.props index 8a724a1..46c4832 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,16 +1,16 @@  - - - - - + + + + + - - - + + + - + diff --git a/TgBotFrame.Commands/Services/CommandExplorerService.cs b/TgBotFrame.Commands/Services/CommandExplorerService.cs index 20ce49a..10fb9e7 100644 --- a/TgBotFrame.Commands/Services/CommandExplorerService.cs +++ b/TgBotFrame.Commands/Services/CommandExplorerService.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Logging; -using Microsoft.FeatureManagement; -using System.Collections.Frozen; +using System.Collections.Frozen; using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.FeatureManagement; using TgBotFrame.Commands.Attributes; using TgBotFrame.Services; @@ -9,12 +9,15 @@ namespace TgBotFrame.Commands.Services; public sealed class CommandExplorerService(ILogger logger) { + private FrozenDictionary methods, Assembly[] assemblies)> + CATEGORIES = + FrozenDictionary methods, Assembly[] assemblies)>.Empty; + private FrozenDictionary> COMMANDS = FrozenDictionary>.Empty; - private FrozenDictionary methods, Assembly[] assemblies)> CATEGORIES = - FrozenDictionary methods, Assembly[] assemblies)>.Empty; - public async IAsyncEnumerable> GetCommand(IVariantFeatureManager? featureManager, string commandName) + public async IAsyncEnumerable> GetCommand( + IVariantFeatureManager? featureManager, string commandName) { if (!COMMANDS.TryGetValue(commandName, out FrozenDictionary? commands)) { @@ -33,12 +36,15 @@ public async IAsyncEnumerable> GetComm foreach (KeyValuePair method in commands) { if (await BotService.IsFeatureEnabled(featureManager, method.Key).ConfigureAwait(false)) + { yield return method; + } } } } - public async IAsyncEnumerable>>> GetCommands(IVariantFeatureManager? featureManager) + public async IAsyncEnumerable>>> + GetCommands(IVariantFeatureManager? featureManager) { foreach (KeyValuePair> commandName in COMMANDS) { @@ -47,15 +53,19 @@ public async IAsyncEnumerable GetAssembliesForCategory(in string category) { - return CATEGORIES.TryGetValue(category, out (FrozenDictionary methods, Assembly[] assemblies) value) ? value.assemblies : []; + return CATEGORIES.TryGetValue(category, + out (FrozenDictionary methods, Assembly[] assemblies) value) + ? value.assemblies + : []; } - public async IAsyncEnumerable GetCategoryCommandsNames(IVariantFeatureManager? featureManager, string category) + public async IAsyncEnumerable GetCategoryCommandsNames(IVariantFeatureManager? featureManager, + string category) { - if (!CATEGORIES.TryGetValue(category, out (FrozenDictionary methods, Assembly[] assemblies) methods)) + if (!CATEGORIES.TryGetValue(category, + out (FrozenDictionary methods, Assembly[] assemblies) methods)) { yield break; } @@ -88,12 +98,17 @@ public void FillControllers(in IEnumerable commandControllers) { continue; } + Assembly assembly = type.Assembly; - if (categories.TryGetValue(controllerAttribute.CategoryKey, out (Dictionary> methods, List assemblies) categoryInfo)) + if (categories.TryGetValue(controllerAttribute.CategoryKey, + out (Dictionary> methods, List assemblies) categoryInfo)) { - if (!categoryInfo.assemblies.Contains(assembly)) categoryInfo.assemblies.Add(assembly); + if (!categoryInfo.assemblies.Contains(assembly)) + { + categoryInfo.assemblies.Add(assembly); + } } else { @@ -116,11 +131,13 @@ public void FillControllers(in IEnumerable commandControllers) if (!commands.TryAdd(attribute.Name, [methodInfo])) { - var overloads = commands[attribute.Name]; - if (!overloads.Contains(methodInfo)) overloads.Add(methodInfo); + List overloads = commands[attribute.Name]; + if (!overloads.Contains(methodInfo)) + { + overloads.Add(methodInfo); + } } } - } COMMANDS = commands.ToFrozenDictionary( diff --git a/TgBotFrame.Example/Controllers/WebhookController.cs b/TgBotFrame.Example/Controllers/WebhookController.cs new file mode 100644 index 0000000..f74464c --- /dev/null +++ b/TgBotFrame.Example/Controllers/WebhookController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Telegram.Bot.Types; +using TgBotFrame.Options; +using TgBotFrame.Services; + +namespace TgBotFrame.Example.Controllers; + +[ApiController] +[Route("tg")] +public class WebhookController( + BotService botService, + IOptions options, + ILogger logger, + IWebHostEnvironment webHostEnvironment) : ControllerBase +{ + [HttpGet] + public ActionResult Get() => StatusCode(StatusCodes.Status405MethodNotAllowed); + + [HttpPost] + public async Task Post([FromBody] Update update, CancellationToken ct = default) + { + string? secretValue = options.Value.WebhookSecretToken; + if (secretValue is not null && secretValue != HttpContext.Request.Headers["X-Telegram-Bot-Api-Secret-Token"]) + { + return Unauthorized(); + } + + try + { + await botService.HandleUpdateAsync(update, ct); + } + catch (TaskCanceledException e) + { + logger.LogWarning(e, "Webhook request was cancelled"); + return Empty; + } + catch (Exception e) + { + logger.LogError(e, "Error handling update"); + if (webHostEnvironment.IsDevelopment()) + { + return Problem( + "Error handling update", + null, + StatusCodes.Status500InternalServerError, + e.Message, + e.GetType().Name); + } + + return Problem( + "Error handling update", + null, + StatusCodes.Status500InternalServerError); + } + + return Ok(); + } +} \ No newline at end of file diff --git a/TgBotFrame.Example/Program.cs b/TgBotFrame.Example/Program.cs index 00bac29..fe19a2b 100644 --- a/TgBotFrame.Example/Program.cs +++ b/TgBotFrame.Example/Program.cs @@ -12,9 +12,10 @@ using TgBotFrame.Commands.Start; using TgBotFrame.Example; using TgBotFrame.Injection; +using TgBotFrame.Options; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - +builder.Services.Configure(builder.Configuration.GetSection("TgBotOptions")); builder.Services.AddOpenTelemetry().WithMetrics(providerBuilder => { providerBuilder.AddPrometheusExporter(); @@ -66,6 +67,8 @@ commandsBuilder.TryAddControllers(Assembly.GetEntryAssembly()!); }); +builder.Services.AddControllers(); + builder.Services.AddHealthChecks() .AddUrlGroup(new Uri("https://api.telegram.org/"), HttpMethod.Head) .AddSqlite(sqliteConnectionString); @@ -83,5 +86,5 @@ app.MapPrometheusScrapingEndpoint(); app.UseHealthChecks("/health"); - +app.MapControllers(); await app.RunAsync().ConfigureAwait(false); \ No newline at end of file diff --git a/TgBotFrame.Example/Properties/launchSettings.json b/TgBotFrame.Example/Properties/launchSettings.json index e83301a..4fcbd3a 100644 --- a/TgBotFrame.Example/Properties/launchSettings.json +++ b/TgBotFrame.Example/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "http": { @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "todos", - "applicationUrl": "http://localhost:5222", + "applicationUrl": "http://*:5222", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/TgBotFrame.Example/TgBotFrame.Example.csproj b/TgBotFrame.Example/TgBotFrame.Example.csproj index 92d65f1..86d91a7 100644 --- a/TgBotFrame.Example/TgBotFrame.Example.csproj +++ b/TgBotFrame.Example/TgBotFrame.Example.csproj @@ -12,13 +12,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/TgBotFrame.Example/appsettings.json b/TgBotFrame.Example/appsettings.json index a2ec70a..96297a0 100644 --- a/TgBotFrame.Example/appsettings.json +++ b/TgBotFrame.Example/appsettings.json @@ -20,5 +20,12 @@ "enabled": false } ] + }, + "TgBotOptions": { + "BotMode": "Polling", + "WebhookUrl": "", + "WebhookSecretToken": "", + "WebhookMaxConnections": 30, + "AllowedUpdates": [] } } \ No newline at end of file diff --git a/TgBotFrame/Options/BotMode.cs b/TgBotFrame/Options/BotMode.cs new file mode 100644 index 0000000..5403275 --- /dev/null +++ b/TgBotFrame/Options/BotMode.cs @@ -0,0 +1,7 @@ +namespace TgBotFrame.Options; + +public enum BotMode +{ + Polling, + Webhook, +} \ No newline at end of file diff --git a/TgBotFrame/Options/TgBotOptions.cs b/TgBotFrame/Options/TgBotOptions.cs new file mode 100644 index 0000000..6c0aec1 --- /dev/null +++ b/TgBotFrame/Options/TgBotOptions.cs @@ -0,0 +1,12 @@ +using Telegram.Bot.Types.Enums; + +namespace TgBotFrame.Options; + +public record TgBotOptions +{ + public BotMode BotMode { get; init; } = BotMode.Polling; + public string? WebhookUrl { get; init; } + public string? WebhookSecretToken { get; init; } + public int? WebhookMaxConnections { get; init; } + public UpdateType[]? AllowedUpdates { get; init; } = Enum.GetValues(); +} \ No newline at end of file diff --git a/TgBotFrame/Services/BotService.cs b/TgBotFrame/Services/BotService.cs index eb05c03..6190fe7 100644 --- a/TgBotFrame/Services/BotService.cs +++ b/TgBotFrame/Services/BotService.cs @@ -3,12 +3,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.FeatureManagement; using Microsoft.FeatureManagement.Mvc; using Telegram.Bot; using Telegram.Bot.Polling; using Telegram.Bot.Types.Enums; using TgBotFrame.Middleware; +using TgBotFrame.Options; namespace TgBotFrame.Services; @@ -19,14 +21,15 @@ namespace TgBotFrame.Services; public class BotService( ITelegramBotClient botClient, ILogger logger, + IOptions options, IServiceScopeFactory scopeFactory, FrameMetricsService frameMetricsService) : BackgroundService, IUpdateHandler { - public async Task HandleUpdateAsync(ITelegramBotClient _, Update update, + public Task HandleUpdateAsync(ITelegramBotClient _, Update update, CancellationToken cancellationToken) { logger.LogDebug(@"Update {id} received", update.Id); - await RunMiddleware(update, cancellationToken).ConfigureAwait(false); + return RunMiddleware(update, cancellationToken); } public Task HandleErrorAsync(ITelegramBotClient _, Exception exception, HandleErrorSource source, @@ -50,12 +53,54 @@ public Task HandleErrorAsync(ITelegramBotClient _, Exception exception, HandleEr return Task.CompletedTask; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) => - await botClient.ReceiveAsync(this, new() + public Task HandleUpdateAsync(Update update, + CancellationToken cancellationToken) + { + logger.LogDebug(@"Update {id} received", update.Id); + return RunMiddleware(update, cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + switch (options.Value.BotMode) { - DropPendingUpdates = false, - AllowedUpdates = Enum.GetValues(), - }, stoppingToken).ConfigureAwait(false); + case BotMode.Polling: + await botClient.ReceiveAsync(this, new() + { + DropPendingUpdates = false, + AllowedUpdates = (options.Value.AllowedUpdates?.Length ?? 0) == 0 + ? Enum.GetValues() + : options.Value.AllowedUpdates, + }, stoppingToken).ConfigureAwait(false); + return; + case BotMode.Webhook when options.Value.WebhookUrl is not null: + try + { + await botClient.SetWebhook( + options.Value.WebhookUrl, + null, + null, + options.Value.WebhookMaxConnections, + (options.Value.AllowedUpdates?.Length ?? 0) == 0 + ? Enum.GetValues() + : options.Value.AllowedUpdates, + false, + options.Value.WebhookSecretToken, + CancellationToken.None).ConfigureAwait(false); + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken).ConfigureAwait(false); + } + finally + { + await botClient.DeleteWebhook(false, CancellationToken.None).ConfigureAwait(false); + } + + return; + case BotMode.Webhook: + throw new ArgumentNullException(nameof(options.Value.WebhookUrl)); + default: + throw new ArgumentOutOfRangeException(nameof(options)); + } + } private async Task RunMiddleware(Update update, CancellationToken cancellationToken = default) { @@ -114,7 +159,11 @@ private static async ValueTask IsFeatureEnabled(IVariantFeatureManager fea private static async ValueTask IsFeatureEnabled(IVariantFeatureManager? featureManager, FeatureGateAttribute? attribute) { - if (featureManager is null) return true; + if (featureManager is null) + { + return true; + } + switch (attribute?.RequirementType) { case RequirementType.Any: