7/10/2019

Step by Step: Configure Bot to Work in Teams and with Microsoft Graph

This blog post is a step-by-step instruction on how to create a Bot from scratch using Microsoft Bot Framework v4, configure it to work with Microsoft Teams, and authenticate it to make Microsoft Graph requests.
There are a lot of resources around bots, authentication in bots and Microsoft Teams (and I'll list resources at the end of the post). But during my investigation I couldn't find an example or an article that walks through all 3 topics at the same time. And especially "from scratch" which is important if you want to understand the technology and the flow.
So this post is kind of a cheat sheet for myself, and hopefully for others.
All the steps described are for Bot Framework v4 and ASP.NET Core.
Code is available here.
Keep in mind that except of code there are different configurations to be done in Azure and MS Teams.
So, it might worth reading the post.

Table of Contents

  1. Prerequisites
  2. What to Install
  3. Create Empty Bot
  4. Prepare Azure Resources
  5. Set AppId and App Password in Bot's Configuration
  6. Deployment
  7. Test the Bot in Bot Framework Emulator
  8. Connecting the Bot with Microsoft Teams
  9. State and Dialogs
  10. Authentication Time!
  11. Add MS Graph Logic
  12. Next Steps
  13. References
  14. Conclusion

Prerequisites

The bot to be created will be registered and hosted in Azure, work with Microsoft Graph from Microsoft Teams, and implemented using ASP.NET Core...
Saying all that the prerequisites are:
  1. O365 Tenant
  2. Azure Subscription with Azure Bot Service, App Service
  3. Visual Studio

What to Install

  1. Bot Framework v4 SDK Templates for Visual Studio
  2. Azure CLI
  3. Bot Framework Emulator
  4. ngrok

Create Empty Bot

  1. Open Visual Studio to create a new project
  2. Select "Empty Bot (Bot Framework v4)" project type
  3. Type a name for the project, and select a folder. I'll be using TeamsGraphBot name
After these easy steps you already have a working bot that welcomes new users in the conversation with "Hello world!" phrase.
If you start the project (F5 in Visual Studio). You'll see a web page illustrating how to test the bot.


Prepare Azure Resources

Official documentation

Login and Connect to Azure Subscription

  1. Open command prompt
  2. Enter the command below to log in to Azure Portal:
    az login
    
    It will open a browser window, allowing you to log in.
  3. Next, set the subscription to use:
    az account set --subscription "<azure-subscription-id>"
    
    If you are not sure which subscription to use for deploying the bot, you can view the list of subscriptions for your account by using az account list command.

Register Azure AD Application

Next step is to register Azure AD App. It can be done either using Azure CLI as described in the documentation listed above, or using Azure Portal.
For Azure CLI registration use the command below:
az ad app create --display-name "displayName" --password "AtLeastSixteenCharacters_0" --available-to-other-tenants
where
  • displayName is a name for the application,
  • password is a 'client secret'.The password must be at least 16 characters long, contain at least 1 upper or lower case alphabetical character, and contain at least 1 special character
  • available-to-other-tenants defines that the application can be used from any Azure AD tenant. This must be true to enable your bot to work with the Azure Bot Service channels.

If you go with Azure Portal UI, do the next things while registering the App:
  1. When registering the app, select Account in any organizational directory and personal Microsoft accounts (e.g. Skype, Xbox, Outlook.com):
  2. Navigate to Certificates & secrets, create new client secret that never expires, and store it somewhere:

Create Azure Resources

The bot can be deployed in a new or existing Azure resource group. I will use new group here.
To create all bot resources in new resource group:
  1. In command prompt navigate to DeploymentTemplates folder inside the bot project
  2. Run the command below:
    az deployment create --name "<name-of-deployment>" --template-file "template-with-new-rg.json" --location "location-name" --parameters appId="<appId>" appSecret="<appSecret>" botId="<id-or-name-of-bot>" botSku=F0 newAppServicePlanName="<name-of-app-service-plan>" newWebAppName="<name-of-web-app>" groupName="<new-group-name>" groupLocation="<location>" newAppServicePlanLocation="<location>"
    
    where
    • name - Friendly name for the deployment.
    • template-file - The path to the template. We use template-with-new-rg.json file provided in the DeploymentTemplates folder of the project.
    • location - Location. Values from: az account list-locations. For example, "West US", or "Central US".
    • parameters - Provide deployment parameter values. appId and appSecret values are AppId and Client Secret of the Azure AD App from previous step. The botId parameter should be globally unique and is used as the immutable bot ID. It is also used to configure the display name of the bot, which is mutable. botSku is the pricing tier and can be F0 (Free) or S1 (Standard). newAppServicePlanName is the name of App Service Plan. newWebAppName is the name of the Web App you are creating. groupName is the name of the Azure resource group you are creating. groupLocation is the location of the Azure resource group. newAppServicePlanLocation is the location of the App Service Plan.

Set AppId and App Password in Bot's Configuration

After we prepared all the resources, and to be specific - registered new Azure AD App, we need to set new app's client Id and client Secret in the bot's project settings. These parameters are used to authenticate the bot to communicate with Bot Framework service.
  1. In Visual Studio open appsettings.json file:
  2. Enter Azure AD App's client Id as MicrosoftAppId value, and client secret as MicrosoftAppPassword value:

Deployment

The deployment can be done using Azure CLI and steps listed in the Official documentation mentioned above.
But let's in this sample use old good Azure Publishing Profile.
  1. Navigate to Azure Portal, to the newly created Bot Web App. You can go there either from App Service section, or go to the bot's resource group and start from there:
  2. Click on Get publish profile to download Azure Publishing Profile to your machine:
  3. In Visual Studio right click on the bot's project -> Publish:
  4. In the popup click Import Profile... in the bottom left corner:
  5. Change settings if you wish to. For debugging/development purposes we can change Configuration from Release to Debug.
  6. Save the changes and click Publish:
If everything goes right you should see a web page hosted on Azure that looks identical to the one we saw after hitting F5:

The difference is that now our bot is hosted on Azure.

Test the Bot in Bot Framework Emulator

Now we can debug our bot from Bot Framework Emulator.
  1. Start the project (F5 in Visual Studio). You'll see a web page illustrating how to debug the bot.
  2. Copy localhost URL.
  3. Launch Bot Framework Emulator and click on Open Bot button
  4. Enter copied localhost in Bot URL input and hit Connect
After that you'll see communication between emulator and the bot in LOG and as a result - "Hello world!" message from your bot:

You can test Azure-hosted bot in Bot Framework Emulator as well in the same way: use created App Service web URL instead of localhost. The only additional configuration to be done to allow remote testing in configuring the emulator to use ngrok tunelling.
  1. Click on cogwheel in left bottom corner of the emulator:
  2. Provide path to ngrok and click Save. It will allow the emulator to automatically launch ngrok:

Connecting the Bot with Microsoft Teams

Next step is to connect the bot with MS Teams.
It can be easily done using Bot Builder SDK 4 - Microsoft Teams Extensions.

Custom Bot Framework Adapter

Currently we're using default BotFrameworkHttpAdapter available in Bot Framework SDK. You can see that in Startup.cs:
// Create the Bot Framework Adapter.
services.AddSingleton<IBotFrameworkHttpAdapter, BotFrameworkHttpAdapter>();
Let's create custom Bot Framework Adapter. It allows us to take control over such parts of the flow as Middleware (see below) and error handling.
  1. Add AdapterWithErrorHandler to the project
  2. Copy the content of the class from EchoBot template:
    public class AdapterWithErrorHandler : BotFrameworkHttpAdapter
    {
      public AdapterWithErrorHandler(ICredentialProvider credentialProvider, ILogger<BotFrameworkHttpAdapter> logger, ConversationState conversationState = null)
        : base(credentialProvider)
      {
        OnTurnError = async (turnContext, exception) =>
        {
          // Log any leaked exception from the application.
          logger.LogError($"Exception caught : {exception.Message}");
    
          // Send a catch-all apology to the user.
          await turnContext.SendActivityAsync("Sorry, it looks like something went wrong.");
    
          if (conversationState != null)
          {
            try
            {
              // 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.
              await conversationState.DeleteAsync(turnContext);
            }
            catch (Exception e)
            {
              logger.LogError($"Exception caught on attempting to Delete ConversationState : {e.Message}");
            }
          }
        };
      }
    }
    
  3. Let's reference the Adapter in Startup.cs:
    Replace
    // Create the Bot Framework Adapter.
    services.AddSingleton<IBotFrameworkHttpAdapter, BotFrameworkHttpAdapter>();
    
    With
    // Create the Bot Framework Adapter.
    services.AddSingleton<IBotFrameworkHttpAdapter, AdapterWithErrorHandler>();
    

Middleware

Bot to MS Teams connection is based on Middleware concept. Middleware is simply a class that sits between the adapter and your bot logic, added to your adapter's middleware collection during initialization. The SDK allows you to write your own middleware or add middleware created by others. Every activity coming into or out of your bot flows through your middleware.
The adapter processes and directs incoming activities in through the bot middleware pipeline to your bot’s logic and then back out again. As each activity flows in and out of the bot, each piece of middleware can inspect or act upon the activity, both before and after the bot logic runs.
In case of MS Teams there is a TeamsMiddleware implementation that processes activities to add Teams context in TurnState.
So, let's add TeamsMiddleware to our bot implementation.
  1. Add a reference to Microsoft.Bot.Builder.Teams NuGet package. Note: we need version 4.*. And currently the version is in prerelease. That's why you need to check "Include prerelease" checkbox in NuGet Package Manager while searching for the module:
  2. Add TeamsMiddleware usage in AdapterWithErrorHandler constructor:
    Use(new TeamsMiddleware(credentialProvider));
    
    Full code of the constructor:
    public class AdapterWithErrorHandler : BotFrameworkHttpAdapter
    {
      public AdapterWithErrorHandler(ICredentialProvider credentialProvider, ILogger<BotFrameworkHttpAdapter> logger, ConversationState conversationState = null)
        : base(credentialProvider)
      {
        OnTurnError = async (turnContext, exception) =>
        {
          // Log any leaked exception from the application.
          logger.LogError($"Exception caught : {exception.Message}");
    
          // Send a catch-all apology to the user.
          await turnContext.SendActivityAsync("Sorry, it looks like something went wrong.");
    
          if (conversationState != null)
          {
            try
            {
              // 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.
              await conversationState.DeleteAsync(turnContext);
            }
            catch (Exception e)
            {
              logger.LogError($"Exception caught on attempting to Delete ConversationState : {e.Message}");
            }
          }
        };
    
        Use(new TeamsMiddleware(credentialProvider));
      }
    }
    

ITeamsContext

Now we can get Teams context in the bot's turns (events).
Let's add some simple code to verify that the context is presented and we can get information from it.
  1. Go to the bot's code (in this sample - TeamsGraphBot.cs)
  2. Override OnMessageActivityAsync method to react on user's messages
  3. Add code to check for MS Teams context and to display some of the properties:
    protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
    {
      var teamsContext = turnContext.TurnState.Get<ITeamsContext>();
    
      if (teamsContext != null) // the bot is used inside MS Teams
      {
        if (teamsContext.Team != null) // inside team
        {
          await turnContext.SendActivityAsync(MessageFactory.Text($"Team Id: {teamsContext.Team.Id}"), cancellationToken).ConfigureAwait(false);
        }
        else // private or group chat
        {
          await turnContext.SendActivityAsync(MessageFactory.Text($"We're in MS Teams but not in Team"), cancellationToken).ConfigureAwait(false);
        }
      }
      else // outside MS Teams
      {
        await turnContext.SendActivityAsync(MessageFactory.Text("We're not in MS Teams context"), cancellationToken).ConfigureAwait(false);
      }
    }
    
If we call our bot from Bot Emulator, or Web Chat we should see "We're not in MS Teams context" response from the bot.

Publish

Let's publish all the changes to Azure so we could use them later on from MS Teams.

Add Microsoft Teams Channel to the Bot

A channel is a connection between the bot and communication apps. You configure a bot to connect to the channels you want it to be available on. The Bot Framework Service, configured through the Azure portal, connects your bot to these channels and facilitates communication between your bot and the user. Web Chat channel is pre-configured for you.
Now we need to add Microsoft Teams channel to the bot.
  1. Sign in to Azure Portal.
  2. Select the bot that you want to configure.
  3. In the Bot Service blade, click Channels under Bot Management.
  4. Click on Microsoft Teams icon to add MS Teams channel to the bot:

Add Teams App with the Bot

Our bot is ready to work with MS Teams. But MS Teams knows nothing about the bot.
We need to register new app and provide information about our bot. It will allow Microsoft Teams to communicate with the bot.
  1. Go to MS Teams (either desktop or web client)
  2. Open App Studio. If you're not familiar with App Studio you can read more about it here.
  3. Navigate to Manifest Editor tab and click Create a new app
  4. Enter App Details:

    The most important setting in App Details is Short Name. This name will be used to @mention bot in conversations.
  5. Add bot information in Capabilities section:

    where:
    • We need to select Existing bot as we're going to connect to the bot that has been already create.
    • Name - Name of the bot.
    • Bot Id - App ID of the bot's Azure AD Application (created above). Here you can either enter the value manually, or select Select from one of my existing bots if the bot is registered in the Azure AD connected to the current tenant.
    • Select all the scopes you want. I selected all 3 to make the bot available in every conversation type.
    After hitting Save you'll see bot configuration page where you can generate new client secret (will be automatically propagated to Azure AD App), change messaging endpoint if the bot has some non-default configuration, and add commands that will be displayed to a user as a hint when messaging to the bot.
    For this sample we can leave everything as is.
  6. Add token.botframework.com in the list of valid domains in Domains and permissions section:

    It will allow to implement authentication flow later on.
  7. Go to Test and distribute section and click Install
  8. In the popup select Add for you as well as some team in Add to a team or chat and click Install. It will create a private chat with bot and will add the bot to the selected team:

    Now the bot can be tested from the Team:

    And from one-on-one chat:

State and Dialogs

Next step in our journey is to add state and dialogs to the bot.

State

Our bot is just an asp.net core web application. And it is stateless by default, meaning that the bot doesn't know what happened in a previous round of the conversation and can't use this information, or data, for the next action.
But thankfully, we can add state support to our project.
There are 3 different types of state:
  • User state - available in any turn that the bot is conversing with that user on that channel, regardless of the conversation.
  • Conversation state - available in any turn in a specific conversation, regardless of user (i.e. group conversations)
  • Private conversation state - scoped to both the specific conversation and to that specific user
In our case we'll add User state to manage authentication, and Conversation state that is used by Dialogs engine.
  1. As state should be stored somewhere, we need to add storage layer to the bot. For development purposes, we'll be using in-memory storage. But for production Microsoft recommends to use CosmosDB storage implementation. You can also create your own implementation of storage layer if needed.
    So, let's add in-memory storage layer in Startup.cs ConfigureServices:
    // storage
    services.AddSingleton<IStorage, MemoryStorage>();
    
  2. Now we can add states singletons to our bot. It is also done in Startup.cs ConfigureServices:
    // 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>();
    
Currently our ConfigureServices method should look like this:
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
  services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

  // Create the credential provider to be used with the Bot Framework Adapter.
  services.AddSingleton<ICredentialProvider, ConfigurationCredentialProvider>();

  // Create the Bot Framework Adapter.
  services.AddSingleton<IBotFrameworkHttpAdapter, AdapterWithErrorHandler>();

  // storage
  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, EmptyBot>();
}

Dialogs

Dialogs are structures in your bot that act like functions in your bot's program; each dialog is designed to perform a specific task, in a specific order.
You can also think of dialogs like "topics" in the conversation. For example, "how can I help" is the initial topic. User replies to bot's question, and the bot decides what other topic (dialog) to start (create support request, show current weather, etc.).
Please, read official documentation to be familiar with dialog concepts, such as dialog sets, dialog context, dialog result, and different types of dialogs available in the SDK.
Let's add dialogs support to our project and modify the bot to work with dialogs.
  1. First, let's install Microsoft.Bot.Builder.Dialogs NuGet package
  2. Next, create DialogExtensions.cs class. This class appears in many samples and contains Run extension method to correctly start new or continue existing dialog:
    public static class DialogExtensions
    {
      public static async Task Run(this Dialog dialog, ITurnContext turnContext, IStatePropertyAccessor<DialogState> accessor, CancellationToken cancellationToken = default(CancellationToken))
      {
        var dialogSet = new DialogSet(accessor);
        dialogSet.Add(dialog);
    
        var dialogContext = await dialogSet.CreateContextAsync(turnContext, cancellationToken);
    
        var results = await dialogContext.ContinueDialogAsync(cancellationToken);
        if (results.Status == DialogTurnStatus.Empty)
        {
          await dialogContext.BeginDialogAsync(dialog.Id, null, cancellationToken);
        }
      }
    }
    
  3. Next, let's create a generic base bot to work with dialogs. Let's call it DialogBot and add it to the new Bots folder:
    public class DialogBot<T> : ActivityHandler where T : Dialog
    {
      protected readonly BotState ConversationState;
      protected readonly Dialog Dialog;
      protected readonly ILogger Logger;
      protected readonly BotState UserState;
    
      public DialogBot(ConversationState conversationState, UserState userState, T dialog, ILogger<DialogBot<T>> logger)
      {
        ConversationState = conversationState;
        UserState = userState;
        Dialog = dialog;
        Logger = logger;
      }
    
      public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
      {
        if (turnContext?.Activity?.Type == ActivityTypes.Invoke && turnContext.Activity.ChannelId == "msteams")
          await Dialog.Run(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
        else
          await base.OnTurnAsync(turnContext, cancellationToken);
    
        // Save any state changes that might have occurred during the turn.
        await ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
        await UserState.SaveChangesAsync(turnContext, false, cancellationToken);
      }
    
      protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
      {
        Logger.LogInformation("Running dialog with Message Activity.");
    
        // Run the Dialog with the new message Activity.
        await Dialog.Run(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
      }
    }
    
    Basically, this base class provides default implementation for OnTurnAsync and OnMessageActivityAsync to start a dialog with the user.
  4. Now, let's create a new bot that inherits DialogBot and duplicates OnMembersAddedAsync logic of our initial bot.
    public class GraphBot<T> : DialogBot<T> where T : Dialog
    {
      public GraphBot(ConversationState conversationState, UserState userState, T dialog, ILogger<DialogBot<T>> logger)
        : base(conversationState, userState, dialog, logger)
      {
      }
    
      protected override async Task OnMembersAddedAsync(IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken)
      {
        foreach (var member in membersAdded)
        {
          if (member.Id != turnContext.Activity.Recipient.Id)
          {
            await turnContext.SendActivityAsync(MessageFactory.Text($"Hello world!"), cancellationToken).ConfigureAwait(false);
          }
        }
      }
    }
    
  5. Next - add MainDialog implementation that will be an entry point Dialog of our bot, and move our Teams-related logic to it:
    public class MainDialog : ComponentDialog
    {
      protected readonly ILogger _logger;
    
      public MainDialog(ILogger<MainDialog> logger) : base(nameof(MainDialog))
      {
        _logger = logger;
    
        AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[]
        {
          DisplayContextInfoStepAsync
        }));
    
        InitialDialogId = nameof(WaterfallDialog);
    
      }
    
      private async Task<DialogTurnResult> DisplayContextInfoStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
      {
        var teamsContext = stepContext.Context.TurnState.Get<ITeamsContext>();
    
        if (teamsContext != null) // the bot is used inside MS Teams
        {
          if (teamsContext.Team != null) // inside team
          {
            await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Team Id: {teamsContext.Team.Id}"), cancellationToken).ConfigureAwait(false);
          }
          else // private or group chat
          {
            await stepContext.Context.SendActivityAsync(MessageFactory.Text($"We're in MS Teams but not in Team"), cancellationToken).ConfigureAwait(false);
          }
        }
        else // outside MS Teams
        {
          await stepContext.Context.SendActivityAsync(MessageFactory.Text("We're not in MS Teams context"), cancellationToken).ConfigureAwait(false);
        }
    
        return await stepContext.EndDialogAsync();
      }
    }
    
    We inherited the dialog from ComponentDialog. It provides a strategy for creating independent dialogs to handle specific scenarios, breaking a large dialog set into more manageable pieces. Each of these pieces has its own dialog set, and avoids any name collisions with the dialog set that contains it. It's like a reusable component that aggregates multiple controls.
    And if you look at the constructor code - we're adding WaterfallDialog to internal dialog set. A WaterfallDialog is a specific implementation of a dialog that is commonly used to collect information from the user or guide the user through a series of tasks. Steps of the dialog are executed sequentially. In our case right now there is the only step DisplayContextInfoStepAsync to display Teams context info.
    The last interesting part of the constructor is InitialDialogId = nameof(WaterfallDialog);. We can have multiple dialogs inside ComponentDialog, and this line of code shows the SDK what dialog to launch when MainDialog is started.
  6. Now, we can reference MainDialog and new bot in Startup.cs and delete old (initial) bot from the project.
    Replace services.AddTransient<IBot, EmptyBot>(); with
    services.AddSingleton<MainDialog>();
    services.AddTransient<IBot, GraphBot<MainDialog>>();
    
After doing all that we can test our bot in the simulator, deploy it to Azure and test from MS Teams. We should see the same results as before, but now - using Dialogs engine under the hood.

Authentication Time!

Now when state and dialogs are configured, we're ready to implement authentication in the bot, and get access to MS Graph.
There are few different parts in this process as well.

Register Additional Azure AD App for Authentication

We've already registered one app while registering the bot... But, quoting official documentation: Whenever you register a bot in Azure, it gets assigned an Azure AD app. However, this app secures channel-to-bot access. You need an additional AAD app for each application that you want the bot to be able to authenticate on behalf of the user.
So, let's register additional app with needed permissions:
  1. Go to Azure AD -> App Registrations
  2. Select New registration
  3. Enter name. In this sample - DEVTeamsGraphBotAuth
  4. For account types select Accounts in any organizational directory and personal Microsoft accounts (e.g. Skype, Xbox, Outlook.com)
  5. Enter https://token.botframework.com/.auth/web/redirect as Redirect URI
  6. Click Register
  7. Go to Certificates & secrets and generate new Client Secret. Store both App ID and Client Secret for next steps.
  8. Go to API Permissions and add permissions you're planning to use in the bot. In this sample we're going to access Group and Site resources. So the permissions are:
    • Group.Read.All
    • Sites.Read.All
    We can also grant admin consent right away as Group.Read.All permission requires admin consent.

