diff --git a/Directory.Packages.props b/Directory.Packages.props index 645a896..8a724a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/TgBotFrame.Commands.Help/HelpCommandController.cs b/TgBotFrame.Commands.Help/HelpCommandController.cs index 5c9d105..0c03f90 100644 --- a/TgBotFrame.Commands.Help/HelpCommandController.cs +++ b/TgBotFrame.Commands.Help/HelpCommandController.cs @@ -1,7 +1,7 @@ -using System.Collections.Frozen; -using System.Reflection; +using System.Reflection; using System.Resources; using System.Text; +using Microsoft.FeatureManagement; using Telegram.Bot; using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.ReplyMarkups; @@ -14,7 +14,10 @@ namespace TgBotFrame.Commands.Help; [CommandController(nameof(Help))] -public class HelpCommandController(ITelegramBotClient botClient, CommandExplorerService commandExplorer) +public class HelpCommandController( + ITelegramBotClient botClient, + CommandExplorerService commandExplorer, + IVariantFeatureManager? featureManager = null) : CommandControllerBase { [Command(nameof(Help))] @@ -56,8 +59,9 @@ public async Task HelpList() text.AppendLine(); text.AppendLine(); - IEnumerable buttons = commandExplorer.Commands.Values - .SelectMany(x => x.Keys) + + IAsyncEnumerable buttons = commandExplorer.GetCommands(featureManager) + .SelectMany(x => x.Value.Select(y => y.Key)) .Select(x => x.DeclaringType) .Select(x => (x?.Assembly, x?.GetCustomAttribute()?.CategoryKey)) @@ -92,7 +96,7 @@ await botClient.SendMessage( Context.GetUserId()!, Resources.ResourceManager.GetString(nameof(HelpCommandController_HelpList_Description), Context.GetCultureInfo())!, - replyMarkup: new InlineKeyboardMarkup(buttons) + replyMarkup: new InlineKeyboardMarkup(await buttons.ToArrayAsync()) ).ConfigureAwait(false); } @@ -102,8 +106,8 @@ await botClient.SendMessage( [Command(nameof(HelpCategory))] public async Task HelpCategory(string category) { - if (!commandExplorer.CategoriesCommandsNames.TryGetValue(category, - out (Assembly assembly, FrozenSet commands) commands)) + string[] commands = await commandExplorer.GetCategoryCommandsNames(featureManager, category).ToArrayAsync(); + if (commands.Length == 0) { await botClient.SendMessage( Context.GetUserId()!, @@ -112,16 +116,22 @@ await botClient.SendMessage( return; } - IEnumerable buttons = commands.commands.Select(x => new[] + IEnumerable buttons = commands.Select(x => new[] { InlineKeyboardButton.WithCallbackData(x, $@"/{nameof(HelpCommand)} {x}") }); - string text = (category.Length == 0 - ? Resources.ResourceManager.GetString( + string text; + if (category.Length == 0) + { + text = Resources.ResourceManager.GetString( nameof(HelpCommandController_HelpCategory_NoCategory), - Context.GetCultureInfo()) - : GetResourceManager(commands.assembly)?.GetString( - CATEGORY_DESCRIPTION_PREFIX + category, - Context.GetCultureInfo())) ?? category; + Context.GetCultureInfo()) ?? category; + } + else + { + text = commandExplorer.GetAssembliesForCategory(category).Select(x => GetResourceManager(x) + ?.GetString(CATEGORY_DESCRIPTION_PREFIX + category, + Context.GetCultureInfo())).FirstOrDefault(x => !string.IsNullOrEmpty(x)) ?? category; + } await botClient.SendMessage( Context.GetUserId()!, @@ -132,9 +142,9 @@ await botClient.SendMessage( [Command(nameof(HelpCommand))] public async Task HelpCommand(string command) { - if (!commandExplorer.Commands.TryGetValue(command, - out FrozenDictionary? methods) - || methods.Count == 0) + Dictionary methods = + await commandExplorer.GetCommand(featureManager, command).ToDictionaryAsync(); + if (methods.Count == 0) { await botClient.SendMessage( Context.GetUserId()!, diff --git a/TgBotFrame.Commands/Middleware/CommandRouterMiddleware.cs b/TgBotFrame.Commands/Middleware/CommandRouterMiddleware.cs index 3554d9e..09930eb 100644 --- a/TgBotFrame.Commands/Middleware/CommandRouterMiddleware.cs +++ b/TgBotFrame.Commands/Middleware/CommandRouterMiddleware.cs @@ -1,9 +1,9 @@ -using System.Collections.Frozen; -using System.Globalization; +using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.FeatureManagement; using Telegram.Bot; using TgBotFrame.Commands.Extensions; using TgBotFrame.Commands.Properties; @@ -15,7 +15,8 @@ public sealed class CommandRouterMiddleware( CommandExplorerService commandExplorerService, IServiceScopeFactory scopeFactory, ITelegramBotClient botClient, - ILogger logger) : FrameMiddleware + ILogger logger, + IVariantFeatureManager? featureManager = null) : FrameMiddleware { public const string COMMAND_CONTROLLER_KEY = "CommandController"; public const string COMMAND_METHOD_KEY = "CommandMethod"; @@ -38,8 +39,11 @@ public override async Task InvokeAsync(Update update, FrameContext context, Canc ExceptedName = context.GetBotUsername(); CommandArgumentsRaw = context.GetCommandArgsRaw(); - if (!commandExplorerService.Commands.TryGetValue(CommandKey, - out FrozenDictionary? allMethods) || allMethods.Count == 0) + + Dictionary allMethods = await commandExplorerService + .GetCommand(featureManager, CommandKey).ToDictionaryAsync(cancellationToken: ct); + + if (allMethods.Count == 0) { await SendCommandNotFound(update, context, ct).ConfigureAwait(false); await Next(update, context, ct).ConfigureAwait(false); @@ -180,7 +184,7 @@ await botClient.SendMessage(update.Message!.Chat, } private (MethodInfo?, int invalidArgIndex) GetMethod( - in FrozenDictionary allMethods, out object?[] args) + in IDictionary allMethods, out object?[] args) { args = []; KeyValuePair method; diff --git a/TgBotFrame.Commands/Services/CommandExplorerService.cs b/TgBotFrame.Commands/Services/CommandExplorerService.cs index 9556f63..20ce49a 100644 --- a/TgBotFrame.Commands/Services/CommandExplorerService.cs +++ b/TgBotFrame.Commands/Services/CommandExplorerService.cs @@ -1,23 +1,86 @@ -using System.Collections.Frozen; +using Microsoft.Extensions.Logging; +using Microsoft.FeatureManagement; +using System.Collections.Frozen; using System.Reflection; -using Microsoft.Extensions.Logging; using TgBotFrame.Commands.Attributes; +using TgBotFrame.Services; namespace TgBotFrame.Commands.Services; public sealed class CommandExplorerService(ILogger logger) { - public FrozenDictionary> Commands { get; private set; } = + private FrozenDictionary> COMMANDS = FrozenDictionary>.Empty; + private FrozenDictionary methods, Assembly[] assemblies)> CATEGORIES = + FrozenDictionary methods, Assembly[] assemblies)>.Empty; - public FrozenDictionary)> CategoriesCommandsNames { get; private set; } = - FrozenDictionary)>.Empty; + public async IAsyncEnumerable> GetCommand(IVariantFeatureManager? featureManager, string commandName) + { + if (!COMMANDS.TryGetValue(commandName, out FrozenDictionary? commands)) + { + yield break; + } + + if (featureManager is null) + { + foreach (KeyValuePair method in commands) + { + yield return method; + } + } + else + { + foreach (KeyValuePair method in commands) + { + if (await BotService.IsFeatureEnabled(featureManager, method.Key).ConfigureAwait(false)) + yield return method; + } + } + } + + public async IAsyncEnumerable>>> GetCommands(IVariantFeatureManager? featureManager) + { + foreach (KeyValuePair> commandName in COMMANDS) + { + yield return new(commandName.Key, GetCommand(featureManager, commandName.Key)); + } + } + + + + public IReadOnlyCollection GetAssembliesForCategory(in string category) + { + return CATEGORIES.TryGetValue(category, out (FrozenDictionary methods, Assembly[] assemblies) value) ? value.assemblies : []; + } + + public async IAsyncEnumerable GetCategoryCommandsNames(IVariantFeatureManager? featureManager, string category) + { + if (!CATEGORIES.TryGetValue(category, out (FrozenDictionary methods, Assembly[] assemblies) methods)) + { + yield break; + } + + foreach (KeyValuePair method in methods.methods) + { + foreach (MethodInfo overload in method.Value) + { + if (!await BotService.IsFeatureEnabled(featureManager, overload)) + { + continue; + } + + yield return method.Key; + break; + } + } + } public void FillControllers(in IEnumerable commandControllers) { - Dictionary> controllers = []; - Dictionary> categories = []; - Dictionary assemblies = []; + Dictionary> commands = []; + Dictionary> methods, List assemblies)> categories = + []; + foreach (Type type in commandControllers.Distinct()) { CommandControllerAttribute? controllerAttribute = type.GetCustomAttribute(); @@ -25,12 +88,17 @@ public void FillControllers(in IEnumerable commandControllers) { continue; } + Assembly assembly = type.Assembly; - if (!categories.TryGetValue(controllerAttribute.CategoryKey, out List? categoryMethods)) + + if (categories.TryGetValue(controllerAttribute.CategoryKey, out (Dictionary> methods, List assemblies) categoryInfo)) + { + if (!categoryInfo.assemblies.Contains(assembly)) categoryInfo.assemblies.Add(assembly); + } + else { - categoryMethods = []; - categories.Add(controllerAttribute.CategoryKey, categoryMethods); - assemblies.Add(controllerAttribute.CategoryKey, type.Assembly); + categories.Add(controllerAttribute.CategoryKey, ([], [assembly])); + categoryInfo = categories[controllerAttribute.CategoryKey]; } foreach (MethodInfo methodInfo in type.GetMethods()) @@ -41,32 +109,31 @@ public void FillControllers(in IEnumerable commandControllers) continue; } - string name = attribute.Name; - categoryMethods.Add(name); - if (controllers.TryGetValue(name, out List? methods)) + if (!categoryInfo.methods.TryAdd(attribute.Name, [methodInfo])) { - if (!methods.Contains(methodInfo)) - { - methods.Add(methodInfo); - } + categoryInfo.methods[attribute.Name].Add(methodInfo); } - else + + if (!commands.TryAdd(attribute.Name, [methodInfo])) { - controllers.Add(name, [methodInfo]); + var overloads = commands[attribute.Name]; + if (!overloads.Contains(methodInfo)) overloads.Add(methodInfo); } } + } - Commands = controllers.ToFrozenDictionary( + COMMANDS = commands.ToFrozenDictionary( x => x.Key, x => x.Value.ToFrozenDictionary( y => y, y => y.GetParameters()), StringComparer.OrdinalIgnoreCase); - CategoriesCommandsNames = categories.ToFrozenDictionary(x => x.Key, - x => (assemblies[x.Key], x.Value.Distinct(StringComparer.OrdinalIgnoreCase).ToFrozenSet())); + CATEGORIES = categories.ToFrozenDictionary(x => x.Key, + x => (x.Value.methods.ToFrozenDictionary(y => y.Key, y => y.Value.ToArray()), + x.Value.assemblies.ToArray())); logger.LogInformation(@"{all_count:D} overload(s) from {count:D} command(s) loaded", - Commands.Sum(x => x.Value.Count), Commands.Count); + COMMANDS.Sum(x => x.Value.Count), COMMANDS.Count); } } \ No newline at end of file diff --git a/TgBotFrame.Example/ExtendedHelpController.cs b/TgBotFrame.Example/ExtendedHelpController.cs index 4125424..5201722 100644 --- a/TgBotFrame.Example/ExtendedHelpController.cs +++ b/TgBotFrame.Example/ExtendedHelpController.cs @@ -1,19 +1,25 @@ -using Telegram.Bot; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Mvc; +using Telegram.Bot; using Telegram.Bot.Types.Enums; +using TgBotFrame.Commands; using TgBotFrame.Commands.Attributes; using TgBotFrame.Commands.Extensions; -using TgBotFrame.Commands.Help; using TgBotFrame.Commands.Services; namespace TgBotFrame.Example; [CommandController("Help")] -public class ExtendedHelpController(ITelegramBotClient botClient, CommandExplorerService commandExplorer) - : HelpCommandController(botClient, commandExplorer) +public class ExtendedHelpController( + ITelegramBotClient botClient, + CommandExplorerService commandExplorer, + IVariantFeatureManager? featureManager = null) + : CommandControllerBase //: HelpCommandController(botClient, commandExplorer, featureManager) { private readonly ITelegramBotClient _botClient = botClient; [Command(nameof(EchoDouble))] + [FeatureGate("EchoDoubleFeature")] public async Task EchoDouble(string text) { int? messageId = Context.GetMessageId(); diff --git a/TgBotFrame.Example/Program.cs b/TgBotFrame.Example/Program.cs index 84140a5..00bac29 100644 --- a/TgBotFrame.Example/Program.cs +++ b/TgBotFrame.Example/Program.cs @@ -1,5 +1,6 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; +using Microsoft.FeatureManagement; using OpenTelemetry.Metrics; using Telegram.Bot; using TgBotFrame.Commands.Authorization.Extensions; @@ -39,6 +40,8 @@ const string sqliteConnectionString = "Data Source=../data/sqlite/example.sqlite"; string? tgToken = builder.Configuration.GetConnectionString("Telegram"); +builder.Services.AddFeatureManagement(); + builder.Services.AddTelegramHttpClient(); builder.Services.AddSingleton(provider => { diff --git a/TgBotFrame.Example/appsettings.json b/TgBotFrame.Example/appsettings.json index b19248a..a2ec70a 100644 --- a/TgBotFrame.Example/appsettings.json +++ b/TgBotFrame.Example/appsettings.json @@ -1,16 +1,24 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "ConnectionStrings": { - "Telegram": "" - }, - "RateLimit": { - "Limit": 1, - "Interval": "00:00:01" - }, - "AllowedHosts": "*" +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "Telegram": "" + }, + "RateLimit": { + "Limit": 1, + "Interval": "00:00:01" + }, + "AllowedHosts": "*", + "feature_management": { + "feature_flags": [ + { + "id": "EchoDoubleFeature", + "enabled": false + } + ] + } } \ No newline at end of file diff --git a/TgBotFrame/Services/BotService.cs b/TgBotFrame/Services/BotService.cs index 2923279..eb05c03 100644 --- a/TgBotFrame/Services/BotService.cs +++ b/TgBotFrame/Services/BotService.cs @@ -1,7 +1,10 @@ -using System.Runtime.CompilerServices; +using System.Reflection; +using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Mvc; using Telegram.Bot; using Telegram.Bot.Polling; using Telegram.Bot.Types.Enums; @@ -59,7 +62,27 @@ private async Task RunMiddleware(Update update, CancellationToken cancellationTo frameMetricsService.IncUpdatesHandled(update.Type); AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); await using ConfiguredAsyncDisposable _ = scope.ConfigureAwait(false); - FrameMiddleware[] middlewares = scope.ServiceProvider.GetServices().ToArray(); + + IVariantFeatureManager? featureManager = scope.ServiceProvider.GetService(); + FrameMiddleware[] middlewares; + if (featureManager is null) + { + middlewares = scope.ServiceProvider.GetServices().ToArray(); + } + else + { + List frameMiddlewares = []; + foreach (FrameMiddleware frameMiddleware in scope.ServiceProvider.GetServices()) + { + if (await IsFeatureEnabled(featureManager, frameMiddleware.GetType())) + { + frameMiddlewares.Add(frameMiddleware); + } + } + + middlewares = frameMiddlewares.ToArray(); + } + if (middlewares.Length == 0) { logger.LogWarning(@"There is no registered middlewares, skip update processing"); @@ -77,4 +100,53 @@ private async Task RunMiddleware(Update update, CancellationToken cancellationTo using FrameContext context = new(); await firstMiddleware.InvokeAsync(update, context, cancellationToken).ConfigureAwait(false); } + + private static async ValueTask IsFeatureEnabled(IVariantFeatureManager featureManager, Type type) + { + if (type.GetCustomAttribute() is not { } attribute) + { + return true; + } + + return await IsFeatureEnabled(featureManager, attribute); + } + + private static async ValueTask IsFeatureEnabled(IVariantFeatureManager? featureManager, + FeatureGateAttribute? attribute) + { + if (featureManager is null) return true; + switch (attribute?.RequirementType) + { + case RequirementType.Any: + foreach (string? feature in attribute.Features) + { + bool result = await featureManager.IsEnabledAsync(feature); + if (result) + { + return true; + } + } + + return false; + case RequirementType.All: + foreach (string? feature in attribute.Features) + { + bool result = await featureManager.IsEnabledAsync(feature); + if (!result) + { + return false; + } + } + + return true; + case null: + return true; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public static async Task IsFeatureEnabled(IVariantFeatureManager? featureManager, MethodInfo method) => + await IsFeatureEnabled(featureManager, method.DeclaringType?.GetCustomAttribute()) + && await IsFeatureEnabled(featureManager, method.GetCustomAttribute()); } \ No newline at end of file diff --git a/TgBotFrame/TgBotFrame.csproj b/TgBotFrame/TgBotFrame.csproj index 090d4da..4060219 100644 --- a/TgBotFrame/TgBotFrame.csproj +++ b/TgBotFrame/TgBotFrame.csproj @@ -33,6 +33,7 @@ +