From 69df6f92b95c1d5ee64bf8eb2998efac2549cf91 Mon Sep 17 00:00:00 2001 From: MoryakSPb <31323043+MoryakSPb@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:03:52 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0?= =?UTF-8?q?=20Feature=20Management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 + .../HelpCommandController.cs | 69 ++++++----- .../Middleware/CommandRouterMiddleware.cs | 16 ++- .../Services/CommandExplorerService.cs | 117 ++++++++++++++---- TgBotFrame.Example/ExtendedHelpController.cs | 14 ++- TgBotFrame.Example/Program.cs | 3 + TgBotFrame.Example/appsettings.json | 38 +++--- TgBotFrame/Services/BotService.cs | 75 ++++++++++- TgBotFrame/TgBotFrame.csproj | 1 + 9 files changed, 252 insertions(+), 82 deletions(-) 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 2739af8..7987e67 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,37 +59,37 @@ 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 => (GetResourceManager(x?.Assembly), - x?.GetCustomAttribute()?.CategoryKey)) + .Select(x => x?.GetCustomAttribute()?.CategoryKey) .Distinct() - .Select(x => + .Select(categoryName => { string displayName; - if (string.IsNullOrEmpty(x.CategoryKey)) + if (string.IsNullOrEmpty(categoryName)) { displayName = Resources.ResourceManager.GetString( nameof(HelpCommandController_HelpList_WithoutCategory), Context.GetCultureInfo())!; } - else if (x.Item1 is not null) - { - displayName = x.Item1.GetString(CATEGORY_NAME_PREFIX + x.CategoryKey, - Context.GetCultureInfo()) ?? x.CategoryKey; - } else { - displayName = x.CategoryKey; + string? str = commandExplorer.GetAssembliesForCategory(categoryName) + .Select(y => + GetResourceManager(y)?.GetString(CATEGORY_NAME_PREFIX + categoryName, + Context.GetCultureInfo())) + .FirstOrDefault(y => !string.IsNullOrEmpty(y)); + displayName = str ?? categoryName; } - return (x.Item1, x.CategoryKey, displayName); + return (x: categoryName, displayName); }).OrderBy(x => x.displayName, StringComparer.Create(Context.GetCultureInfo(), true)).Select(x => new[] { InlineKeyboardButton.WithCallbackData( x.displayName, - $@"/{nameof(HelpCategory)} {x.CategoryKey}"), + $@"/{nameof(HelpCategory)} {x.x}"), }); @@ -94,7 +97,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); } @@ -104,8 +107,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()!, @@ -114,16 +117,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()!, @@ -134,9 +143,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..0d969dc 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,52 @@ 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) + { + 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 @@ + From dd6ec7d3edc450d3a4685982f82ad55274500f9e Mon Sep 17 00:00:00 2001 From: MoryakSPb <31323043+MoryakSPb@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:22:21 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BE=D1=82=D1=81=D1=83=D1=82=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B8=D0=B5=20IVariantFeatureManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TgBotFrame/Services/BotService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/TgBotFrame/Services/BotService.cs b/TgBotFrame/Services/BotService.cs index 0d969dc..eb05c03 100644 --- a/TgBotFrame/Services/BotService.cs +++ b/TgBotFrame/Services/BotService.cs @@ -114,6 +114,7 @@ private static async ValueTask IsFeatureEnabled(IVariantFeatureManager fea private static async ValueTask IsFeatureEnabled(IVariantFeatureManager? featureManager, FeatureGateAttribute? attribute) { + if (featureManager is null) return true; switch (attribute?.RequirementType) { case RequirementType.Any: