Quote of the Day

more Quotes

Categories

Get notified of new posts

Buy me coffee

  • Home>
  • C#>

Supporting Multiple Microsoft Teams Bots in One ASP.NET Core Application

Published December 26, 2024 in .NET , Architecture , ASP.NET core , C# , Software Development - 0 Comments

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.

Teams Bot1 Bot2 Bot3 Unified ASP.NET Core App LLM / Data

Architecture overview

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.

Configuration setup

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.

Dependency configuration

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");
        };
    }
}

Tie in controllers

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");
         }
     }
 }

Final thoughts

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.

References

Microsoft Bot Framework

How bots work

Authentication with the bot connector API

Options pattern in ASP.NET Core | Microsoft Learn

No comments yet