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: