May 19, 2020

3960 words 19 mins read

Bot Framework Teams Messaging Extensions Walkthrough

Bot Framework Teams Messaging Extensions Walkthrough

Contents

The Starting Point

I was previously dealing quite extensively with the topic Bot Framework & Microsoft Teams, as Teams is becoming THE communication tool in the modern workplace. Myself, as well as we (the company I work for) have build quite a lot of bots, which are not targeting Teams exclusively but other channels like Web Chat or others as well. But I did a little project during the homeoffice time due to Covid-19 to finally build our very own bot which should serve me and my co-workers in our daily jobs. As we are quite dependent on Teams and do all of our calls, meetings and project work in Teams, I built a bot which is only targeting Teams at the moment. And this bot in it’s first stage has 2 main use cases included:

  • QnA
  • Task management

The QnA part is rather simple as it is utilizing LUIS & QnA Maker to answer questions stored in a knowledge base. But for the task management part, I wanted to do something quite new on the platform: Teams Messaging Extensions. And those components are pretty useful in many use cases, may it be as a search based extension to grab stock images somewhere, or an action based extension to handle specific actions with it. And myself and many of my co-workers are dependent on a solid task management solution, I decided to “modernize” how can manage tasks right within a conversation. The awesome thing here is that with the solution shown in this post, we avoid the context-switch, meaning users do not need to go to another tool during a long running Teams conversation to note down a task they need to work on. All that happens now right from the conversation. So the following post describes how to setup your bot in a way to use a messaging extension to fulfill task management based on Microsoft To Do and Planner (but of course you can take the code and exhange the task management tools to your preferred solution).

The code is published in the Bot Builder Community GitHub repo and can be found here (feel free to use or contribute)

Create Azure Resources

This post will only describe the process of having a local bot running and a Bot Channels Registration which we will use to connect our Teams bot to our local running bot using ngrok (More infos on how to setup ngrok for local bot development can be found here).

Azure AD App Registration

The first thing we need is to setup a new Azure AD app registration, as we want to use the Microsoft Graph to handle the task management processing. Therefore we need to go over to our Azure portal and create a new Azure AD App registration (like shown here):

While creating your app registration you need to provied the url https://token.botframework.com/.auth/web/redirect as a redirect URI to establish a conncetion to the Bot Framework for grabbing your authentication token.

Next up, we need to add some API permissions to our app to make sure we can use the Graph to perform certain tasks (don’t forget to grant admin consent for those permissions after adding them):

Azure Bot Channels Registration

After the Azure AD App Registration has been created, we can create a new Bot Channels Registration in the Azure portal:

After it has been created, we need to add our previously created app registration to the OAuth Connection Settings of our bot:

From here you need to provide the following details:

After saving you can validate your service provider connection setting to see if you can connect to your AAD app registration and get a token from there:

The last thing we need to grab is the Microsoft App ID and App secret for the Bot Channels Registration which can be found from the Azure AD App Registration pane as well.

Create the Bot

As I would only like to demo the Teams bits in this post, I will create everything in an echo bot template, but you can of course use it in our existing bots or go with any other BF template. The first thing we need to do after the bot has been created in VS or VS Code is to add our MicrosoftAppId and MicrosoftAppSecret in the appsettings.json of our bot as well as the , tenantId and the serviceUrl we will use from our bot:

Next up we need to add some NuGet packages to our bot which are used in this demo:

  • Microsoft.Bot.Builder
  • AdpativeCards
  • Microsoft.Graph
  • Microsoft.Graph.Beta

So more or less you’ll end up having the following NuGet packages added to your bot:

What we now need is to tweak our project file a bit, because we are using the Microsoft.Graph and the Microsoft.Graph.Beta NuGet packages, as the To Do implementation is still only available from the beta endpoints. So you would need to add the following lines to our csproj file to make both work at the same time:

<Target Name="ChangeAliasesOfStrongNameAssemblies" BeforeTargets="FindReferenceAssembliesForReferences;ResolveReferences">
    <ItemGroup>
        <ReferencePath Condition="'%(FileName)' == 'Microsoft.Graph.Beta'">
        <Aliases>BetaLib</Aliases>
        </ReferencePath>
    </ItemGroup>
</Target>

and:

<PackageReference Include="Microsoft.Graph.Beta" Version="0.18.0-preview">
    <SpecificVersion>False</SpecificVersion>
    <HintPath>Resources\Microsoft.Graph.Beta.dll</HintPath>
    <Aliases>BetaLib</Aliases>
</PackageReference>

Implement Graph Client

After setting up the bot locally, we need to add the Microsoft Graph functionality to it. The easiest way of doing that is to create a new class called SimpleGraphClient.cs which contains the following code:

extern alias BetaLib;
using System;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Beta = BetaLib.Microsoft.Graph;
using Microsoft.Graph;
namespace SBI_TeamsBot.Services
{
    public class SimpleGraphClient
    {
        private readonly string _token;
        public SimpleGraphClient(string token)
        {
            if (string.IsNullOrWhiteSpace(token))
            {
                throw new ArgumentNullException(nameof(token));
            }
            _token = token;
        }
        public async Task<Beta.OutlookTask> GetTaskAsync()
        {
            var graphClient = GetAuthenticatedClient();
            var tasks = await graphClient.Me.Outlook.Tasks.Request().GetAsync();
            return tasks[0];
        }
        public async Task<Beta.IPlannerUserFavoritePlansCollectionWithReferencesPage> GetFavoritePlans()
        {
            var graphClient = GetAuthenticatedClient();
            var plans = await graphClient.Me.Planner.FavoritePlans.Request().GetAsync();
            return plans;
        }
        public async Task<Beta.IPlannerGroupPlansCollectionPage> GetCurrentPlan(string groupId)
        {
            var graphClient = GetAuthenticatedClient();
            var plans = await graphClient.Groups[groupId].Planner.Plans.Request().GetAsync();
            return plans;
        }
        public async Task<Beta.PlannerTask> CreatePlannerTaskAsync(string planId, string subject, string dueDate, string startTime, string userId)
        {
            var graphClient = GetAuthenticatedClient();
            var assignments = new Beta.PlannerAssignments();
            assignments.AddAssignee(userId);
            var plannerTask = new Beta.PlannerTask
            {
                DueDateTime = DateTimeOffset.Parse(dueDate),
                StartDateTime = DateTimeOffset.Parse(startTime),
                Title = subject,
                PlanId = planId,
                Assignments = assignments
            };
            var plans = await graphClient.Planner.Tasks.Request().AddAsync(plannerTask);
            return plans;
        }
        public async Task<Beta.OutlookTask> CreateTaskAsync(string subject, string dueDate, string startTime, Beta.ItemBody body)
        {
            var graphClient = GetAuthenticatedClient();
            var outlookTask = new Beta.OutlookTask
            {
                Subject = subject,
                StartDateTime = new Beta.DateTimeTimeZone
                {
                    DateTime = startTime,
                    TimeZone = "Eastern Standard Time"
                },
                DueDateTime = new Beta.DateTimeTimeZone
                {
                    DateTime = dueDate,
                    TimeZone = "Eastern Standard Time"
                },
                Body = body
            };
            var res = await graphClient.Me.Outlook.Tasks.Request().Header("Prefer", "outlook.timezone=\"Pacific Standard Time\"").AddAsync(outlookTask);
            return res;
        }
        // Get an Authenticated Microsoft Graph client using the token issued to the user.
        private Beta.GraphServiceClient GetAuthenticatedClient()
        {
            var graphClient = new Beta.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;
                    }));
            return graphClient;
        }
    }
}

This class will allow us to use the Microsoft Graph SDK to perform certain operations instead of using HTTP clients to perform those operations.

Implement Adaptive Cards Helper

As we want to provide a messaging extension which will dynamically fetch data using an Adaptive Card, we need to provide two additional classes to fulill that:

  • AdaptiveCardData.cs
    • This class will act as our Adaptive Card data model
  • AdaptiveCardHelper.cs
    • This class will help us generate the Adaptive Card based on the current context

The AdaptiveCardData.cs will look as follows:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SBI_TeamsBot.Models
{
    public class AdaptiveCardData
    {
        public AdaptiveCardData()
        {
            MultiSelect = "false";
        }
        public string SubmitLocation { get; set; }
        public string Question { get; set; }
        public string MultiSelect { get; set; }
        public string Option1 { get; set; }
        public string Option2 { get; set; }
        public string Option3 { get; set; }
        public string Option4 { get; set; }
        public string Option1Value { get; set; }
        public string Option2Value { get; set; }
        public string Option3Value { get; set; }
        public string Option4Value { get; set; }
    }
}

The AdaptiveCardHelper.cs will look as follows:

using System.Collections.Generic;
using AdaptiveCards;
using SBI_TeamsBot.Models;
using Newtonsoft.Json.Linq;

namespace SBI_TeamsBot.Services
{
    public static class AdaptiveCardHelper
    {
        public static AdaptiveCardData CreateExampleData(AdaptiveCard adaptiveCard)
        {
            string userText = (adaptiveCard.Body[1] as AdaptiveTextBlock).Text;
            var choiceSet = adaptiveCard.Body[3] as AdaptiveChoiceSetInput;

            return new AdaptiveCardData
            {
                Question = userText,
                MultiSelect = choiceSet.IsMultiSelect ? "true" : "false",
                Option1 = choiceSet.Choices[0].Title,
                Option2 = choiceSet.Choices[1].Title,
                Option3 = choiceSet.Choices[2].Title,
            };
        }
        public static AdaptiveCard CreateAdaptiveCardEditor(AdaptiveCardData exampleData)
        {
            var cardData = exampleData ?? new AdaptiveCardData();

            return new AdaptiveCard("1.0")
            {
                Body = new List<AdaptiveElement>
                {
                    new AdaptiveTextBlock("Task creation")
                    {
                        Weight = AdaptiveTextWeight.Bolder,
                    },
                    new AdaptiveTextBlock("Which type of task would you like to create?"),
                    new AdaptiveTextBlock() { Id = "TitleText", Text = "Title", IsVisible = false },
                    new AdaptiveTextInput() { Id = "Title", Placeholder = "Title", IsVisible = false, IsRequired = true},
                    new AdaptiveDateInput() { Id = "StartDate", IsVisible = false, IsRequired = true, Placeholder = "Start date"},
                    new AdaptiveDateInput() { Id = "DueDate", IsVisible = false, IsRequired = true, Placeholder = "Due date"},
                    new AdaptiveChoiceSetInput
                    {
                        Type = AdaptiveChoiceSetInput.TypeName,
                        Id = "Choices",
                        IsVisible = false,
                        IsMultiSelect = bool.Parse(exampleData.MultiSelect),
                        Value = "Choose a plan",
                        Choices = new List<AdaptiveChoice>
                        {
                            new AdaptiveChoice() { Title = exampleData.Option1, Value = exampleData.Option1Value },
                            new AdaptiveChoice() { Title = exampleData.Option2, Value = exampleData.Option2Value },
                            new AdaptiveChoice() { Title = exampleData.Option3, Value = exampleData.Option3Value },
                            new AdaptiveChoice() { Title = exampleData.Option4, Value = exampleData.Option4Value },
                        },
                    },
                    new AdaptiveActionSet()
                    {
                        Id = "SubmitTodoAction",
                        IsVisible = false,
                        Actions = new List<AdaptiveAction>
                        {
                            new AdaptiveSubmitAction
                            {
                                Type = AdaptiveSubmitAction.TypeName,
                                Title = "Create ToDo task",
                                Id = "SubmitTodo",
                                Data = new JObject { { "Type", "todo" } },
                            },
                        },
                    },
                    new AdaptiveActionSet()
                    {
                        Id = "SubmitPlannerAction",
                        IsVisible = false,
                        Actions = new List<AdaptiveAction>
                        {
                            new AdaptiveSubmitAction
                            {
                                Type = AdaptiveSubmitAction.TypeName,
                                Title = "Create Planner task",
                                Id = "SubmitPlanner",
                                Data = new JObject { { "Type", "planner" } },
                            },
                        },
                    }
                },
                Actions = new List<AdaptiveAction>
                {
                    new AdaptiveToggleVisibilityAction
                    {
                        Type = AdaptiveToggleVisibilityAction.TypeName,
                        Title = "Todo Task",
                        TargetElements = new List<AdaptiveTargetElement>
                        {
                            new AdaptiveTargetElement(){ElementId = "TitleText", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "Title", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "StartDate", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "DueDate", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "SubmitTodoAction", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "Choices", IsVisible = false},
                            new AdaptiveTargetElement(){ElementId = "SubmitPlannerAction", IsVisible = false}
                        }
                    },
                    new AdaptiveToggleVisibilityAction
                    {
                        Type = AdaptiveToggleVisibilityAction.TypeName,
                        Title = "Planner Task",
                        TargetElements = new List<AdaptiveTargetElement>
                        {
                            new AdaptiveTargetElement(){ElementId = "TitleText", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "Title", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "StartDate", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "DueDate", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "Choices", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "SubmitPlannerAction", IsVisible = true},
                            new AdaptiveTargetElement(){ElementId = "SubmitTodoAction", IsVisible = false},
                        }
                    },
                },
            };
        }
    }
}

Those two classes will be used in the next step within our EchoBot.cs to generate and visualize the Adaptive Card in our messaging extension.

Implement Teams Functionality

The last piece of bot logic we need to add deals with the messaging extension functionality. Therefore we need to add the following lines to our EchoBot.cs to make that work:

First of all we need to change the EchoBot type from an ActivityHandler to a TeamsActivityHandler and create some constants and add the constructor as follows:

public class EchoBot : TeamsActivityHandler
    {
        readonly string _connectionName;
        private string plannerGroupId;
        public static string botClientID;
        public static string botClientSecret;
        private string tenantId;
        private string serviceUrl;

        public EchoBot(IConfiguration configuration)
        {
            _connectionName = configuration["ConnectionNameGraph"] ?? throw new NullReferenceException("ConnectionNameGraph");
            botClientID = configuration["MicrosoftAppId"];
            botClientSecret = configuration["MicrosoftAppPassword"];
            tenantId = configuration["tenantId"];
            serviceUrl = configuration["serviceUrl"];
        }

Next we need to override the OnTeamsSigninVerifyStateAsync method to make sure authentication works in our messaging exentsion:

protected override async Task OnTeamsSigninVerifyStateAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
    {
        Logger.LogInformation("Running dialog with signin/verifystate from an Invoke Activity.");
        // The OAuth Prompt needs to see the Invoke Activity in order to complete the login process.
        // Run the Dialog with the new Invoke Activity.
        await Dialog.RunAsync(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken);
    }

What we now need is to implement the functionality for what is called justInTimeInstallation to make sure we can use the messaging extension also in a team conversation where the bot is not (yet) installed. Therefore we first of all need an Adaptive Card which we will send to the user trying to open the messaging extension, requesting to install the bot in the team:

{
    "type": "AdaptiveCard",
    "body": [
        {
            "type": "TextBlock",
            "text": "Looks like you haven't installed this app in this team/chat"
        }
    ],
    "actions": [
        {
            "type": "Action.Submit",
            "title": "Continue",
            "data": {
                "msteams": {
                    "justInTimeInstall": true
                }
            }
        }
    ],
    "version": "1.0",
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json"
}

Next we need an override for the OnTeamsMessagingExtensionFetchTaskAsync method which will be called when the user triggers the messaging extension:

 // 1. Will be called when user triggers messaging extension which then calls CreateTaskModuleCommand
protected override async Task<MessagingExtensionActionResponse> OnTeamsMessagingExtensionFetchTaskAsync(ITurnContext<IInvokeActivity> turnContext, MessagingExtensionAction action, CancellationToken cancellationToken)
{
    // Check if the bot has been installed in the team by getting the team rooster
    try
    {
        var teamsMembers = await TeamsInfo.GetMembersAsync(turnContext);
    }
    catch
    {
        // if not installed we will send out the card instructing the user to install the bot
        string jitCardPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Cards", "jitCard.json");
        var jitCard = File.ReadAllText(jitCardPath, Encoding.UTF8);
        string jitCardJson = jitCard;
        var jitCardTeams = AdaptiveCard.FromJson(jitCardJson);
        return await Task.FromResult(new MessagingExtensionActionResponse
        {
            Task = new TaskModuleContinueResponse
            {
                Value = new TaskModuleTaskInfo
                {
                    Card = new Attachment
                    {
                        Content = jitCardTeams.Card,
                        ContentType = AdaptiveCard.ContentType,
                    },
                    Height = 300,
                    Width = 600,
                    Title = "Install bot",
                },
            },
        });
    }
    var magicCode = string.Empty;
    var state = (turnContext.Activity.Value as Newtonsoft.Json.Linq.JObject).Value<string>("state");
    if (!string.IsNullOrEmpty(state))
    {
        int parsed = 0;
        if (int.TryParse(state, out parsed))
        {
            magicCode = parsed.ToString();
        }
    }
    var tokenResponse = await (turnContext.Adapter as IUserTokenProvider).GetUserTokenAsync(turnContext, _connectionName, magicCode, cancellationToken: cancellationToken);
    if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.Token))
    {
        // There is no token, so the user has not signed in yet.
        // Retrieve the OAuth Sign in Link to use in the MessagingExtensionResult Suggested Actions
        var signInLink = await (turnContext.Adapter as IUserTokenProvider).GetOauthSignInLinkAsync(turnContext, _connectionName, cancellationToken);

        return new MessagingExtensionActionResponse
        {
            ComposeExtension = new MessagingExtensionResult
            {
                Type = "auth",
                SuggestedActions = new MessagingExtensionSuggestedAction
                {
                    Actions = new List<CardAction>
                        {
                            new CardAction
                            {
                                Type = ActionTypes.OpenUrl,
                                Value = signInLink,
                                Title = "Sign in Please",
                            },
                        },
                },
            },
        };
    }
    var accessToken = tokenResponse.Token;
    if (accessToken != null || !string.IsNullOrEmpty(accessToken))
    {
        // Create Graph Client
        var client = new SimpleGraphClient(accessToken);
        // Get Group details
        var channel = turnContext.Activity.TeamsGetChannelId();
        if (channel != null)
        {
            var members = new List<TeamsChannelAccount>();
            string continuationToken = null;
            do
            {
                var currentPage = await TeamsInfo.GetPagedMembersAsync(turnContext, 100, continuationToken, cancellationToken);
                continuationToken = currentPage.ContinuationToken;
                members = members.Concat(currentPage.Members).ToList();
            }
            while (continuationToken != null);
            TeamDetails teamDetails = await TeamsInfo.GetTeamDetailsAsync(turnContext, turnContext.Activity.TeamsGetTeamInfo().Id, cancellationToken);
            if (teamDetails != null)
            {
                var groupId = teamDetails.AadGroupId;
                plannerGroupId = groupId;
                //Get Plans
                var currentGroupPlan = await client.GetCurrentPlan(groupId);
                var favoritePlans = await client.GetFavoritePlans();
                // Fill Adaptive Card data
                var exampleData = new AdaptiveCardData();
                exampleData.MultiSelect = "false";
                if (currentGroupPlan.CurrentPage.Count == 0)
                {
                    exampleData.Option1 = favoritePlans.CurrentPage[4].Title;
                    exampleData.Option1Value = favoritePlans.CurrentPage[4].Id;
                }
                else
                {
                    exampleData.Option1 = currentGroupPlan.CurrentPage[0].Title;
                    exampleData.Option1Value = currentGroupPlan.CurrentPage[0].Id;
                }
                exampleData.Option2 = favoritePlans.CurrentPage[0].Title;
                exampleData.Option3 = favoritePlans.CurrentPage[1].Title;
                exampleData.Option4 = favoritePlans.CurrentPage[2].Title;
                exampleData.Option2Value = favoritePlans.CurrentPage[0].Id;
                exampleData.Option3Value = favoritePlans.CurrentPage[1].Id;
                exampleData.Option4Value = favoritePlans.CurrentPage[2].Id;
                // Create and return card
                var adaptiveCardEditor = AdaptiveCardHelper.CreateAdaptiveCardEditor(exampleData);
                return await Task.FromResult(new MessagingExtensionActionResponse
                {
                    Task = new TaskModuleContinueResponse
                    {
                        Value = new TaskModuleTaskInfo
                        {
                            Card = new Microsoft.Bot.Schema.Attachment
                            {
                                Content = adaptiveCardEditor,
                                ContentType = AdaptiveCard.ContentType,
                            },
                            Height = 600,
                            Width = 600,
                            Title = "Task creation",
                        },
                    },
                });
            }
        }
        else
        {
            // Return only favorite plans without current plan as in 1:1 or group chat
            var favoritePlans = await client.GetFavoritePlans();
            // Fill Adaptive Card data
            var exampleData = new AdaptiveCardData();
            exampleData.MultiSelect = "false";
            exampleData.Option1 = favoritePlans.CurrentPage[0].Title;
            exampleData.Option2 = favoritePlans.CurrentPage[1].Title;
            exampleData.Option3 = favoritePlans.CurrentPage[2].Title;
            exampleData.Option1Value = favoritePlans.CurrentPage[0].Id;
            exampleData.Option3Value = favoritePlans.CurrentPage[1].Id;
            exampleData.Option4Value = favoritePlans.CurrentPage[2].Id;
            // Create and return card
            var adaptiveCardEditor = AdaptiveCardHelper.CreateAdaptiveCardEditor(exampleData);
            return await Task.FromResult(new MessagingExtensionActionResponse
            {
                Task = new TaskModuleContinueResponse
                {
                    Value = new TaskModuleTaskInfo
                    {
                        Card = new Microsoft.Bot.Schema.Attachment
                        {
                            Content = adaptiveCardEditor,
                            ContentType = AdaptiveCard.ContentType,
                        },
                        Height = 600,
                        Width = 600,
                        Title = "Task creation",
                    },
                },
            });
        }
    }
    return null;
}