Update Bot Settings with Connection Setting

Next step is to update Bot Settings in Azure - we need to provide a connection setting that reference newly registered Azure AD App. This configuration will be used to request authentication and permissions from the bot.
  1. Navigate to registered Bot Service
  2. Go to Settings -> OAuth Connection Settings -> Add Setting
  3. Enter some name. For example, you can use the same name as for Azure AD App - DEVTeamsGraphBotAuth
  4. For Service Provider select Azure Active Directory v2
  5. Enter stored Client ID and Client Secret of the Azure AD App
  6. For Tenant ID you can either enter your Azure AD ID, or common to be available across tenants
  7. For Scopes we need to enter the same permissions as for API Permissions section in Azure AD App: Group.Read.All and Sites.Read.All. The values should be separated by space
  8. Hit Save
Now you can navigate back to the Connection Setting and click Test Connection. It will initiate the authentication process. And if it's successfull you'll see "Success" page like that:


Add Authentication Step to the Bot

Azure part is done. Now, let's do needed changes in the code to initiate authentication from there.
For that purposes we'll be using OAuthPrompt dialog as a part of our MainDialog flow.
  1. Add new property to appsettings.json - ConnectionName. Value of that property should be the name of the Connection Setting we've just added to the bot. In this sample - DEVTeamsGraphBotAuth
  2. Modify MainDialog constructor to have additional parameter - configuration:
    public MainDialog(ILogger<MainDialog> logger, IConfiguration configuration) : base(nameof(MainDialog))
    
  3. Add OAuthPrompt dialog in MainDialog constructor:
    AddDialog(new OAuthPrompt(
      nameof(OAuthPrompt),
      new OAuthPromptSettings
      {
        ConnectionName = configuration["ConnectionName"],
        Text = "Please login",
        Title = "Login",
        Timeout = 300000
    }));
    
  4. Add prompt step as the first step of the dialog to prompt for login:
    private async Task<DialogTurnResult> PromptStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
      return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken);
    }
    
    And in constructor:
    AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[]
    {
      PromptStepAsync,
      DisplayContextInfoStepAsync
    }));
    
  5. Modify Info step to check for access token:
    private async Task<DialogTurnResult> DisplayContextInfoStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
      if (stepContext.Result != null)
      {
        var tokenResponse = stepContext.Result as TokenResponse;
        if (tokenResponse?.Token != null)
        {
          // same code as before
        }
      }
    
      return await stepContext.EndDialogAsync();
    }
    
    The whole code of the MainDialog class:
    public class MainDialog : ComponentDialog
    {
      protected readonly ILogger _logger;
    
      public MainDialog(ILogger<MainDialog> logger, IConfiguration configuration) : base(nameof(MainDialog))
      {
        _logger = logger;
    
        AddDialog(new OAuthPrompt(
          nameof(OAuthPrompt),
          new OAuthPromptSettings
          {
            ConnectionName = configuration["ConnectionName"],
            Text = "Please login",
            Title = "Login",
            Timeout = 300000
          }));
    
        AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[]
          {
            PromptStepAsync,
            DisplayContextInfoStepAsync
          }));
    
        InitialDialogId = nameof(WaterfallDialog);
    
      }
    
      private async Task<DialogTurnResult> PromptStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
      {
        return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken);
      }
    
      private async Task<DialogTurnResult> DisplayContextInfoStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
      {
        if (stepContext.Result != null)
        {
          var tokenResponse = stepContext.Result as TokenResponse;
    
          if (tokenResponse?.Token != null)
          {
            var teamsContext = stepContext.Context.TurnState.Get<ITeamsContext>();
    
            if (teamsContext != null) // the bot is used inside MS Teams
            {
              if (teamsContext.Team != null) // inside team
              {
                await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Team Id: {teamsContext.Team.Id}"), cancellationToken).ConfigureAwait(false);
              }
              else // private or group chat
              {
                await stepContext.Context.SendActivityAsync(MessageFactory.Text($"We're in MS Teams but not in Team"), cancellationToken).ConfigureAwait(false);
              }
            }
            else // outside MS Teams
            {
              await stepContext.Context.SendActivityAsync(MessageFactory.Text("We're not in MS Teams context"), cancellationToken).ConfigureAwait(false);
            }
          }
        }
    
        return await stepContext.EndDialogAsync();
      }
    }
    
  6. Last thing to do is to add OnTokenResponseEventAsync implementation to the bot itself (you can add it to the base DialogBot). This event will be generated if the token has already been acquired before and there is no need for auth prompt:
    protected override async Task OnTokenResponseEventAsync(ITurnContext<IEventActivity> turnContext, CancellationToken cancellationToken)
    {
      Logger.LogInformation("Running dialog with Token Response Event Activity.");
    
      // Run the Dialog with the new Token Response Event Activity.
      await Dialog.Run(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
    }
    
Now we can publish the bot to the Azure and test it. It worth saying that Bot Emulator works weirdly with OAuthPrompt, so it's better to test bot from Teams:


Add MS Graph Logic

Let's finally add some logic to test that the authentication and Teams context work.
As mentioned before, we'll be getting Group based on the current Team, and some properties of the site associated with the group (team).

Getting Group ID from Teams Context

First step here is to get Group ID that can be used to request data from MS Graph.
If you look at TeamInfo class which is a type of ITeamsContext.Team property, you'll see that it contains Team Name and Id. Nothing else.
To get Group Id we need to perform additional operation included in ITeamsContext - FetchTeamDetailsWithHttpMessagesAsync.
Let's modify our code to get Team details if the conversation is happening inside a Team:
if (teamsContext.Team != null) // inside team
{
  var team = teamsContext.Team;
  var teamDetails = await teamsContext.Operations.FetchTeamDetailsWithHttpMessagesAsync(team.Id);
       
  await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Team Id: {teamsContext.Team.Id}"), cancellationToken).ConfigureAwait(false);
}
Now we have Group Id in teamDetails.Body.AadGroupId.

Requesting needed information from MS Graph

Now, let's implement MS Graph part...
  1. Install Microsoft.Graph NuGet package
  2. Create Graph Client:
    var token = tokenResponse.Token;
    
    var graphClient = new GraphServiceClient(
      new DelegateAuthenticationProvider(
      requestMessage =>
      {
        // Append the access token to the request.
        requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
    
        // Get event times in the current time zone.
        requestMessage.Headers.Add("Prefer", "outlook.timezone=\"" + TimeZoneInfo.Local.Id + "\"");
    
        return Task.CompletedTask;
      })
    );
    
  3. Get information about the site:
    var siteInfo = await graphClient.Groups[teamDetails.Body.AadGroupId].Sites["root"].Request().GetAsync();
    
  4. Display site properties:
    await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Site Id: {siteInfo.Id}, Site Title: {siteInfo.DisplayName}, Site Url: {siteInfo.WebUrl}"), cancellationToken).ConfigureAwait(false);
    
The final code of DisplayContextInfoStepAsync looks like that:
private async Task<DialogTurnResult> DisplayContextInfoStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
  if (stepContext.Result != null)
  {
    var tokenResponse = stepContext.Result as TokenResponse;
    if (tokenResponse?.Token != null)
    {
      var teamsContext = stepContext.Context.TurnState.Get<ITeamsContext>();

      if (teamsContext != null) // the bot is used inside MS Teams
      {
        if (teamsContext.Team != null) // inside team
        {
          var team = teamsContext.Team;
          var teamDetails = await teamsContext.Operations.FetchTeamDetailsWithHttpMessagesAsync(team.Id);
          var token = tokenResponse.Token;

          var graphClient = new GraphServiceClient(
            new DelegateAuthenticationProvider(
              requestMessage =>
              {
                // Append the access token to the request.
                requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);

                // Get event times in the current time zone.
                requestMessage.Headers.Add("Prefer", "outlook.timezone=\"" + TimeZoneInfo.Local.Id + "\"");

                return Task.CompletedTask;
            })
          );

          var siteInfo = await graphClient.Groups[teamDetails.Body.AadGroupId].Sites["root"].Request().GetAsync();

          await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Site Id: {siteInfo.Id}, Site Title: {siteInfo.DisplayName}, Site Url: {siteInfo.WebUrl}"), cancellationToken).ConfigureAwait(false);
        }
        else // private or group chat
        {
          await stepContext.Context.SendActivityAsync(MessageFactory.Text($"We're in MS Teams but not in Team"), cancellationToken).ConfigureAwait(false);
        }
      }
      else // outside MS Teams
      {
        await stepContext.Context.SendActivityAsync(MessageFactory.Text("We're not in MS Teams context"), cancellationToken).ConfigureAwait(false);
      }
    }
  }

  return await stepContext.EndDialogAsync();
}
Now we can publish the code again and test in from a Team in Microsoft Teams. The result should be like that:


Next Steps

Now when we have the basement, we can improve our bot by adding different commands, routing between dialogs, displaying Adaptive Cards, etc.

References

Here are helpful links that were used to come up with this step-by-step guidance:


Conclusion

Connecting together bot, Microsoft Teams, and Microsoft Graph is not a rocket science. But it takes time and consists of pretty big number of steps, including configurations as well as coding.
Hopefully, this post will help to proceed with all the steps in correct order with ease.

That's all for today!
Have fun!

2 comments: