Since the advent of Large Language Models (LLMs), we have been leveraging the technology to streamline processes and improve staff efficiency. One of the things LLMs are good at is coming up with coherent text based on a given context. Leveraging this strength, we have applied the Retrieval Augmented Generation (RAG) pattern to build chatbots that help staff across different areas, including human resources, contract management, and other department-specific procedures. For the user interface of the chatbots, we use Microsoft Teams, as it’s our primary communication platform. The bots’ functionalities are quite similar in nature, in the sense that they all pull data from a data store by calling APIs, sending the user’s chat message, and replying to messages in Microsoft Teams. However, because each bot requires a different category of data for use as the LLM’s context, we need a separate Microsoft Teams app for each bot. Behind the scenes, the Teams app calls the corresponding web API, which is an ASP.NET Core application, to send and receive messages. The ASP.NET Core applications are very similar in nature, in that they all call APIs to get the LLM’s response for a user’s request. As such, we were thinking of ways to minimize code duplication and the infrastructure needed to support multiple bots. In the web API, we follow a clean architecture with infrastructure, a shared kernel, a domain layer, and other typical layers. One approach I thought of was that we could reuse all the layers except for the web API. We would have one web API project for each bot we want to support. However, that approach still requires separate infrastructure for each bot—things like Key Vault, App Insights, Blob Storage, etc.—since each bot is a separate app that serves a different domain. Luckily, after some back-and-forth discussions with GitHub Copilot and reading sample code, I tested and found a way to support multiple bots using the same codebase and infrastructure.
To accomplish the goal of supporting multiple bots via the same ASP.NET Core web API, the idea is to have different configurations for each bot. Essentially, when a bot calls the web app through the Teams client, the web app needs to identify the bot, so it knows which credentials to use to authenticate and send messages back via the Teams client. Therefore, for each bot that the app supports, we still need to register a corresponding application in Entra ID.
We can identify the bot based on the HTTP request path. Essentially, we can set up a separate controller for each bot. In the controller, we can inject the appropriate dependencies, which in turn will use the right credentials and configurations for that bot. In the app, I utilize the factory pattern so I can conditionally inject the concrete dependencies based on the HTTP request path.
The code snippets below show an example of how the appsettings
file looks for multiple bot configurations.
"Bot1": { "AuthSettings": { "MicrosoftAppId": "********-****-****-****-****6c70ed1a", "MicrosoftAppPassword": "secret", "MicrosoftAppType": "SingleTenant" "MicrosoftAppTenantId": "********-****-****-****-****395ed452", "MicrosoftAppType": "SingleTenant" } }, "Bot2": { "AuthSettings": { "MicrosoftAppId": "********-****-****-****-02bc9300594a", "MicrosoftAppPassword": "secret", "MicrosoftAppType": "SingleTenant" "MicrosoftAppTenantId": "********-****-****-****-****395ed452", "MicrosoftAppType": "SingleTenant" } }
The above code snippets show an example of how you can configure the credentials for the app to authenticate and send messages for each bot. If you have taken a look at an example of building a Teams bot application, then you know the configurations that start with MicrosoftApp
are typical. The difference is that in my app, I group them under specific keys to distinguish the different credentials for communicating with the bots. It’s then a matter of pulling the appropriate configuration at runtime, which you can accomplish using the factory pattern.
In a one-app-supporting-one-bot scenario, typically you would wire in the concrete types as shown in the code snippets below:
var azureAdOptions = configuration.GetSection(AppSettingNames.AzureAd).Get<AzureAdOptions>(); configuration[AppSettingNames.MicrosoftAppId] = azureAdOptions!.ClientId; configuration[AppSettingNames.MicrosoftAppPassword] = azureAdOptions.ClientSecret; configuration[AppSettingNames.MicrosoftAppTenantId] = azureAdOptions.TenantId; configuration[AppSettingNames.MicrosoftAppType] = MicrosoftAppType.SingleTenant; services.AddSingleton<BotFrameworkAuthentication, ConfigurationBotFrameworkAuthentication>(); // Create the Bot Framework Adapter with error handling enabled. services.AddSingleton<IBotFrameworkHttpAdapter, AdapterWithErrorHandler>(); // Create the storage we'll be using for User and Conversation state. (Memory is great for testing purposes.) services.AddSingleton<IStorage, MemoryStorage>(); // Create the User state. (Used in this bot's Dialog implementation.) services.AddSingleton<UserState>(); // Create the Conversation state. (Used by the Dialog system itself.) services.AddSingleton<ConversationState>(); // Create the bot as a transient. In this case the ASP Controller is expecting an IBot. services.AddTransient<IBot, DialogBot<MainDialog>>(); // other configs omitted for brevity.
When you only need to support one bot, it’s straightforward to set the credentials such as MicrosoftAppId
and MicrosoftAppPassword
for Microsoft Bot Framework to use, and wire in the ConfigurationBotFrameworkAuthentication
as a concrete type:
services.AddSingleton<BotFrameworkAuthentication, ConfigurationBotFrameworkAuthentication>();
In the multi-bot scenario, the trick is to create an instance of ConfigurationBotFrameworkAuthentication
and pass in a memory instance of Configuration
, which can dynamically hold the authentication configurations based on the request path. The code snippets below demonstrate configuring multiple BotAuthenticationSettings
and wiring factory classes that in turn dynamically create concrete types based on HTTP request paths.
services.Configure<BotAuthenticationSettings>("Bot1", configuration.GetSection("Bot1")); services.Configure<BotAuthenticationSettings>("Bot2", configuration.GetSection("Bot2")); services.AddScoped<BotAuthenticationFactory>(); services.AddScoped<CloudAdapterFactory>(); services.AddScoped<WelcomeMessageFactory>(); // Create the User state. (Used in this bot's Dialog implementation.) services.AddScoped<UserState>(); // Create the Conversation state. (Used by the Dialog system itself.) services.AddSingleton<ConversationState>(); // The Dialog that will be run by the bot. services.AddScoped<MainDialog>(); // Create the bot as a transient. In this case the ASP Controller is expecting an IBot. services.AddTransient<IBot, DialogBot<MainDialog>>();
In the above code snippets, notice that the factory classes are wired as scoped instances, which is appropriate because we want to evaluate the dependency for each request to the app.
Let’s take a look at the BotAuthenticationFactory
class to see how to inject a specific authentication configuration based on the HTTP path.
public class BotAuthenticationFactory(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) { private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; private readonly IConfiguration _configuration = configuration; public ConfigurationBotFrameworkAuthentication CreateBotAuthentication() { var botConfigKey = GetBotConfigKeyFromPath(); var botSettings = _configuration.GetSection(botConfigKey).Get<BotAuthenticationSettings>(); var memoryConfiguration = new ConfigurationBuilder().AddInMemoryCollection().Build(); memoryConfiguration[AppSettingNames.MicrosoftAppId] = botSettings!.MicrosoftAppId; memoryConfiguration[AppSettingNames.MicrosoftAppPassword] = botSettings.MicrosoftAppPassword; memoryConfiguration[AppSettingNames.MicrosoftAppTenantId] = botSettings.MicrosoftAppTenantId; memoryConfiguration[AppSettingNames.MicrosoftAppType] = botSettings.MicrosoftAppType; return new ConfigurationBotFrameworkAuthentication(configuration: memoryConfiguration); } private string GetBotConfigKeyFromPath() { var path = _httpContextAccessor.HttpContext?.Request.Path.Value; if (string.IsNullOrEmpty(path)) { throw new InvalidOperationException("Request path is not available."); } if (path.Contains("Bot1", StringComparison.OrdinalIgnoreCase)) { return "Bot1"; } if (path.Contains("Bot2") { return "Bot2"; } throw new InvalidOperationException("Unknown bot type"); } }
The BotAuthenticationSettings
is just a POCO that represents a bot’s authentication configuration. In the above code snippets, notice the use of named options to pull in the specific bot configurations.
public record BotAuthenticationSettings { public required string MicrosoftAppId { get; set; } public required string MicrosoftAppPassword { get; set; } public required string MicrosoftAppTenantId { get; set; } public required string MicrosoftAppType { get; set; } }
Now that you have seen how to dynamically inject authentication configurations for each bot based on the HTTP path, let’s look at CloudAdapterFactory
to see how to configure a CloudAdapter
that uses BotAuthenticationFactory
for specific authentication configurations.
public class CloudAdapterFactory(IServiceProvider services) { private readonly IServiceProvider _services = services; public CloudAdapter CreateCloudAdapter() { var botAuthenticationFactory = _services.GetRequiredService<BotAuthenticationFactory>(); var logger = _services.GetRequiredService<ILogger<AdapterWithErrorHandler>>(); var configuration = _services.GetRequiredService<IConfiguration>(); var storage = _services.GetRequiredService<IStorage>(); var conversationState = _services.GetRequiredService<ConversationState>(); var telemetryInitializerMiddleware = _services.GetRequiredService<TelemetryInitializerMiddleware>(); var auth = botAuthenticationFactory.CreateBotAuthentication(); return new AdapterWithErrorHandler(configuration, auth, logger, storage, conversationState, telemetryInitializerMiddleware); } }
As you see in the above snippets, the solution is to create an instance of AdapterWithErrorHandler
and pass in the custom ConfigurationBotFrameworkAuthentication
instance via the BotAuthenticationFactory
.
public class AdapterWithErrorHandler : CloudAdapter { public AdapterWithErrorHandler(IConfiguration configuration, BotFrameworkAuthentication auth, ILogger<AdapterWithErrorHandler> logger, IStorage storage, ConversationState conversationState, TelemetryInitializerMiddleware telemetryInitializerMiddleware) : base(auth, logger) { Use(telemetryInitializerMiddleware); Use(new TeamsSSOTokenExchangeMiddleware(storage, configuration["ConnectionName"])); OnTurnError = async (turnContext, exception) => { // Log any leaked exception from the application. // NOTE: In production environment, you should consider logging this to // Azure Application Insights. Visit https://aka.ms/bottelemetry to see how // to add telemetry capture to your bot. logger.LogError(exception, "[OnTurnError] unhandled error : {EceptionMessage}", args: exception.Message); // Send a message to the user await turnContext.SendActivityAsync($"The bot encountered an error or bug. Exception Caught: {exception.Message}"); if (conversationState != null) { // Delete the conversationState for the current conversation // to prevent the bot from getting stuck in a error-loop caused // by being in a bad state. // ConversationState should be thought of as similar to // "cookie-state" in a Web pages. try { await conversationState.DeleteAsync(turnContext); } catch (Exception e) { logger.LogError(exception: e, message: "Exception caught on attempting to Delete ConversationState : {ExceptionMessage}", args: e.Message); } } // Send a trace activity, which will be displayed in the Bot Framework Emulator await turnContext.TraceActivityAsync("OnTurnError Trace", exception.Message, "https://www.botframework.com/schemas/error", "TurnError"); }; } }
The last step is to inject the CloudAdapterFactory
into the controllers that handle incoming messages from the different Teams bots, as shown in the code snippets below.
// This ASP Controller is created to handle a request. Dependency Injection will provide the Adapter and IBot // implementation at runtime. Multiple different IBot implementations running at different endpoints can be // achieved by specifying a more specific type for the bot constructor argument. [Route($"api/messages/bot1")] [ApiController] public class Bot1Controller(CloudAdapterFactory adapterFactory, IBot bot, ILogger<Bot1Controller> logger) : ControllerBase { private readonly CloudAdapter Adapter = adapterFactory.CreateCloudAdapter(); private readonly IBot Bot = bot; private readonly ILogger<Bot1Controller> _logger = logger; [HttpPost] public async Task PostAsync() { // Delegate the processing of the HTTP POST to the adapter. // The adapter will invoke the bot. try { _logger.LogInformation("Processing request"); await Adapter.ProcessAsync(Request, Response, Bot); } catch (Exception e) { _logger.LogError(exception: e, message: "Error processing request"); } } } [Route($"api/messages/bot2")] [ApiController] public class Bot2Controller(CloudAdapterFactory adapterFactory, IBot bot, ILogger<Bot2Controller> logger) : ControllerBase { private readonly IBotFrameworkHttpAdapter Adapter = adapterFactory.CreateCloudAdapter(); private readonly IBot Bot = bot; private readonly ILogger<Bot2Controller> _logger = logger; [HttpPost] public async Task PostAsync() { // Delegate the processing of the HTTP POST to the adapter. // The adapter will invoke the bot. try { _logger.LogInformation("Processing request"); await Adapter.ProcessAsync(Request, Response, Bot); } catch (Exception e) { _logger.LogError(exception: e, message: "Error processing request"); } } }
In this post, I have shown how to dynamically support multiple Teams bots in the same ASP.NET Core application. I am glad I got it to work as expected because this pattern saves us from having to duplicate and maintain multiple codebases as well as infrastructure. However, as with any architecture decision, it’s important to consider the pros and cons, and business context. For us, this pattern is appropriate since the bots are similar in terms of functionalities. However, the downside of supporting multiple bots is that you run a risk of impacting multiple users across different domains.
That’s all for now. I hope you find this post helpful. Please feel free to share your thoughts and ask any questions you may have.
Understanding Message Flow in Microsoft Teams Bot Development.
Enhancing ASP.NET Core/Blazor App Security and Reusability with HttpMessageHandler and Named HttpClient
Building a fully multitenant system using Microsoft Identity Framework and SQL Row Level Security
Analyzing a rental property through automation
Web scraping in C# using HtmlAgilityPack
Building multitenant application – Part 2: Storing value into database session context from ASP.NET core web API
Build and deploy a WebJob alongside web app using azure pipelines
Authenticate against azure ad using certificate in a client credentials flow