After the user has entered the data we need to call the CreateTaskModuleCommand which will fetch the input data and create the tasks using the SimpleGraphClient.cs:

// 2. Will be called after OnTeamsMessagingExtensionFetchTaskAsync when user has entered all data in the Messaging Extension Adaptive Card
private async Task<MessagingExtensionActionResponse> CreateTaskModuleCommand(ITurnContext<IInvokeActivity> turnContext, MessagingExtensionAction action, CancellationToken cancellationToken)
{
    var msg = action.MessagePayload.Body.Content;
    var data = action.Data;
    var channel = turnContext.Activity.TeamsGetChannelId();
    var groupId = "";
    if (channel != null)
    {
        TeamDetails teamDetails = await TeamsInfo.GetTeamDetailsAsync(turnContext, turnContext.Activity.TeamsGetTeamInfo().Id, cancellationToken);
        groupId = teamDetails.AadGroupId;
    }
    var subject = ((JObject)action.Data)["Title"]?.ToString();
    var dueDate = ((JObject)action.Data)["DueDate"]?.ToString();
    var startDate = ((JObject)action.Data)["StartDate"]?.ToString();
    var type = ((JObject)action.Data)["Type"]?.ToString();
    var url = turnContext.Activity.Value.ToString();
    JObject jsonUrl = JObject.Parse(url);
    var link = jsonUrl["messagePayload"]["linkToMessage"];
    var magicCode = string.Empty;
    var state = (turnContext.Activity.Value as Newtonsoft.Json.Linq.JObject).Value<string>("state");
    if (!string.IsNullOrEmpty(state))
    {
        int parsed = 0;
        if (int.TryParse(state, out parsed))
        {
            magicCode = parsed.ToString();
        }
    }
    var tokenResponse = await (turnContext.Adapter as IUserTokenProvider).GetUserTokenAsync(turnContext, _connectionName, magicCode, cancellationToken: cancellationToken);
    if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.Token))
    {
        // There is no token, so the user has not signed in yet.
        // Retrieve the OAuth Sign in Link to use in the MessagingExtensionResult Suggested Actions
        var signInLink = await (turnContext.Adapter as IUserTokenProvider).GetOauthSignInLinkAsync(turnContext, _connectionName, cancellationToken);
        return new MessagingExtensionActionResponse
        {
            ComposeExtension = new MessagingExtensionResult
            {
                Type = "auth",
                SuggestedActions = new MessagingExtensionSuggestedAction
                {
                    Actions = new List<CardAction>
                        {
                            new CardAction
                            {
                                Type = ActionTypes.OpenUrl,
                                Value = signInLink,
                                Title = "Bot Service OAuth",
                            },
                        },
                },
            },
        };
    }
    var accessToken = tokenResponse.Token;
    if (accessToken != null || !string.IsNullOrEmpty(accessToken))
    {
        var client = new SimpleGraphClient(accessToken);
        if (type == "todo")
        {
            var body = new Beta.ItemBody
            {
                Content = msg + " - " + link
            };
            var taskResult = await client.CreateTaskAsync(subject, dueDate, startDate, body);
            var todoUrl = "https://to-do.office.com/tasks/id/" + taskResult.Id + "/details";
            List<ChannelAccount> participants = new List<ChannelAccount>();
            participants.Add(new ChannelAccount(turnContext.Activity.From.Id, turnContext.Activity.From.Name));
            var connectorClient = new ConnectorClient(new Uri(serviceUrl), new MicrosoftAppCredentials(botClientID, botClientSecret));
            var conversationParameters = new ConversationParameters()
            {
                ChannelData = new TeamsChannelData
                {
                    Tenant = new TenantInfo
                    {
                        Id = tenantId,
                    }
                },
                Members = new List<ChannelAccount>() { participants[0] }
            };
            var response = await connectorClient.Conversations.CreateConversationAsync(conversationParameters);
            string taskCardPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Cards", "todoCardTeams.json");
            var r = File.ReadAllText(taskCardPath, Encoding.UTF8);
            string taskCardJson = r;
            taskCardJson = taskCardJson.Replace("replaceUrl", todoUrl ?? "", true, culture: CultureInfo.InvariantCulture);
            taskCardJson = taskCardJson.Replace("ReplaceTitel", taskResult.Subject.ToString() ?? "", true, culture: CultureInfo.InvariantCulture);
            var card = AdaptiveCard.FromJson(taskCardJson);
            Attachment attachment = new Attachment()
            {
                ContentType = AdaptiveCard.ContentType,
                Content = card.Card
            };
            IMessageActivity cardMsg = MessageFactory.Attachment(attachment);
            await connectorClient.Conversations.SendToConversationAsync(response.Id, (Activity)cardMsg, cancellationToken);
        }
        if (type == "planner")
        {
            var username = turnContext.Activity.From.AadObjectId;
            var taskTitle = ((JObject)action.Data)["Title"]?.ToString();
            var taskStartDate = ((JObject)action.Data)["StartDate"]?.ToString();
            var taskDueDate = ((JObject)action.Data)["DueDate"]?.ToString();
            var taskSPlanId = ((JObject)action.Data)["Choices"]?.ToString();
            var planResult = await client.CreatePlannerTaskAsync(taskSPlanId, taskTitle, taskDueDate, taskStartDate, username);
            if (!string.IsNullOrEmpty(groupId))
            {
                var taskUrl = "https://tasks.office.com/solviondemo.net/en-US/Home/Planner/#/plantaskboard?groupId=" + groupId + "&planId=" + planResult.PlanId + "&taskId=" + planResult.Id;
                List<ChannelAccount> participants = new List<ChannelAccount>();
                participants.Add(new ChannelAccount(turnContext.Activity.From.Id, turnContext.Activity.From.Name));
                var connectorClient = new ConnectorClient(new Uri(serviceUrl), new MicrosoftAppCredentials(botClientID, botClientSecret));
                var conversationParameters = new ConversationParameters()
                {
                    ChannelData = new TeamsChannelData
                    {
                        Tenant = new TenantInfo
                        {
                            Id = tenantId,
                        }
                    },
                    Members = new List<ChannelAccount>() { participants[0] }
                };
                var response = await connectorClient.Conversations.CreateConversationAsync(conversationParameters);
                string taskCardPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Cards", "plannerCardTeams.json");
                var r = File.ReadAllText(taskCardPath, Encoding.UTF8);
                string taskCardJson = r;
                taskCardJson = taskCardJson.Replace("replaceUrl", taskUrl ?? "", true, culture: CultureInfo.InvariantCulture);
                taskCardJson = taskCardJson.Replace("ReplaceTitel", planResult.Title.ToString() ?? "", true, culture: CultureInfo.InvariantCulture);
                var card = AdaptiveCard.FromJson(taskCardJson);
                Attachment attachment = new Attachment()
                {
                    ContentType = AdaptiveCard.ContentType,
                    Content = card.Card
                };
                IMessageActivity cardMsg = MessageFactory.Attachment(attachment);
                await connectorClient.Conversations.SendToConversationAsync(response.Id, (Activity)cardMsg, cancellationToken);
            }
            else
            {
                List<ChannelAccount> participants = new List<ChannelAccount>();
                participants.Add(new ChannelAccount(turnContext.Activity.From.Id, turnContext.Activity.From.Name));
                var connectorClient = new ConnectorClient(new Uri(serviceUrl), new MicrosoftAppCredentials(botClientID, botClientSecret));
                var conversationParameters = new ConversationParameters()
                {
                    ChannelData = new TeamsChannelData
                    {
                        Tenant = new TenantInfo
                        {
                            Id = tenantId,
                        }
                    },
                    Members = new List<ChannelAccount>() { participants[0] }
                };
                var response = await connectorClient.Conversations.CreateConversationAsync(conversationParameters);
                var personalMessageActivity = MessageFactory.Text($"I've created a new Planner task with the title **" + planResult.Title.ToString() + "** in the Plan you have chosen");
                await connectorClient.Conversations.SendToConversationAsync(response.Id, personalMessageActivity);
            }
        }
    }
    return null;
}

The last piece here is an override for the OnTeamsMessagingExtensionSubmitActionAsync method which we will need to call our CreateTaskModuleCommand:

protected override async Task<MessagingExtensionActionResponse> OnTeamsMessagingExtensionSubmitActionAsync(ITurnContext<IInvokeActivity> turnContext, MessagingExtensionAction action, CancellationToken cancellationToken)
{
    // check if we previousy requested to install the bot - if true we will present the messaging extension
    if (action.Data.ToString().Contains("justInTimeInstall"))
    {
        return await OnTeamsMessagingExtensionFetchTaskAsync(turnContext, action, cancellationToken);
    }
    else
    {
        switch (action.CommandId)
        {
            case "createTaskModule":
                // This command will first call OnTeamsMessagingExtensionFetchTaskAsync and then CreateTaskModuleCommand
                return await CreateTaskModuleCommand(turnContext, action, cancellationToken);
            default:
                throw new NotImplementedException($"Invalid CommandId: {action.CommandId}");
        }
    }
}

Create Teams App

Now that the bot is ready, we can create our new Microsoft Teams application using the Teams App Studio. Within there we well connect our Bot Channels Registration service we created earlier and also add a messaging extension command with the name createTaskModule exactly as we have used in our EchoBot.cs:

From the Bots section, make sure to add personal, team and group as a scope so users can use your messaging extension in 1:1 & group chats and teams conversations.

In order to create the messaging extension correctly, you’ll need to select the same bot as before and then add a new command like this:

From the Domains and permissions section also add token.botframework.com to the list of valid domains to make authentication work:

After installing the bot for your users you can test it (make sure you added the Teams channel to your Bot Channels registration in the Azure portal) and you should be greeted immediately by the bot:

Now if you click the ellipsis (…) on a message and select more actions you should see your “Creat Task” command there:

If you click that the first time you will be prompted to authenticate:

After you have signed, in the bot will show the Adaptive Card where you can select to create either a To Do task or a Planner task:

If you click on Todo Task you can insert the title, start and due date of your to be created task:

After hitting Create ToDo Task the bot will create the task in your ToDo task list and send you back a message that the task creation has been done successfully:

Now if you click on Open Task you will be directly getting to that newly created task in To Do:

Now even cooler is the functionality in a team conversation, where you have a Planner Plan attached. If I select the Create Task action again on a conversation in a team and then create a new Planner task, it will let me select my favorite Planner plans as well as the Planner plan for this particular team and the bot will send you a 1:1 direct message when the creation has been finished:

The code is published in the Bot Builder Community GitHub repo and can be found here (feel free to use or contribute)

Conclusion

NOTE: This code is definetly NOT production ready but you can of course use it and modify it to use it for your use case (but please don’t blame me if there are bugs 😁). The things I learned while developing this bot are:

  • Authentication is still a bit tricky with so many components playing together (Azure AD, Graph, Teams, Bot Framework, …)
  • Adaptive Cards come in handy when you want to build your own UI for your messaging extensions
  • Notifying users using a 1:1 chat message from a team conversation is a bit hacky as of now
  • The Microsoft Bot Framework is awesome 👍
comments powered by Disqus