diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/AdapterWithErrorHandler.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/AdapterWithErrorHandler.cs new file mode 100644 index 000000000..de49623d2 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/AdapterWithErrorHandler.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Skills; +using Microsoft.Bot.Builder.Skills; +using Microsoft.Bot.Builder.TraceExtensions; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.BotBuilderSamples.AdaptiveRootBot.Dialogs; +using Microsoft.BotBuilderSamples.AdaptiveRootBot.Middleware; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot +{ + public class AdapterWithErrorHandler : BotFrameworkHttpAdapter + { + private readonly IConfiguration _configuration; + private readonly ConversationState _conversationState; + private readonly ILogger _logger; + private readonly SkillHttpClient _skillClient; + private readonly SkillsConfiguration _skillsConfig; + + public AdapterWithErrorHandler(IConfiguration configuration, ILogger logger, IStorage storage, UserState userState, ConversationState conversationState, SkillHttpClient skillClient = null, SkillsConfiguration skillsConfig = null) + : base(configuration, logger) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _conversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _skillClient = skillClient; + _skillsConfig = skillsConfig; + + this.UseStorage(storage); + this.UseState(userState, conversationState); + + OnTurnError = HandleTurnError; + Use(new LoggerMiddleware(logger)); + } + + private async Task HandleTurnError(ITurnContext turnContext, Exception exception) + { + // Log any leaked exception from the application. + _logger.LogError(exception, $"[OnTurnError] unhandled error : {exception.Message}"); + + await SendErrorMessageAsync(turnContext, exception); + await EndSkillConversationAsync(turnContext); + await ClearConversationStateAsync(turnContext); + } + + private async Task SendErrorMessageAsync(ITurnContext turnContext, Exception exception) + { + try + { + // Send a message to the user. + var errorMessageText = "The bot encountered an error or bug."; + var errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.IgnoringInput); + await turnContext.SendActivityAsync(errorMessage); + + errorMessageText = "To continue to run this bot, please fix the bot source code."; + errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.ExpectingInput); + await turnContext.SendActivityAsync(errorMessage); + + // Send a trace activity, which will be displayed in the Bot Framework Emulator. + await turnContext.TraceActivityAsync("OnTurnError Trace", exception.ToString(), "https://www.botframework.com/schemas/error", "TurnError"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Exception caught in SendErrorMessageAsync : {ex}"); + } + } + + private async Task EndSkillConversationAsync(ITurnContext turnContext) + { + if (_skillClient == null || _skillsConfig == null) + { + return; + } + + try + { + // Inform the active skill that the conversation is ended so that it has a chance to clean up. + // Note: the root bot manages the ActiveSkillPropertyName, which has a value while the root bot + // has an active conversation with a skill. + var activeSkill = await _conversationState.CreateProperty(MainDialog.ActiveSkillPropertyName).GetAsync(turnContext, () => null); + if (activeSkill != null) + { + var botId = _configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value; + + var endOfConversation = Activity.CreateEndOfConversationActivity(); + endOfConversation.Code = "RootSkillError"; + endOfConversation.ApplyConversationReference(turnContext.Activity.GetConversationReference(), true); + + await _conversationState.SaveChangesAsync(turnContext, true); + await _skillClient.PostActivityAsync(botId, activeSkill, _skillsConfig.SkillHostEndpoint, (Activity)endOfConversation, CancellationToken.None); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Exception caught on attempting to send EndOfConversation : {ex}"); + } + } + + private async Task ClearConversationStateAsync(ITurnContext turnContext) + { + 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" for a Web page. + await _conversationState.DeleteAsync(turnContext); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Exception caught on attempting to Delete ConversationState : {ex}"); + } + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/AdaptiveRootBot.csproj b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/AdaptiveRootBot.csproj new file mode 100644 index 000000000..d1b09fea0 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/AdaptiveRootBot.csproj @@ -0,0 +1,32 @@ + + + + netcoreapp3.1 + Microsoft.BotBuilderSamples.AdaptiveRootBot + Microsoft.BotBuilderSamples.AdaptiveRootBot + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Authentication/AllowedSkillsClaimsValidator.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Authentication/AllowedSkillsClaimsValidator.cs new file mode 100644 index 000000000..00334cc12 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Authentication/AllowedSkillsClaimsValidator.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.Bot.Connector.Authentication; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot.Authentication +{ + /// + /// Sample claims validator that loads an allowed list from configuration if present + /// and checks that responses are coming from configured skills. + /// + public class AllowedSkillsClaimsValidator : ClaimsValidator + { + private readonly List _allowedSkills; + + public AllowedSkillsClaimsValidator(SkillsConfiguration skillsConfig) + { + if (skillsConfig == null) + { + throw new ArgumentNullException(nameof(skillsConfig)); + } + + // Load the appIds for the configured skills (we will only allow responses from skills we have configured). + _allowedSkills = (from skill in skillsConfig.Skills.Values select skill.AppId).ToList(); + } + + public override Task ValidateClaimsAsync(IList claims) + { + if (SkillValidation.IsSkillClaim(claims)) + { + // Check that the appId claim in the skill request is in the list of skills configured for this bot. + var appId = JwtTokenValidation.GetAppIdFromClaims(claims); + if (!_allowedSkills.Contains(appId)) + { + throw new UnauthorizedAccessException($"Received a request from an application with an appID of \"{appId}\". To enable requests from this skill, add the skill to your configuration file."); + } + } + + return Task.CompletedTask; + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Bots/RootBot.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Bots/RootBot.cs new file mode 100644 index 000000000..05644abd3 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Bots/RootBot.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; +using Newtonsoft.Json; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot.Bots +{ + public class RootBot : ActivityHandler + where T : Dialog + { + private readonly ConversationState _conversationState; + private readonly DialogManager _dialogManager; + + public RootBot(ConversationState conversationState, T mainDialog) + { + _conversationState = conversationState; + _dialogManager = new DialogManager(mainDialog); + } + + public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + if (turnContext.Activity.Type != ActivityTypes.ConversationUpdate) + { + // Run the Dialog with the Activity. + await _dialogManager.OnTurnAsync(turnContext, cancellationToken); + } + else + { + // Let the base class handle the activity. + await base.OnTurnAsync(turnContext, cancellationToken); + } + + // Save any state changes that might have occurred during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + + protected override async Task OnMembersAddedAsync(IList membersAdded, ITurnContext turnContext, CancellationToken cancellationToken) + { + foreach (var member in membersAdded) + { + // Greet anyone that was not the target (recipient) of this message. + // To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards. + if (member.Id != turnContext.Activity.Recipient.Id) + { + var welcomeCard = CreateAdaptiveCardAttachment(); + var activity = MessageFactory.Attachment(welcomeCard); + activity.Speak = "Welcome to the Dialog Skill Prototype!"; + await turnContext.SendActivityAsync(activity, cancellationToken); + await _dialogManager.OnTurnAsync(turnContext, cancellationToken); + } + } + } + + // Load attachment from embedded resource. + private Attachment CreateAdaptiveCardAttachment() + { + var cardResourcePath = "Microsoft.BotBuilderSamples.AdaptiveRootBot.Cards.welcomeCard.json"; + + using (var stream = GetType().Assembly.GetManifestResourceStream(cardResourcePath)) + { + using (var reader = new StreamReader(stream)) + { + var adaptiveCard = reader.ReadToEnd(); + return new Attachment + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = JsonConvert.DeserializeObject(adaptiveCard) + }; + } + } + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Cards/welcomeCard.json b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Cards/welcomeCard.json new file mode 100644 index 000000000..1fc98a223 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Cards/welcomeCard.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Image", + "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", + "size": "stretch" + }, + { + "type": "TextBlock", + "spacing": "Medium", + "size": "Medium", + "weight": "Bolder", + "text": "Welcome to the Skill Dialog Sample!", + "wrap": true, + "maxLines": 0, + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "This sample allows you to connect to a skill using a SkillDialog and invoke several actions.", + "wrap": true, + "maxLines": 0 + } + ] +} \ No newline at end of file diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Controllers/BotController.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Controllers/BotController.cs new file mode 100644 index 000000000..3056e9228 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Controllers/BotController.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot.Controllers +{ + // 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")] + [ApiController] + public class BotController : ControllerBase + { + private readonly IBotFrameworkHttpAdapter _adapter; + private readonly IBot _bot; + + public BotController(BotFrameworkHttpAdapter adapter, IBot bot) + { + _adapter = adapter; + _bot = bot; + } + + [HttpPost] + [HttpGet] + public async Task PostAsync() + { + // Delegate the processing of the HTTP POST to the adapter. + // The adapter will invoke the bot. + await _adapter.ProcessAsync(Request, Response, _bot); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Controllers/SkillController.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Controllers/SkillController.cs new file mode 100644 index 000000000..4fec03dbc --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Controllers/SkillController.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Skills; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot.Controllers +{ + /// + /// A controller that handles skill replies to the bot. + /// This example uses the that is registered as a in startup.cs. + /// + [ApiController] + [Route("api/skills")] + public class SkillController : ChannelServiceController + { + public SkillController(ChannelServiceHandler handler) + : base(handler) + { + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Dialogs/MainDialog.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Dialogs/MainDialog.cs new file mode 100644 index 000000000..9aa4ee86b --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Dialogs/MainDialog.cs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Dialogs.Choices; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Skills; +using Microsoft.Bot.Builder.Skills; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot.Dialogs +{ + /// + /// The main dialog for this bot. It uses a to call skills. + /// + public class MainDialog : ComponentDialog + { + public static readonly string ActiveSkillPropertyName = $"{typeof(MainDialog).FullName}.ActiveSkillProperty"; + + // Constants used for selecting actions on the skill. + private const string SkillActionBookFlight = "BookFlight"; + private const string SkillActionBookFlightWithInputParameters = "BookFlight with input parameters"; + private const string SkillActionGetWeather = "GetWeather"; + private const string SkillActionMessage = "Message"; + + private readonly IStatePropertyAccessor _activeSkillProperty; + private readonly string _selectedSkillKey = $"{typeof(MainDialog).FullName}.SelectedSkillKey"; + private readonly SkillsConfiguration _skillsConfig; + + // Dependency injection uses this constructor to instantiate MainDialog. + public MainDialog(ConversationState conversationState, SkillConversationIdFactoryBase conversationIdFactory, SkillHttpClient skillClient, SkillsConfiguration skillsConfig, IConfiguration configuration) + : base(nameof(MainDialog)) + { + var botId = configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value; + if (string.IsNullOrWhiteSpace(botId)) + { + throw new ArgumentException($"{MicrosoftAppCredentials.MicrosoftAppIdKey} is not in configuration"); + } + + _skillsConfig = skillsConfig ?? throw new ArgumentNullException(nameof(skillsConfig)); + + if (skillClient == null) + { + throw new ArgumentNullException(nameof(skillClient)); + } + + if (conversationState == null) + { + throw new ArgumentNullException(nameof(conversationState)); + } + + // Use helper method to add SkillDialog instances for the configured skills. + AddSkillDialogs(conversationState, conversationIdFactory, skillClient, skillsConfig, botId); + + // Add ChoicePrompt to render available skills. + AddDialog(new ChoicePrompt("SkillPrompt")); + + // Add ChoicePrompt to render skill actions. + AddDialog(new ChoicePrompt("SkillActionPrompt", SkillActionPromptValidator)); + + // Add main waterfall dialog for this bot. + var waterfallSteps = new WaterfallStep[] + { + SelectSkillStepAsync, + SelectSkillActionStepAsync, + CallSkillActionStepAsync, + FinalStepAsync + }; + AddDialog(new WaterfallDialog(nameof(WaterfallDialog), waterfallSteps)); + + // Create state property to track the active skill. + _activeSkillProperty = conversationState.CreateProperty(ActiveSkillPropertyName); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + protected override async Task OnContinueDialogAsync(DialogContext innerDc, CancellationToken cancellationToken = default) + { + // This is an example on how to cancel a SkillDialog that is currently in progress from the parent bot. + var activeSkill = await _activeSkillProperty.GetAsync(innerDc.Context, () => null, cancellationToken); + var activity = innerDc.Context.Activity; + if (activeSkill != null && activity.Type == ActivityTypes.Message && activity.Text.Equals("abort", StringComparison.CurrentCultureIgnoreCase)) + { + // Cancel all dialogs when the user says abort. + // The SkillDialog automatically sends an EndOfConversation message to the skill to let the + // skill know that it needs to end its current dialogs, too. + await innerDc.CancelAllDialogsAsync(cancellationToken); + return await innerDc.ReplaceDialogAsync(InitialDialogId, "Canceled! \n\n What skill would you like to call?", cancellationToken); + } + + return await base.OnContinueDialogAsync(innerDc, cancellationToken); + } + + // Render a prompt to select the skill to call. + private async Task SelectSkillStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + // Create the PromptOptions from the skill configuration which contain the list of configured skills. + var messageText = stepContext.Options?.ToString() ?? "What skill would you like to call?"; + var repromptMessageText = "That was not a valid choice, please select a valid skill."; + var options = new PromptOptions + { + Prompt = MessageFactory.Text(messageText, messageText, InputHints.ExpectingInput), + RetryPrompt = MessageFactory.Text(repromptMessageText, repromptMessageText, InputHints.ExpectingInput), + Choices = _skillsConfig.Skills.Select(skill => new Choice(skill.Value.Id)).ToList() + }; + + // Prompt the user to select a skill. + return await stepContext.PromptAsync("SkillPrompt", options, cancellationToken); + } + + // Render a prompt to select the action for the skill. + private async Task SelectSkillActionStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + // Get the skill info based on the selected skill. + var selectedSkillId = ((FoundChoice)stepContext.Result).Value; + var selectedSkill = _skillsConfig.Skills.FirstOrDefault(s => s.Value.Id == selectedSkillId).Value; + + // Remember the skill selected by the user. + stepContext.Values[_selectedSkillKey] = selectedSkill; + + // Create the PromptOptions with the actions supported by the selected skill. + var messageText = $"Select an action # to send to **{selectedSkill.Id}** or just type in a message and it will be forwarded to the skill"; + var options = new PromptOptions + { + Prompt = MessageFactory.Text(messageText, messageText, InputHints.ExpectingInput), + Choices = GetSkillActions(selectedSkill) + }; + + // Prompt the user to select a skill action. + return await stepContext.PromptAsync("SkillActionPrompt", options, cancellationToken); + } + + // This validator defaults to Message if the user doesn't select an existing option. + private Task SkillActionPromptValidator(PromptValidatorContext promptContext, CancellationToken cancellationToken) + { + if (!promptContext.Recognized.Succeeded) + { + // Assume the user wants to send a message if an item in the list is not selected. + promptContext.Recognized.Value = new FoundChoice { Value = SkillActionMessage }; + } + + return Task.FromResult(true); + } + + // Starts the SkillDialog based on the user's selections. + private async Task CallSkillActionStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var selectedSkill = (BotFrameworkSkill)stepContext.Values[_selectedSkillKey]; + + Activity skillActivity; + switch (selectedSkill.Id) + { + case "DialogSkillBot": + skillActivity = CreateDialogSkillBotActivity(((FoundChoice)stepContext.Result).Value, stepContext.Context); + break; + + // We can add other case statements here if we support more than one skill. + default: + throw new Exception($"Unknown target skill id: {selectedSkill.Id}."); + } + + // Create the BeginSkillDialogOptions and assign the activity to send. + var skillDialogArgs = new BeginSkillDialogOptions { Activity = skillActivity }; + + // Save active skill in state. + await _activeSkillProperty.SetAsync(stepContext.Context, selectedSkill, cancellationToken); + + // Start the skillDialog instance with the arguments. + return await stepContext.BeginDialogAsync(selectedSkill.Id, skillDialogArgs, cancellationToken); + } + + // The SkillDialog has ended, render the results (if any) and restart MainDialog. + private async Task FinalStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var activeSkill = await _activeSkillProperty.GetAsync(stepContext.Context, () => null, cancellationToken); + + // Check if the skill returned any results and display them. + if (stepContext.Result != null) + { + var message = $"Skill \"{activeSkill.Id}\" invocation complete."; + message += $" Result: {JsonConvert.SerializeObject(stepContext.Result)}"; + await stepContext.Context.SendActivityAsync(MessageFactory.Text(message, message, inputHint: InputHints.IgnoringInput), cancellationToken: cancellationToken); + } + + // Clear the skill selected by the user. + stepContext.Values[_selectedSkillKey] = null; + + // Clear active skill in state. + await _activeSkillProperty.DeleteAsync(stepContext.Context, cancellationToken); + + // Restart the main dialog with a different message the second time around. + return await stepContext.ReplaceDialogAsync(InitialDialogId, $"Done with \"{activeSkill.Id}\". \n\n What skill would you like to call?", cancellationToken); + } + + // Helper method that creates and adds SkillDialog instances for the configured skills. + private void AddSkillDialogs(ConversationState conversationState, SkillConversationIdFactoryBase conversationIdFactory, SkillHttpClient skillClient, SkillsConfiguration skillsConfig, string botId) + { + foreach (var skillInfo in _skillsConfig.Skills.Values) + { + // Create the dialog options. + var skillDialogOptions = new SkillDialogOptions + { + BotId = botId, + ConversationIdFactory = conversationIdFactory, + SkillClient = skillClient, + SkillHostEndpoint = skillsConfig.SkillHostEndpoint, + ConversationState = conversationState, + Skill = skillInfo + }; + + // Add a SkillDialog for the selected skill. + AddDialog(new SkillDialog(skillDialogOptions, skillInfo.Id)); + } + } + + // Helper method to create Choice elements for the actions supported by the skill. + private IList GetSkillActions(BotFrameworkSkill skill) + { + // Note: the bot would probably render this by reading the skill manifest. + // We are just using hardcoded skill actions here for simplicity. + + var choices = new List(); + switch (skill.Id) + { + case "DialogSkillBot": + choices.Add(new Choice(SkillActionBookFlight)); + choices.Add(new Choice(SkillActionBookFlightWithInputParameters)); + choices.Add(new Choice(SkillActionGetWeather)); + break; + } + + return choices; + } + + // Helper method to create the activity to be sent to the DialogSkillBot using selected type and values. + private Activity CreateDialogSkillBotActivity(string selectedOption, ITurnContext turnContext) + { + // Note: in a real bot, the dialogArgs will be created dynamically based on the conversation + // and what each action requires; here we hardcode the values to make things simpler. + + // Just forward the message activity to the skill with whatever the user said. + if (selectedOption.Equals(SkillActionMessage, StringComparison.CurrentCultureIgnoreCase)) + { + // Note message activities also support input parameters but we are not using them in this example. + return turnContext.Activity; + } + + Activity activity = null; + + // Send an event activity to the skill with "BookFlight" in the name. + if (selectedOption.Equals(SkillActionBookFlight, StringComparison.CurrentCultureIgnoreCase)) + { + activity = (Activity)Activity.CreateEventActivity(); + activity.Name = SkillActionBookFlight; + } + + // Send an event activity to the skill with "BookFlight" in the name and some testing values. + if (selectedOption.Equals(SkillActionBookFlightWithInputParameters, StringComparison.CurrentCultureIgnoreCase)) + { + activity = (Activity)Activity.CreateEventActivity(); + activity.Name = SkillActionBookFlight; + activity.Value = JObject.Parse("{ \"origin\": \"New York\", \"destination\": \"Seattle\"}"); + } + + // Send an event activity to the skill with "GetWeather" in the name and some testing values. + if (selectedOption.Equals(SkillActionGetWeather, StringComparison.CurrentCultureIgnoreCase)) + { + activity = (Activity)Activity.CreateEventActivity(); + activity.Name = SkillActionGetWeather; + activity.Value = JObject.Parse("{ \"latitude\": 47.614891, \"longitude\": -122.195801}"); + return activity; + } + + if (activity == null) + { + throw new Exception($"Unable to create dialogArgs for \"{selectedOption}\"."); + } + + // We are manually creating the activity to send to the skill; ensure we add the ChannelData and Properties + // from the original activity so the skill gets them. + // Note: this is not necessary if we are just forwarding the current activity from context. + activity.ChannelData = turnContext.Activity.ChannelData; + activity.Properties = turnContext.Activity.Properties; + + return activity; + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Middleware/LoggerMiddleware.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Middleware/LoggerMiddleware.cs new file mode 100644 index 000000000..c6e2719bc --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Middleware/LoggerMiddleware.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot.Middleware +{ + /// + /// Uses an ILogger instance to log user and bot messages. It filters out ContinueConversation events coming from skill responses. + /// + public class LoggerMiddleware : IMiddleware + { + private readonly ILogger _logger; + + public LoggerMiddleware(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + // Note: skill responses will show as ContinueConversation events; we don't log those. + // We only log incoming messages from users. + if (turnContext.Activity.Type != ActivityTypes.Event && turnContext.Activity.Name != "ContinueConversation") + { + var message = $"User said: {turnContext.Activity.Text} Type: \"{turnContext.Activity.Type}\" Name: \"{turnContext.Activity.Name}\""; + _logger.LogInformation(message); + } + + // Register outgoing handler. + turnContext.OnSendActivities(OutgoingHandler); + + // Continue processing messages. + await next(cancellationToken); + } + + private async Task OutgoingHandler(ITurnContext turnContext, List activities, Func> next) + { + foreach (var activity in activities) + { + var message = $"Bot said: {activity.Text} Type: \"{activity.Type}\" Name: \"{activity.Name}\""; + _logger.LogInformation(message); + } + + return await next(); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Program.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Program.cs new file mode 100644 index 000000000..50823ccb9 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/SkillConversationIdFactory.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/SkillConversationIdFactory.cs new file mode 100644 index 000000000..473724ade --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/SkillConversationIdFactory.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Skills; +using Newtonsoft.Json.Linq; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot +{ + /// + /// A that uses to store + /// and retrieve instances. + /// + public class SkillConversationIdFactory : SkillConversationIdFactoryBase + { + private readonly IStorage _storage; + + public SkillConversationIdFactory(IStorage storage) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + } + + public override async Task CreateSkillConversationIdAsync(SkillConversationIdFactoryOptions options, CancellationToken cancellationToken) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + // Create the storage key based on the SkillConversationIdFactoryOptions. + var conversationReference = options.Activity.GetConversationReference(); + var skillConversationId = $"{conversationReference.Conversation.Id}-{options.BotFrameworkSkill.Id}-{conversationReference.ChannelId}-skillconvo"; + + // Create the SkillConversationReference instance. + var skillConversationReference = new SkillConversationReference + { + ConversationReference = conversationReference, + OAuthScope = options.FromBotOAuthScope + }; + + // Store the SkillConversationReference using the skillConversationId as a key. + var skillConversationInfo = new Dictionary { { skillConversationId, JObject.FromObject(skillConversationReference) } }; + await _storage.WriteAsync(skillConversationInfo, cancellationToken).ConfigureAwait(false); + + // Return the generated skillConversationId (that will be also used as the conversation ID to call the skill). + return skillConversationId; + } + + public override async Task GetSkillConversationReferenceAsync(string skillConversationId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(skillConversationId)) + { + throw new ArgumentNullException(nameof(skillConversationId)); + } + + // Get the SkillConversationReference from storage for the given skillConversationId. + var skillConversationInfo = await _storage.ReadAsync(new[] { skillConversationId }, cancellationToken).ConfigureAwait(false); + if (skillConversationInfo.Any()) + { + var conversationInfo = ((JObject)skillConversationInfo[skillConversationId]).ToObject(); + return conversationInfo; + } + + return null; + } + + public override async Task DeleteConversationReferenceAsync(string skillConversationId, CancellationToken cancellationToken) + { + // Delete the SkillConversationReference from storage. + await _storage.DeleteAsync(new[] { skillConversationId }, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/SkillsConfiguration.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/SkillsConfiguration.cs new file mode 100644 index 000000000..a6dd35ad5 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/SkillsConfiguration.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Bot.Builder.Skills; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot +{ + /// + /// A helper class that loads Skills information from configuration. + /// + public class SkillsConfiguration + { + public SkillsConfiguration(IConfiguration configuration) + { + var section = configuration?.GetSection("BotFrameworkSkills"); + var skills = section?.Get(); + if (skills != null) + { + foreach (var skill in skills) + { + Skills.Add(skill.Id, skill); + } + } + + var skillHostEndpoint = configuration?.GetValue(nameof(SkillHostEndpoint)); + if (!string.IsNullOrWhiteSpace(skillHostEndpoint)) + { + SkillHostEndpoint = new Uri(skillHostEndpoint); + } + } + + public Uri SkillHostEndpoint { get; } + + public Dictionary Skills { get; } = new Dictionary(); + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Startup.cs b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Startup.cs new file mode 100644 index 000000000..b8361cd23 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/Startup.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.BotFramework; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Skills; +using Microsoft.Bot.Builder.Skills; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.BotBuilderSamples.AdaptiveRootBot.Authentication; +using Microsoft.BotBuilderSamples.AdaptiveRootBot.Bots; +using Microsoft.BotBuilderSamples.AdaptiveRootBot.Dialogs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.BotBuilderSamples.AdaptiveRootBot +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers() + .AddNewtonsoftJson(); + + // Register credential provider. + services.AddSingleton(); + + // Register the skills configuration class. + services.AddSingleton(); + + // Register AuthConfiguration to enable custom claim validation. + services.AddSingleton(sp => new AuthenticationConfiguration { ClaimsValidator = new AllowedSkillsClaimsValidator(sp.GetService()) }); + + // Register the Bot Framework Adapter with error handling enabled. + // Note: some classes expect a BotAdapter and some expect a BotFrameworkHttpAdapter, so + // register the same adapter instance for both types. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetService()); + + // Register the skills conversation ID factory, the client and the request handler. + services.AddSingleton(); + services.AddHttpClient(); + services.AddSingleton(); + + // Register the storage we'll be using for User and Conversation state. (Memory is great for testing purposes.) + services.AddSingleton(); + + // Register User state (used by the Dialog system itself). + services.AddSingleton(); + + // Register Conversation state (used by the Dialog system itself). + services.AddSingleton(); + + // Register the MainDialog that will be run by the bot. + services.AddSingleton(); + + // Register the bot as a transient. In this case the ASP Controller is expecting an IBot. + services.AddSingleton>(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseDefaultFiles(); + app.UseStaticFiles(); + + // Uncomment this to support HTTPS. + // app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseWebSockets(); + + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/appsettings.json b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/appsettings.json new file mode 100644 index 000000000..80459ea76 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/appsettings.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "MicrosoftAppId": "TODO: Add here the App ID for the bot", + "MicrosoftAppPassword": "TODO: Add here the password for the bot", + + "SkillHostEndpoint": "http://localhost:3978/api/skills/", + "BotFrameworkSkills": [ + { + "Id": "EchoSkillBot", + "AppId": "TODO: Add here the App ID for the skill", + "SkillEndpoint": "http://localhost:39793/api/messages" + }, + { + "Id": "DialogSkillBot", + "AppId": "TODO: Add here the App ID for the skill", + "SkillEndpoint": "http://localhost:39783/api/messages" + } + ] +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/wwwroot/default.htm b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/wwwroot/default.htm new file mode 100644 index 000000000..15924d420 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveRootBot/wwwroot/default.htm @@ -0,0 +1,420 @@ + + + + + + + DialogRootBot + + + + + +
+
+
+
DialogRootBot Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
+ +
Visit Azure + Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks + like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/AdaptiveSkillBot.csproj b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/AdaptiveSkillBot.csproj new file mode 100644 index 000000000..b9df42efe --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/AdaptiveSkillBot.csproj @@ -0,0 +1,23 @@ + + + + netcoreapp3.1 + Microsoft.BotBuilderSamples.AdaptiveSkillBot + Microsoft.BotBuilderSamples.AdaptiveSkillBot + + + + + + + + + + + + + + Always + + + diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Bots/ActivityRouterDialog.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Bots/ActivityRouterDialog.cs new file mode 100644 index 000000000..b60c246d8 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Bots/ActivityRouterDialog.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.TraceExtensions; +using Microsoft.Bot.Schema; +using Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Bots +{ + /// + /// A root dialog that can route activities sent to the skill to different dialogs. + /// + public class ActivityRouterDialog : ComponentDialog + { + private readonly DialogSkillBotRecognizer _luisRecognizer; + + public ActivityRouterDialog(DialogSkillBotRecognizer luisRecognizer, IConfiguration configuration) + : base(nameof(ActivityRouterDialog)) + { + _luisRecognizer = luisRecognizer; + + AddDialog(new BookingDialog()); + AddDialog(new OAuthTestDialog(configuration)); + AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { ProcessActivityAsync })); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + private async Task ProcessActivityAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + // A skill can send trace activities if needed :) + await stepContext.Context.TraceActivityAsync($"{GetType().Name}.ProcessActivityAsync()", label: $"Got ActivityType: {stepContext.Context.Activity.Type}", cancellationToken: cancellationToken); + + switch (stepContext.Context.Activity.Type) + { + case ActivityTypes.Message: + return await OnMessageActivityAsync(stepContext, cancellationToken); + + case ActivityTypes.Invoke: + return await OnInvokeActivityAsync(stepContext, cancellationToken); + + case ActivityTypes.Event: + return await OnEventActivityAsync(stepContext, cancellationToken); + + default: + // We didn't get an activity type we can handle. + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Unrecognized ActivityType: \"{stepContext.Context.Activity.Type}\".", inputHint: InputHints.IgnoringInput), cancellationToken); + return new DialogTurnResult(DialogTurnStatus.Complete); + } + } + + // This method performs different tasks based on the event name. + private async Task OnEventActivityAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var activity = stepContext.Context.Activity; + await stepContext.Context.TraceActivityAsync($"{GetType().Name}.OnEventActivityAsync()", label: $"Name: {activity.Name}. Value: {GetObjectAsJsonString(activity.Value)}", cancellationToken: cancellationToken); + + // Resolve what to execute based on the event name. + switch (activity.Name) + { + case "BookFlight": + var bookingDetails = new BookingDetails(); + if (activity.Value != null) + { + bookingDetails = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(activity.Value)); + } + + // Start the booking dialog + var bookingDialog = FindDialog(nameof(BookingDialog)); + return await stepContext.BeginDialogAsync(bookingDialog.Id, bookingDetails, cancellationToken); + + case "OAuthTest": + // Start the OAuthTestDialog + var oAuthDialog = FindDialog(nameof(OAuthTestDialog)); + return await stepContext.BeginDialogAsync(oAuthDialog.Id, null, cancellationToken); + + default: + // We didn't get an event name we can handle. + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Unrecognized EventName: \"{activity.Name}\".", inputHint: InputHints.IgnoringInput), cancellationToken); + return new DialogTurnResult(DialogTurnStatus.Complete); + } + } + + // This method responds right away using an invokeResponse based on the activity name property. + private async Task OnInvokeActivityAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var activity = stepContext.Context.Activity; + await stepContext.Context.TraceActivityAsync($"{GetType().Name}.OnInvokeActivityAsync()", label: $"Name: {activity.Name}. Value: {GetObjectAsJsonString(activity.Value)}", cancellationToken: cancellationToken); + + // Resolve what to execute based on the invoke name. + switch (activity.Name) + { + case "GetWeather": + var location = new Location(); + if (activity.Value != null) + { + location = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(activity.Value)); + } + + var lookingIntoItMessage = "Getting your weather forecast..."; + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"{lookingIntoItMessage} \n\nValue parameters: {JsonConvert.SerializeObject(location)}", lookingIntoItMessage, inputHint: InputHints.IgnoringInput), cancellationToken); + + // Create and return an invoke activity with the weather results. + var invokeResponseActivity = new Activity(type: "invokeResponse") + { + Value = new InvokeResponse + { + Body = new[] + { + "New York, NY, Clear, 56 F", + "Bellevue, WA, Mostly Cloudy, 48 F" + }, + Status = (int)HttpStatusCode.OK + } + }; + await stepContext.Context.SendActivityAsync(invokeResponseActivity, cancellationToken); + break; + + default: + // We didn't get an invoke name we can handle. + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"Unrecognized InvokeName: \"{activity.Name}\".", inputHint: InputHints.IgnoringInput), cancellationToken); + break; + } + + return new DialogTurnResult(DialogTurnStatus.Complete); + } + + // This method just gets a message activity and runs it through LUIS. + // A developer can chose to start a dialog based on the LUIS results (not implemented here). + private async Task OnMessageActivityAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var activity = stepContext.Context.Activity; + await stepContext.Context.TraceActivityAsync($"{GetType().Name}.OnMessageActivityAsync()", label: $"Text: \"{activity.Text}\". Value: {GetObjectAsJsonString(activity.Value)}", cancellationToken: cancellationToken); + + if (!_luisRecognizer.IsConfigured) + { + await stepContext.Context.SendActivityAsync(MessageFactory.Text("NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and 'LuisAPIHostName' to the appsettings.json file.", inputHint: InputHints.IgnoringInput), cancellationToken); + } + else + { + // Call LUIS with the utterance. + var luisResult = await _luisRecognizer.RecognizeAsync(stepContext.Context, cancellationToken); + + // Create a message showing the LUIS results. + var sb = new StringBuilder(); + sb.AppendLine($"LUIS results for \"{activity.Text}\":"); + var (intent, intentScore) = luisResult.Intents.FirstOrDefault(x => x.Value.Equals(luisResult.Intents.Values.Max())); + sb.AppendLine($"Intent: \"{intent}\" Score: {intentScore.Score}"); + sb.AppendLine($"Entities found: {luisResult.Entities.Count - 1}"); + foreach (var luisResultEntity in luisResult.Entities) + { + if (!luisResultEntity.Key.Equals("$instance")) + { + sb.AppendLine($"* {luisResultEntity.Key}"); + } + } + + await stepContext.Context.SendActivityAsync(MessageFactory.Text(sb.ToString(), inputHint: InputHints.IgnoringInput), cancellationToken); + } + + return new DialogTurnResult(DialogTurnStatus.Complete); + } + + private string GetObjectAsJsonString(object value) + { + return value == null ? string.Empty : JsonConvert.SerializeObject(value); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Bots/SkillBot.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Bots/SkillBot.cs new file mode 100644 index 000000000..97dd953da --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Bots/SkillBot.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Bots +{ + public class SkillBot : IBot + where T : Dialog + { + private readonly ConversationState _conversationState; + private readonly DialogManager _dialogManager; + + public SkillBot(ConversationState conversationState, T mainDialog) + { + _conversationState = conversationState; + _dialogManager = new DialogManager(mainDialog); + } + + public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + await _dialogManager.OnTurnAsync(turnContext, cancellationToken); + + // Save any state changes that might have occured during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/CognitiveModels/FlightBooking.json b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/CognitiveModels/FlightBooking.json new file mode 100644 index 000000000..f0e4b9770 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/CognitiveModels/FlightBooking.json @@ -0,0 +1,339 @@ +{ + "luis_schema_version": "3.2.0", + "versionId": "0.1", + "name": "FlightBooking", + "desc": "Luis Model for CoreBot", + "culture": "en-us", + "tokenizerVersion": "1.0.0", + "intents": [ + { + "name": "BookFlight" + }, + { + "name": "Cancel" + }, + { + "name": "GetWeather" + }, + { + "name": "None" + } + ], + "entities": [], + "composites": [ + { + "name": "From", + "children": [ + "Airport" + ], + "roles": [] + }, + { + "name": "To", + "children": [ + "Airport" + ], + "roles": [] + } + ], + "closedLists": [ + { + "name": "Airport", + "subLists": [ + { + "canonicalForm": "Paris", + "list": [ + "paris", + "cdg" + ] + }, + { + "canonicalForm": "London", + "list": [ + "london", + "lhr" + ] + }, + { + "canonicalForm": "Berlin", + "list": [ + "berlin", + "txl" + ] + }, + { + "canonicalForm": "New York", + "list": [ + "new york", + "jfk" + ] + }, + { + "canonicalForm": "Seattle", + "list": [ + "seattle", + "sea" + ] + } + ], + "roles": [] + } + ], + "patternAnyEntities": [], + "regex_entities": [], + "prebuiltEntities": [ + { + "name": "datetimeV2", + "roles": [] + } + ], + "model_features": [], + "regex_features": [], + "patterns": [], + "utterances": [ + { + "text": "book a flight", + "intent": "BookFlight", + "entities": [] + }, + { + "text": "book a flight from new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 26 + } + ] + }, + { + "text": "book a flight from seattle", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 25 + } + ] + }, + { + "text": "book a hotel in new york", + "intent": "None", + "entities": [] + }, + { + "text": "book a restaurant", + "intent": "None", + "entities": [] + }, + { + "text": "book flight from london to paris on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 17, + "endPos": 22 + }, + { + "entity": "To", + "startPos": 27, + "endPos": 31 + } + ] + }, + { + "text": "book flight to berlin on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 15, + "endPos": 20 + } + ] + }, + { + "text": "book me a flight from london to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 22, + "endPos": 27 + }, + { + "entity": "To", + "startPos": 32, + "endPos": 36 + } + ] + }, + { + "text": "bye", + "intent": "Cancel", + "entities": [] + }, + { + "text": "cancel booking", + "intent": "Cancel", + "entities": [] + }, + { + "text": "exit", + "intent": "Cancel", + "entities": [] + }, + { + "text": "find an airport near me", + "intent": "None", + "entities": [] + }, + { + "text": "flight to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "flight to paris from london on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + }, + { + "entity": "From", + "startPos": 21, + "endPos": 26 + } + ] + }, + { + "text": "fly from berlin to paris on may 5th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 9, + "endPos": 14 + }, + { + "entity": "To", + "startPos": 19, + "endPos": 23 + } + ] + }, + { + "text": "go to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 6, + "endPos": 10 + } + ] + }, + { + "text": "going from paris to berlin", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 11, + "endPos": 15 + }, + { + "entity": "To", + "startPos": 20, + "endPos": 25 + } + ] + }, + { + "text": "i'd like to rent a car", + "intent": "None", + "entities": [] + }, + { + "text": "ignore", + "intent": "Cancel", + "entities": [] + }, + { + "text": "travel from new york to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 12, + "endPos": 19 + }, + { + "entity": "To", + "startPos": 24, + "endPos": 28 + } + ] + }, + { + "text": "travel to new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 17 + } + ] + }, + { + "text": "travel to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "what's the forecast for this friday?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like for tomorrow", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like in new york", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "winter is coming", + "intent": "None", + "entities": [] + } + ], + "settings": [] +} \ No newline at end of file diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Controllers/BotController.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Controllers/BotController.cs new file mode 100644 index 000000000..3fea4ed0e --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Controllers/BotController.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Controllers +{ + // 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")] + [ApiController] + public class BotController : ControllerBase + { + private readonly IBotFrameworkHttpAdapter _adapter; + private readonly IBot _bot; + + public BotController(IBotFrameworkHttpAdapter adapter, IBot bot) + { + _adapter = adapter; + _bot = bot; + } + + [HttpGet] + [HttpPost] + public async Task PostAsync() + { + await _adapter.ProcessAsync(Request, Response, _bot); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/BookingDetails.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/BookingDetails.cs new file mode 100644 index 000000000..1eb49aa35 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/BookingDetails.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs +{ + public class BookingDetails + { + [JsonProperty("destination")] + public string Destination { get; set; } + + [JsonProperty("origin")] + public string Origin { get; set; } + + [JsonProperty("travelDate")] + public string TravelDate { get; set; } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/BookingDialog.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/BookingDialog.cs new file mode 100644 index 000000000..ed86a7491 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/BookingDialog.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; +using Microsoft.Recognizers.Text.DataTypes.TimexExpression; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs +{ + public class BookingDialog : CancelAndHelpDialog + { + private const string DestinationStepMsgText = "Where would you like to travel to?"; + private const string OriginStepMsgText = "Where are you traveling from?"; + + public BookingDialog() + : base(nameof(BookingDialog)) + { + AddDialog(new TextPrompt(nameof(TextPrompt))); + AddDialog(new ConfirmPrompt(nameof(ConfirmPrompt))); + AddDialog(new DateResolverDialog()); + AddDialog( + new WaterfallDialog( + nameof(WaterfallDialog), + new WaterfallStep[] { DestinationStepAsync, OriginStepAsync, TravelDateStepAsync, ConfirmStepAsync, FinalStepAsync })); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + private static bool IsAmbiguous(string timex) + { + var timexProperty = new TimexProperty(timex); + return !timexProperty.Types.Contains(Constants.TimexTypes.Definite); + } + + private async Task DestinationStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var bookingDetails = (BookingDetails)stepContext.Options; + + if (bookingDetails.Destination == null) + { + var promptMessage = MessageFactory.Text(DestinationStepMsgText, DestinationStepMsgText, InputHints.ExpectingInput); + return await stepContext.PromptAsync(nameof(TextPrompt), new PromptOptions { Prompt = promptMessage }, cancellationToken); + } + + return await stepContext.NextAsync(bookingDetails.Destination, cancellationToken); + } + + private async Task OriginStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var bookingDetails = (BookingDetails)stepContext.Options; + + bookingDetails.Destination = (string)stepContext.Result; + + if (bookingDetails.Origin == null) + { + var promptMessage = MessageFactory.Text(OriginStepMsgText, OriginStepMsgText, InputHints.ExpectingInput); + return await stepContext.PromptAsync(nameof(TextPrompt), new PromptOptions { Prompt = promptMessage }, cancellationToken); + } + + return await stepContext.NextAsync(bookingDetails.Origin, cancellationToken); + } + + private async Task TravelDateStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var bookingDetails = (BookingDetails)stepContext.Options; + + bookingDetails.Origin = (string)stepContext.Result; + + if (bookingDetails.TravelDate == null || IsAmbiguous(bookingDetails.TravelDate)) + { + return await stepContext.BeginDialogAsync(nameof(DateResolverDialog), bookingDetails.TravelDate, cancellationToken); + } + + return await stepContext.NextAsync(bookingDetails.TravelDate, cancellationToken); + } + + private async Task ConfirmStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var bookingDetails = (BookingDetails)stepContext.Options; + + bookingDetails.TravelDate = (string)stepContext.Result; + + var messageText = $"Please confirm, I have you traveling to: {bookingDetails.Destination} from: {bookingDetails.Origin} on: {bookingDetails.TravelDate}. Is this correct?"; + var promptMessage = MessageFactory.Text(messageText, messageText, InputHints.ExpectingInput); + + return await stepContext.PromptAsync(nameof(ConfirmPrompt), new PromptOptions { Prompt = promptMessage }, cancellationToken); + } + + private async Task FinalStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + if ((bool)stepContext.Result) + { + var bookingDetails = (BookingDetails)stepContext.Options; + + return await stepContext.EndDialogAsync(bookingDetails, cancellationToken); + } + + return await stepContext.EndDialogAsync(null, cancellationToken); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/CancelAndHelpDialog.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/CancelAndHelpDialog.cs new file mode 100644 index 000000000..1a2fd44e3 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/CancelAndHelpDialog.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs +{ + public class CancelAndHelpDialog : ComponentDialog + { + private const string HelpMsgText = "Show help here"; + private const string CancelMsgText = "Cancelling..."; + + public CancelAndHelpDialog(string id) + : base(id) + { + } + + protected override async Task OnContinueDialogAsync(DialogContext innerDc, CancellationToken cancellationToken = default) + { + var result = await InterruptAsync(innerDc, cancellationToken); + if (result != null) + { + return result; + } + + return await base.OnContinueDialogAsync(innerDc, cancellationToken); + } + + private async Task InterruptAsync(DialogContext innerDc, CancellationToken cancellationToken) + { + if (innerDc.Context.Activity.Type == ActivityTypes.Message) + { + var text = innerDc.Context.Activity.Text.ToLowerInvariant(); + + switch (text) + { + case "help": + case "?": + var helpMessage = MessageFactory.Text(HelpMsgText, HelpMsgText, InputHints.ExpectingInput); + await innerDc.Context.SendActivityAsync(helpMessage, cancellationToken); + return new DialogTurnResult(DialogTurnStatus.Waiting); + + case "cancel": + case "quit": + var cancelMessage = MessageFactory.Text(CancelMsgText, CancelMsgText, InputHints.IgnoringInput); + await innerDc.Context.SendActivityAsync(cancelMessage, cancellationToken); + return await innerDc.CancelAllDialogsAsync(true, cancellationToken: cancellationToken); + } + } + + return null; + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/DateResolverDialog.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/DateResolverDialog.cs new file mode 100644 index 000000000..380ab06ef --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/DateResolverDialog.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; +using Microsoft.Recognizers.Text.DataTypes.TimexExpression; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs +{ + public class DateResolverDialog : CancelAndHelpDialog + { + private const string PromptMsgText = "When would you like to travel?"; + private const string RepromptMsgText = "I'm sorry, to make your booking please enter a full travel date including Day Month and Year."; + + public DateResolverDialog(string id = null) + : base(id ?? nameof(DateResolverDialog)) + { + AddDialog(new DateTimePrompt(nameof(DateTimePrompt), DateTimePromptValidator)); + AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { InitialStepAsync, FinalStepAsync })); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + private static Task DateTimePromptValidator(PromptValidatorContext> promptContext, CancellationToken cancellationToken) + { + if (promptContext.Recognized.Succeeded) + { + // This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. + // TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. + var timex = promptContext.Recognized.Value[0].Timex.Split('T')[0]; + + // If this is a definite Date including year, month and day we are good otherwise reprompt. + // A better solution might be to let the user know what part is actually missing. + var isDefinite = new TimexProperty(timex).Types.Contains(Constants.TimexTypes.Definite); + + return Task.FromResult(isDefinite); + } + + return Task.FromResult(false); + } + + private async Task InitialStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var timex = (string)stepContext.Options; + + var promptMessage = MessageFactory.Text(PromptMsgText, PromptMsgText, InputHints.ExpectingInput); + var repromptMessage = MessageFactory.Text(RepromptMsgText, RepromptMsgText, InputHints.ExpectingInput); + + if (timex == null) + { + // We were not given any date at all so prompt the user. + return await stepContext.PromptAsync( + nameof(DateTimePrompt), + new PromptOptions + { + Prompt = promptMessage, + RetryPrompt = repromptMessage, + }, cancellationToken); + } + + // We have a Date we just need to check it is unambiguous. + var timexProperty = new TimexProperty(timex); + if (!timexProperty.Types.Contains(Constants.TimexTypes.Definite)) + { + // This is essentially a "reprompt" of the data we were given up front. + return await stepContext.PromptAsync( + nameof(DateTimePrompt), + new PromptOptions + { + Prompt = repromptMessage, + }, cancellationToken); + } + + return await stepContext.NextAsync(new List { new DateTimeResolution { Timex = timex } }, cancellationToken); + } + + private async Task FinalStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + var timex = ((List)stepContext.Result)[0].Timex; + return await stepContext.EndDialogAsync(timex, cancellationToken); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/DialogSkillBotRecognizer.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/DialogSkillBotRecognizer.cs new file mode 100644 index 000000000..8b5d0ae7d --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/DialogSkillBotRecognizer.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.AI.Luis; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs +{ + public class DialogSkillBotRecognizer : IRecognizer + { + private readonly LuisRecognizer _recognizer; + + public DialogSkillBotRecognizer(IConfiguration configuration) + { + var luisIsConfigured = !string.IsNullOrEmpty(configuration["LuisAppId"]) && !string.IsNullOrEmpty(configuration["LuisAPIKey"]) && !string.IsNullOrEmpty(configuration["LuisAPIHostName"]); + if (luisIsConfigured) + { + var luisApplication = new LuisApplication( + configuration["LuisAppId"], + configuration["LuisAPIKey"], + "https://" + configuration["LuisAPIHostName"]); + var luisOptions = new LuisRecognizerOptionsV2(luisApplication); + + _recognizer = new LuisRecognizer(luisOptions); + } + } + + // Returns true if luis is configured in the appsettings.json and initialized. + public virtual bool IsConfigured => _recognizer != null; + + public virtual async Task RecognizeAsync(ITurnContext turnContext, CancellationToken cancellationToken) + => await _recognizer.RecognizeAsync(turnContext, cancellationToken); + + public virtual async Task RecognizeAsync(ITurnContext turnContext, CancellationToken cancellationToken) + where T : IRecognizerConvert, new() + => await _recognizer.RecognizeAsync(turnContext, cancellationToken); + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/Location.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/Location.cs new file mode 100644 index 000000000..67be23503 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/Location.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs +{ + public class Location + { + [JsonProperty("latitude")] + public float? Latitude { get; set; } + + [JsonProperty("longitude")] + public float? Longitude { get; set; } + + [JsonProperty("postalCode")] + public string PostalCode { get; set; } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/OAuthTestDialog.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/OAuthTestDialog.cs new file mode 100644 index 000000000..4c97b2554 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Dialogs/OAuthTestDialog.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs +{ + public class OAuthTestDialog : CancelAndHelpDialog + { + private readonly string _connectionName; + + public OAuthTestDialog(IConfiguration configuration) + : base(nameof(OAuthTestDialog)) + { + _connectionName = configuration["ConnectionName"]; + + AddDialog(new OAuthPrompt( + nameof(OAuthPrompt), + new OAuthPromptSettings + { + ConnectionName = _connectionName, + Text = $"Please Sign In to connection: '{_connectionName}'", + Title = "Sign In", + Timeout = 300000 // User has 5 minutes to login (1000 * 60 * 5) + })); + + AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { PromptStepAsync, LoginStepAsync })); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + private async Task PromptStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken); + } + + private async Task LoginStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + // Get the token from the previous step. + var tokenResponse = (TokenResponse)stepContext.Result; + if (tokenResponse != null) + { + // Show the token + var loggedInMessage = "You are now logged in."; + await stepContext.Context.SendActivityAsync(MessageFactory.Text(loggedInMessage, loggedInMessage, InputHints.IgnoringInput), cancellationToken); + var showTokenMessage = "Here is your token:"; + await stepContext.Context.SendActivityAsync(MessageFactory.Text($"{showTokenMessage} {tokenResponse.Token}", showTokenMessage, InputHints.IgnoringInput), cancellationToken); + + // Sign out + var botAdapter = (BotFrameworkAdapter)stepContext.Context.Adapter; + await botAdapter.SignOutUserAsync(stepContext.Context, _connectionName, null, cancellationToken); + var signOutMessage = "I have signed you out."; + await stepContext.Context.SendActivityAsync(MessageFactory.Text(signOutMessage, signOutMessage, inputHint: InputHints.IgnoringInput), cancellationToken); + + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + + var tryAgainMessage = "Login was not successful please try again."; + await stepContext.Context.SendActivityAsync(MessageFactory.Text(tryAgainMessage, tryAgainMessage, InputHints.IgnoringInput), cancellationToken); + return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Program.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Program.cs new file mode 100644 index 000000000..dc49788d3 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Program.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/SkillAdapterWithErrorHandler.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/SkillAdapterWithErrorHandler.cs new file mode 100644 index 000000000..7cf3b190f --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/SkillAdapterWithErrorHandler.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.TraceExtensions; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot +{ + public class SkillAdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public SkillAdapterWithErrorHandler(IConfiguration configuration, ILogger logger, IStorage storage, UserState userState, ConversationState conversationState) + : base(configuration, logger) + { + this.UseStorage(storage); + this.UseState(userState, conversationState, false); + + OnTurnError = async (turnContext, exception) => + { + // Log any leaked exception from the application. + logger.LogError(exception, $"[OnTurnError] unhandled error : {exception.Message}"); + + // Send a message to the user + var errorMessageText = "The skill encountered an error or bug."; + var errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.IgnoringInput); + await turnContext.SendActivityAsync(errorMessage); + + errorMessageText = "To continue to run this bot, please fix the bot source code."; + errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.ExpectingInput); + await turnContext.SendActivityAsync(errorMessage); + + 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 ex) + { + logger.LogError(ex, $"Exception caught on attempting to Delete ConversationState : {ex}"); + } + } + + // Send and EndOfConversation activity to the skill caller with the error to end the conversation + // and let the caller decide what to do. + var endOfConversation = Activity.CreateEndOfConversationActivity(); + endOfConversation.Code = "SkillError"; + endOfConversation.Text = exception.Message; + await turnContext.SendActivityAsync(endOfConversation); + + // Send a trace activity, which will be displayed in the Bot Framework Emulator + // Note: we return the entire exception in the value property to help the developer, this should not be done in prod. + await turnContext.TraceActivityAsync("OnTurnError Trace", exception.ToString(), "https://www.botframework.com/schemas/error", "TurnError"); + }; + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Startup.cs b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Startup.cs new file mode 100644 index 000000000..be1a26498 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/Startup.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.BotBuilderSamples.AdaptiveSkillBot.Bots; +using Microsoft.BotBuilderSamples.AdaptiveSkillBot.Dialogs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.BotBuilderSamples.AdaptiveSkillBot +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers() + .AddNewtonsoftJson(); + + // Create the Bot Framework Adapter with error handling enabled. + services.AddSingleton(); + + // Create the storage we'll be using for User and Conversation state. (Memory is great for testing purposes.) + services.AddSingleton(); + + // Create the Conversation state. (Used by the Dialog system itself.) + services.AddSingleton(); + + // Create the User state. (Used by the Dialog system itself.) + services.AddSingleton(); + + // Register LUIS recognizer + services.AddSingleton(); + + // The Dialog that will be run by the bot. + services.AddSingleton(); + + // Create the bot as a transient. In this case the ASP Controller is expecting an IBot. + services.AddTransient>(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseDefaultFiles(); + app.UseStaticFiles(); + + //app.UseHttpsRedirection(); Enable this to support https + + app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/appsettings.json b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/appsettings.json new file mode 100644 index 000000000..b2de4325f --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + "ConnectionName": "", + + "LuisAppId": "", + "LuisAPIKey": "", + "LuisAPIHostName": "" +} diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/wwwroot/default.htm b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/wwwroot/default.htm new file mode 100644 index 000000000..13a72f652 --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/wwwroot/default.htm @@ -0,0 +1,420 @@ + + + + + + + DialogSkillBot + + + + + +
+
+
+
DialogSkillBot Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
+ +
Visit Azure + Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks + like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/wwwroot/manifest/dialogchildbot-manifest-1.0.json b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/wwwroot/manifest/dialogchildbot-manifest-1.0.json new file mode 100644 index 000000000..251c3081c --- /dev/null +++ b/FunctionalTests/Skills/Adaptive/AdaptiveSkillBot/wwwroot/manifest/dialogchildbot-manifest-1.0.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://schemas.botframework.com/schemas/skills/skill-manifest-2.0.0.json", + "$id": "DialogSkillBot", + "name": "Skill bot with dialogs", + "version": "1.0", + "description": "This is a sample skill definition for multiple activity types", + "publisherName": "Microsoft", + "privacyUrl": "https://dialogskillbot.contoso.com/privacy.html", + "copyright": "Copyright (c) Microsoft Corporation. All rights reserved.", + "license": "", + "iconUrl": "https://dialogskillbot.contoso.com/icon.png", + "tags": [ + "sample", + "travel", + "weather", + "luis" + ], + "endpoints": [ + { + "name": "default", + "protocol": "BotFrameworkV3", + "description": "Default endpoint for the skill", + "endpointUrl": "https://ggdialogskillbot.azurewebsites.net/api/messages", + "msAppId": "f3fe8762-e50c-4688-b202-a040f522d916" + } + ], + "activities": { + "bookFlight": { + "description": "Books a flight (multi turn)", + "type": "event", + "name": "BookFlight", + "value": { + "$ref": "#/definitions/bookingInfo" + }, + "resultValue": { + "$ref": "#/definitions/bookingInfo" + } + }, + "getWeather": { + "description": "Retrieves and returns the weather for the user's location (single turn, invoke)", + "type": "invoke", + "name": "GetWeather", + "value": { + "$ref": "#/definitions/location" + }, + "resultValue": { + "$ref": "#/definitions/weatherReport" + } + }, + "passthroughMessage": { + "type": "message", + "description": "Receives the user's utterance and attempts to resolve it using the skill's LUIS models", + "value": { + "type": "object" + } + } + }, + "definitions": { + "localeValue": { + "type": "object", + "properties": { + "locale": { + "type": "string", + "description": "The current user's locale ISO code" + } + } + }, + "bookingInfo": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "origin": { + "type": "string", + "description": "this is the origin city for the flight" + }, + "destination": { + "type": "string", + "description": "this is the destination city for the flight" + }, + "travelDate": { + "type": "string", + "description": "The date for the flight in YYYY-MM-DD format" + } + } + }, + "weatherReport": { + "type": "array", + "description": "Array of forecasts for the next week.", + "items": [ + { + "type": "string" + } + ] + }, + "location": { + "type": "object", + "description": "Location metadata", + "properties": { + "latitude": { + "type": "number", + "title": "Latitude" + }, + "longitude": { + "type": "number", + "title": "Longitude" + }, + "postalCode": { + "type": "string", + "title": "Postal code" + } + } + } + } +} \ No newline at end of file diff --git a/Microsoft.Bot.Builder.Skills.sln b/Microsoft.Bot.Builder.Skills.sln index cdc0929ef..05dcc6bd9 100644 --- a/Microsoft.Bot.Builder.Skills.sln +++ b/Microsoft.Bot.Builder.Skills.sln @@ -160,6 +160,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DialogSkillBot", "Functiona EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Bot.Builder.Dialogs.Adaptive.Testing", "libraries\Microsoft.Bot.Builder.Dialogs.Adaptive.Testing\Microsoft.Bot.Builder.Dialogs.Adaptive.Testing.csproj", "{27ADE3CC-43B5-4563-B6D1-4103E8A6ECCD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Adaptive", "Adaptive", "{AE7143FE-C460-449D-AA50-EFA4820EBADE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRootBot", "FunctionalTests\Skills\Adaptive\AdaptiveRootBot\AdaptiveRootBot.csproj", "{1E638E89-43B9-4381-8CC7-B65548DB0070}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveSkillBot", "FunctionalTests\Skills\Adaptive\AdaptiveSkillBot\AdaptiveSkillBot.csproj", "{3EA28B80-440E-4919-9850-31236968BC04}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -632,6 +638,22 @@ Global {27ADE3CC-43B5-4563-B6D1-4103E8A6ECCD}.Release|Any CPU.Build.0 = Release|Any CPU {27ADE3CC-43B5-4563-B6D1-4103E8A6ECCD}.Release-Windows|Any CPU.ActiveCfg = Release|Any CPU {27ADE3CC-43B5-4563-B6D1-4103E8A6ECCD}.Release-Windows|Any CPU.Build.0 = Release|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Debug-Windows|Any CPU.ActiveCfg = Debug|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Debug-Windows|Any CPU.Build.0 = Debug|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Release|Any CPU.Build.0 = Release|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Release-Windows|Any CPU.ActiveCfg = Release|Any CPU + {1E638E89-43B9-4381-8CC7-B65548DB0070}.Release-Windows|Any CPU.Build.0 = Release|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Debug-Windows|Any CPU.ActiveCfg = Debug|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Debug-Windows|Any CPU.Build.0 = Debug|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Release|Any CPU.Build.0 = Release|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Release-Windows|Any CPU.ActiveCfg = Release|Any CPU + {3EA28B80-440E-4919-9850-31236968BC04}.Release-Windows|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -702,6 +724,9 @@ Global {31D0EA80-192C-4645-911D-F0393535B176} = {3E023AB7-FE1F-41B1-9EF4-1550BCE1DC37} {AB75F219-CFA4-4051-8A11-3EE7A128AF08} = {3E023AB7-FE1F-41B1-9EF4-1550BCE1DC37} {27ADE3CC-43B5-4563-B6D1-4103E8A6ECCD} = {4269F3C3-6B42-419B-B64A-3E6DC0F1574A} + {AE7143FE-C460-449D-AA50-EFA4820EBADE} = {3C16F609-61D8-49C5-A243-9D32B9491D91} + {1E638E89-43B9-4381-8CC7-B65548DB0070} = {AE7143FE-C460-449D-AA50-EFA4820EBADE} + {3EA28B80-440E-4919-9850-31236968BC04} = {AE7143FE-C460-449D-AA50-EFA4820EBADE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7173C9F3-A7F9-496E-9078-9156E35D6E16} diff --git a/libraries/Microsoft.Bot.Builder.Dialogs.Adaptive/DialogManager.cs b/libraries/Microsoft.Bot.Builder.Dialogs.Adaptive/DialogManager.cs index 15b23df3e..062a008d1 100644 --- a/libraries/Microsoft.Bot.Builder.Dialogs.Adaptive/DialogManager.cs +++ b/libraries/Microsoft.Bot.Builder.Dialogs.Adaptive/DialogManager.cs @@ -2,9 +2,13 @@ // Licensed under the MIT License. using System; +using System.Security.Claims; +using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder.Dialogs.Memory; +using Microsoft.Bot.Builder.TraceExtensions; +using Microsoft.Bot.Connector.Authentication; using Microsoft.Bot.Schema; using Newtonsoft.Json; @@ -15,23 +19,23 @@ namespace Microsoft.Bot.Builder.Dialogs /// public class DialogManager { - private const string LASTACCESS = "_lastAccess"; - private string rootDialogId; - private string dialogStateProperty; + private const string LastAccess = "_lastAccess"; + private string _rootDialogId; + private readonly string _dialogStateProperty; /// /// Initializes a new instance of the class. /// - /// rootdialog to use. + /// Root dialog to use. /// alternate name for the dialogState property. (Default is "DialogState"). public DialogManager(Dialog rootDialog = null, string dialogStateProperty = null) { if (rootDialog != null) { - this.RootDialog = rootDialog; + RootDialog = rootDialog; } - this.dialogStateProperty = dialogStateProperty ?? "DialogState"; + _dialogStateProperty = dialogStateProperty ?? "DialogState"; } /// @@ -56,7 +60,7 @@ namespace Microsoft.Bot.Builder.Dialogs /// /// TurnState. /// - public TurnContextStateCollection TurnState { get; private set; } = new TurnContextStateCollection(); + public TurnContextStateCollection TurnState { get; } = new TurnContextStateCollection(); /// /// Gets or sets root dialog to use to start conversation. @@ -68,9 +72,9 @@ namespace Microsoft.Bot.Builder.Dialogs { get { - if (this.rootDialogId != null) + if (_rootDialogId != null) { - return this.Dialogs.Find(this.rootDialogId); + return Dialogs.Find(_rootDialogId); } return null; @@ -78,16 +82,16 @@ namespace Microsoft.Bot.Builder.Dialogs set { - this.Dialogs = new DialogSet(); + Dialogs = new DialogSet(); if (value != null) { - this.rootDialogId = value.Id; - this.Dialogs.TelemetryClient = value.TelemetryClient; - this.Dialogs.Add(value); + _rootDialogId = value.Id; + Dialogs.TelemetryClient = value.TelemetryClient; + Dialogs.Add(value); } else { - this.rootDialogId = null; + _rootDialogId = null; } } } @@ -119,63 +123,62 @@ namespace Microsoft.Bot.Builder.Dialogs /// Runs dialog system in the context of an ITurnContext. /// /// turn context. - /// cancelation token. + /// Cancellation token. /// result of the running the logic against the activity. - public async Task OnTurnAsync(ITurnContext context, CancellationToken cancellationToken = default(CancellationToken)) + public async Task OnTurnAsync(ITurnContext context, CancellationToken cancellationToken = default) { var botStateSet = new BotStateSet(); - // preload turnstate with DM turnstate - foreach (var pair in this.TurnState) + // Preload TurnState with DM TurnState. + foreach (var pair in TurnState) { context.TurnState.Set(pair.Key, pair.Value); } - if (this.ConversationState == null) + if (ConversationState == null) { - this.ConversationState = context.TurnState.Get() ?? throw new ArgumentNullException(nameof(this.ConversationState)); + ConversationState = context.TurnState.Get() ?? throw new ArgumentNullException(nameof(ConversationState)); } else { - context.TurnState.Set(this.ConversationState); + context.TurnState.Set(ConversationState); } - botStateSet.Add(this.ConversationState); + botStateSet.Add(ConversationState); - if (this.UserState == null) + if (UserState == null) { - this.UserState = context.TurnState.Get(); + UserState = context.TurnState.Get(); } - if (this.UserState != null) + if (UserState != null) { - botStateSet.Add(this.UserState); + botStateSet.Add(UserState); } // create property accessors - var lastAccessProperty = ConversationState.CreateProperty(LASTACCESS); - var lastAccess = await lastAccessProperty.GetAsync(context, () => DateTime.UtcNow, cancellationToken: cancellationToken).ConfigureAwait(false); + var lastAccessProperty = ConversationState.CreateProperty(LastAccess); + var lastAccess = await lastAccessProperty.GetAsync(context, () => DateTime.UtcNow, cancellationToken).ConfigureAwait(false); // Check for expired conversation - var now = DateTime.UtcNow; - if (this.ExpireAfter.HasValue && (DateTime.UtcNow - lastAccess) >= TimeSpan.FromMilliseconds((double)this.ExpireAfter)) + if (ExpireAfter.HasValue && (DateTime.UtcNow - lastAccess) >= TimeSpan.FromMilliseconds((double)ExpireAfter)) { // Clear conversation state - await ConversationState.ClearStateAsync(context, cancellationToken: cancellationToken).ConfigureAwait(false); + await ConversationState.ClearStateAsync(context, cancellationToken).ConfigureAwait(false); } lastAccess = DateTime.UtcNow; - await lastAccessProperty.SetAsync(context, lastAccess, cancellationToken: cancellationToken).ConfigureAwait(false); + await lastAccessProperty.SetAsync(context, lastAccess, cancellationToken).ConfigureAwait(false); // get dialog stack - var dialogsProperty = ConversationState.CreateProperty(this.dialogStateProperty); - DialogState dialogState = await dialogsProperty.GetAsync(context, () => new DialogState(), cancellationToken: cancellationToken).ConfigureAwait(false); + var dialogsProperty = ConversationState.CreateProperty(_dialogStateProperty); + var dialogState = await dialogsProperty.GetAsync(context, () => new DialogState(), cancellationToken).ConfigureAwait(false); // Create DialogContext - var dc = new DialogContext(this.Dialogs, context, dialogState); + var dc = new DialogContext(Dialogs, context, dialogState); - // get the dialogstatemanager configuration - var dialogStateManager = new DialogStateManager(dc, this.StateConfiguration); + // get the DialogStateManager configuration + var dialogStateManager = new DialogStateManager(dc, StateConfiguration); await dialogStateManager.LoadAllScopesAsync(cancellationToken).ConfigureAwait(false); dc.Context.TurnState.Add(dialogStateManager); @@ -185,31 +188,25 @@ namespace Microsoft.Bot.Builder.Dialogs // // NOTE: We loop around this block because each pass through we either complete the turn and break out of the loop // or we have had an exception AND there was an OnError action which captured the error. We need to continue the - // turn based on the actions the OnError handler introduced. - while (true) + // turn based on the actions the OnError handler introduced. + var endOfTurn = false; + while (!endOfTurn) { try { - if (dc.ActiveDialog == null) + if (context.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims)) { - // start root dialog - turnResult = await dc.BeginDialogAsync(this.rootDialogId, cancellationToken: cancellationToken).ConfigureAwait(false); + // The bot is running as a skill. + turnResult = await HandleSkillOnTurnAsync(dc, cancellationToken).ConfigureAwait(false); } else { - // Continue execution - // - This will apply any queued up interruptions and execute the current/next step(s). - turnResult = await dc.ContinueDialogAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - - if (turnResult.Status == DialogTurnStatus.Empty) - { - // restart root dialog - turnResult = await dc.BeginDialogAsync(this.rootDialogId, cancellationToken: cancellationToken).ConfigureAwait(false); - } + // The bot is running as root bot. + turnResult = await HandleBotOnTurnAsync(dc, cancellationToken).ConfigureAwait(false); } // turn successfully completed, break the loop - break; + endOfTurn = true; } catch (Exception err) { @@ -227,21 +224,129 @@ namespace Microsoft.Bot.Builder.Dialogs // save all state scopes to their respective botState locations. await dialogStateManager.SaveAllChangesAsync(cancellationToken).ConfigureAwait(false); - // save botstate changes + // save BotState changes await botStateSet.SaveAllChangesAsync(dc.Context, false, cancellationToken).ConfigureAwait(false); + return new DialogManagerResult { TurnResult = turnResult }; + } + + /// + /// Helper to send a trace activity with a memory snapshot of the active dialog DC. + /// + private static async Task SendStateSnapshotTraceAsync(DialogContext dc, string traceLabel, CancellationToken cancellationToken) + { // send trace of memory - var snapshotDc = dc; - while (snapshotDc.Child != null) + var snapshot = GetActiveDialogContext(dc).State.GetMemorySnapshot(); + var traceActivity = (Activity)Activity.CreateTraceActivity("BotState", "https://www.botframework.com/schemas/botState", snapshot, traceLabel); + await dc.Context.SendActivityAsync(traceActivity, cancellationToken).ConfigureAwait(false); + } + + // We should only cancel the current dialog stack if the EoC activity is coming from a parent (a root bot or another skill). + // When the EoC is coming back from a child, we should just process that EoC normally through the + // dialog stack and let the child dialogs handle that. + private static bool IsEocComingFromParent(ITurnContext turnContext) + { + // To determine the direction we check callerId property which is set to the parent bot + // by the BotFrameworkHttpClient on outgoing requests. + return !string.IsNullOrWhiteSpace(turnContext.Activity.CallerId); + } + + // Recursively walk up the DC stack to find the active DC. + private static DialogContext GetActiveDialogContext(DialogContext dialogContext) + { + var child = dialogContext.Child; + if (child == null) { - snapshotDc = snapshotDc.Child; + return dialogContext; } - var snapshot = snapshotDc.State.GetMemorySnapshot(); - var traceActivity = (Activity)Activity.CreateTraceActivity("BotState", "https://www.botframework.com/schemas/botState", snapshot, "Bot State"); - await dc.Context.SendActivityAsync(traceActivity).ConfigureAwait(false); + return GetActiveDialogContext(child); + } - return new DialogManagerResult() { TurnResult = turnResult }; + private async Task HandleSkillOnTurnAsync(DialogContext dc, CancellationToken cancellationToken) + { + // the bot is running as a skill. + var turnContext = dc.Context; + + // Process remote cancellation + if (turnContext.Activity.Type == ActivityTypes.EndOfConversation && dc.ActiveDialog != null && IsEocComingFromParent(turnContext)) + { + // Handle remote cancellation request from parent. + var activeDialogContext = GetActiveDialogContext(dc); + + var remoteCancelText = "Skill was canceled through an EndOfConversation activity from the parent."; + await turnContext.TraceActivityAsync($"{typeof(Dialog).Name}.RunAsync()", label: $"{remoteCancelText}", cancellationToken: cancellationToken).ConfigureAwait(false); + + // Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the right order. + return await activeDialogContext.CancelAllDialogsAsync(true, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + // Handle reprompt + // Process a reprompt event sent from the parent. + if (turnContext.Activity.Type == ActivityTypes.Event && turnContext.Activity.Name == DialogEvents.RepromptDialog) + { + if (dc.ActiveDialog == null) + { + return new DialogTurnResult(DialogTurnStatus.Empty); + } + + await dc.RepromptDialogAsync(cancellationToken).ConfigureAwait(false); + return new DialogTurnResult(DialogTurnStatus.Waiting); + } + + // Continue execution + // - This will apply any queued up interruptions and execute the current/next step(s). + var turnResult = await dc.ContinueDialogAsync(cancellationToken).ConfigureAwait(false); + if (turnResult.Status == DialogTurnStatus.Empty) + { + // restart root dialog + var startMessageText = $"Starting {_rootDialogId}."; + await turnContext.TraceActivityAsync($"{typeof(Dialog).Name}.RunAsync()", label: $"{startMessageText}", cancellationToken: cancellationToken).ConfigureAwait(false); + turnResult = await dc.BeginDialogAsync(_rootDialogId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + await SendStateSnapshotTraceAsync(dc, "Skill State", cancellationToken).ConfigureAwait(false); + + // Send end of conversation if it is completed or cancelled. + if (turnResult.Status == DialogTurnStatus.Complete || turnResult.Status == DialogTurnStatus.Cancelled) + { + var endMessageText = $"Dialog {_rootDialogId} has **completed**. Sending EndOfConversation."; + await turnContext.TraceActivityAsync($"{typeof(Dialog).Name}.RunAsync()", label: $"{endMessageText}", value: turnResult.Result, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Send End of conversation at the end. + var activity = new Activity(ActivityTypes.EndOfConversation) { Value = turnResult.Result }; + await turnContext.SendActivityAsync(activity, cancellationToken).ConfigureAwait(false); + } + + return turnResult; + } + + private async Task HandleBotOnTurnAsync(DialogContext dc, CancellationToken cancellationToken) + { + DialogTurnResult turnResult; + + // the bot is running as a root bot. + if (dc.ActiveDialog == null) + { + // start root dialog + turnResult = await dc.BeginDialogAsync(_rootDialogId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + else + { + // Continue execution + // - This will apply any queued up interruptions and execute the current/next step(s). + turnResult = await dc.ContinueDialogAsync(cancellationToken).ConfigureAwait(false); + + if (turnResult.Status == DialogTurnStatus.Empty) + { + // restart root dialog + turnResult = await dc.BeginDialogAsync(_rootDialogId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + await SendStateSnapshotTraceAsync(dc, "Bot State", cancellationToken).ConfigureAwait(false); + + return turnResult; } } } diff --git a/tests/Microsoft.Bot.Builder.Dialogs.Tests/DialogManagerTests.cs b/tests/Microsoft.Bot.Builder.Dialogs.Tests/DialogManagerTests.cs index f7965855e..7f5ef04f0 100644 --- a/tests/Microsoft.Bot.Builder.Dialogs.Tests/DialogManagerTests.cs +++ b/tests/Microsoft.Bot.Builder.Dialogs.Tests/DialogManagerTests.cs @@ -4,15 +4,15 @@ using System; using System.Collections.Generic; -using System.Reflection.Metadata; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder.Adapters; using Microsoft.Bot.Builder.Dialogs.Adaptive; using Microsoft.Bot.Builder.Dialogs.Adaptive.Actions; using Microsoft.Bot.Builder.Dialogs.Adaptive.Conditions; +using Microsoft.Bot.Connector.Authentication; using Microsoft.Bot.Schema; -using Microsoft.Extensions.Configuration; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.Bot.Builder.Dialogs.Tests @@ -20,6 +20,15 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests [TestClass] public class DialogManagerTests { + // An App ID for a parent bot. + private readonly string _parentBotId = Guid.NewGuid().ToString(); + + // An App ID for a skill bot. + private readonly string _skillBotId = Guid.NewGuid().ToString(); + + // Property to capture the DialogManager turn results and do assertions. + private DialogManagerResult _dmTurnResult; + public TestContext TestContext { get; set; } [TestMethod] @@ -46,7 +55,7 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests var firstConversationId = Guid.NewGuid().ToString(); var storage = new MemoryStorage(); - var adaptiveDialog = CreateTestDialog(property: "conversation.name"); + var adaptiveDialog = CreateTestDialog("conversation.name"); await CreateFlow(adaptiveDialog, storage, firstConversationId, dialogStateProperty: "dialogState") .Send("hi") @@ -65,7 +74,7 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests var secondConversationId = Guid.NewGuid().ToString(); var storage = new MemoryStorage(); - var adaptiveDialog = CreateTestDialog(property: "conversation.name"); + var adaptiveDialog = CreateTestDialog("conversation.name"); await CreateFlow(adaptiveDialog, storage, firstConversationId) .Send("hi") @@ -89,7 +98,7 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests var secondConversationId = Guid.NewGuid().ToString(); var storage = new MemoryStorage(); - var adaptiveDialog = CreateTestDialog(property: "user.name"); + var adaptiveDialog = CreateTestDialog("user.name"); await CreateFlow(adaptiveDialog, storage, firstConversationId) .Send("hi") @@ -111,7 +120,7 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests var secondConversationId = Guid.NewGuid().ToString(); var storage = new MemoryStorage(); - var outerAdaptiveDialog = CreateTestDialog(property: "user.name"); + var outerAdaptiveDialog = CreateTestDialog("user.name"); var componentDialog = new ComponentDialog(); componentDialog.AddDialog(outerAdaptiveDialog); @@ -186,7 +195,7 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests } }; - DialogManager dm = new DialogManager(rootDialog); + var dm = new DialogManager(rootDialog); dm.Dialogs.Add(new SimpleDialog() { Id = "test" }); await new TestFlow(adapter, async (turnContext, cancellationToken) => @@ -199,12 +208,90 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests .StartTestAsync(); } - private Dialog CreateTestDialog(string property = "user.name") + [TestMethod] + public async Task SkillSendsEoCAndValuesAtDialogEnd() + { + var firstConversationId = Guid.NewGuid().ToString(); + var storage = new MemoryStorage(); + + var adaptiveDialog = CreateTestDialog(property: "conversation.name"); + + await CreateFlow(adaptiveDialog, storage, firstConversationId, isSkillFlow: true) + .Send("hi") + .AssertReply("Hello, what is your name?") + .Send("Carlos") + .AssertReply("Hello Carlos, nice to meet you!") + .AssertReply(activity => + { + Assert.AreEqual(activity.Type, ActivityTypes.EndOfConversation); + Assert.AreEqual(((Activity)activity).Value, "Carlos"); + }) + .StartTestAsync(); + Assert.AreEqual(DialogTurnStatus.Complete, _dmTurnResult.TurnResult.Status); + } + + [TestMethod] + public async Task SkillHandlesEoCFromParent() + { + var firstConversationId = Guid.NewGuid().ToString(); + var storage = new MemoryStorage(); + + var adaptiveDialog = CreateTestDialog(property: "conversation.name"); + + var eocActivity = new Activity(ActivityTypes.EndOfConversation) { CallerId = _parentBotId }; + + await CreateFlow(adaptiveDialog, storage, firstConversationId, isSkillFlow: true) + .Send("hi") + .AssertReply("Hello, what is your name?") + .Send(eocActivity) + .StartTestAsync(); + + Assert.AreEqual(DialogTurnStatus.Cancelled, _dmTurnResult.TurnResult.Status); + } + + [TestMethod] + public async Task SkillHandlesRepromptFromParent() + { + var firstConversationId = Guid.NewGuid().ToString(); + var storage = new MemoryStorage(); + + var adaptiveDialog = CreateTestDialog(property: "conversation.name"); + + var repromptEvent = new Activity(ActivityTypes.Event) { Name = DialogEvents.RepromptDialog }; + + await CreateFlow(adaptiveDialog, storage, firstConversationId, isSkillFlow: true) + .Send("hi") + .AssertReply("Hello, what is your name?") + .Send(repromptEvent) + .AssertReply("Hello, what is your name?") + .StartTestAsync(); + + Assert.AreEqual(DialogTurnStatus.Waiting, _dmTurnResult.TurnResult.Status); + } + + [TestMethod] + public async Task SkillShouldReturnEmptyOnRepromptWithNoDialog() + { + var firstConversationId = Guid.NewGuid().ToString(); + var storage = new MemoryStorage(); + + var adaptiveDialog = CreateTestDialog(property: "conversation.name"); + + var repromptEvent = new Activity(ActivityTypes.Event) { Name = DialogEvents.RepromptDialog }; + + await CreateFlow(adaptiveDialog, storage, firstConversationId, isSkillFlow: true) + .Send(repromptEvent) + .StartTestAsync(); + + Assert.AreEqual(DialogTurnStatus.Empty, _dmTurnResult.TurnResult.Status); + } + + private Dialog CreateTestDialog(string property) { return new AskForNameDialog(property.Replace(".", string.Empty), property); } - private TestFlow CreateFlow(Dialog dialog, IStorage storage, string conversationId, string dialogStateProperty = null) + private TestFlow CreateFlow(Dialog dialog, IStorage storage, string conversationId, string dialogStateProperty = null, bool isSkillFlow = false) { var convoState = new ConversationState(storage); var userState = new UserState(storage); @@ -215,62 +302,81 @@ namespace Microsoft.Bot.Builder.Dialogs.Tests .UseState(userState, convoState) .Use(new TranscriptLoggerMiddleware(new TraceTranscriptLogger(traceActivity: false))); - DialogManager dm = new DialogManager(dialog, dialogStateProperty: dialogStateProperty); + var dm = new DialogManager(dialog, dialogStateProperty: dialogStateProperty); return new TestFlow(adapter, async (turnContext, cancellationToken) => { - await dm.OnTurnAsync(turnContext, cancellationToken: cancellationToken).ConfigureAwait(false); + if (isSkillFlow) + { + // Create a skill ClaimsIdentity and put it in TurnState so SkillValidation.IsSkillClaim() returns true. + var claimsIdentity = new ClaimsIdentity(); + claimsIdentity.AddClaim(new Claim(AuthenticationConstants.VersionClaim, "2.0")); + claimsIdentity.AddClaim(new Claim(AuthenticationConstants.AudienceClaim, _skillBotId)); + claimsIdentity.AddClaim(new Claim(AuthenticationConstants.AuthorizedParty, _parentBotId)); + turnContext.TurnState.Add(BotAdapter.BotIdentityKey, claimsIdentity); + } + + // Capture the last DialogManager turn result for assertions. + _dmTurnResult = await dm.OnTurnAsync(turnContext, cancellationToken: cancellationToken).ConfigureAwait(false); }); } - public class AskForNameDialog : ComponentDialog, IDialogDependencies + private class AskForNameDialog : ComponentDialog, IDialogDependencies { + private readonly string _property; + public AskForNameDialog(string id, string property) : base(id) { AddDialog(new TextPrompt("prompt")); - this.Property = property; + _property = property; } - public string Property { get; set; } - public override async Task BeginDialogAsync(DialogContext outerDc, object options = null, CancellationToken cancellationToken = default) { - if (outerDc.State.TryGetValue(this.Property, out string result)) + if (outerDc.State.TryGetValue(_property, out var result)) { - await outerDc.Context.SendActivityAsync($"Hello {result.ToString()}, nice to meet you!"); - return await outerDc.EndDialogAsync(result); + await outerDc.Context.SendActivityAsync($"Hello {result}, nice to meet you!", cancellationToken: cancellationToken); + return await outerDc.EndDialogAsync(result, cancellationToken); } return await outerDc.BeginDialogAsync( - "prompt", - new PromptOptions - { - Prompt = new Activity { Type = ActivityTypes.Message, Text = "Hello, what is your name?" }, - RetryPrompt = new Activity { Type = ActivityTypes.Message, Text = "Hello, what is your name?" }, - }, - cancellationToken: cancellationToken) + "prompt", + new PromptOptions + { + Prompt = new Activity + { + Type = ActivityTypes.Message, + Text = "Hello, what is your name?" + }, + RetryPrompt = new Activity + { + Type = ActivityTypes.Message, + Text = "Hello, what is your name?" + } + }, + cancellationToken: cancellationToken) .ConfigureAwait(false); } public IEnumerable GetDependencies() { - return this.Dialogs.GetDialogs(); + return Dialogs.GetDialogs(); } public override async Task ResumeDialogAsync(DialogContext outerDc, DialogReason reason, object result = null, CancellationToken cancellationToken = default) { - outerDc.State.SetValue(this.Property, result); - await outerDc.Context.SendActivityAsync($"Hello {result.ToString()}, nice to meet you!"); - return await outerDc.EndDialogAsync(result); + outerDc.State.SetValue(_property, result); + await outerDc.Context.SendActivityAsync($"Hello {result}, nice to meet you!", cancellationToken: cancellationToken); + return await outerDc.EndDialogAsync(result, cancellationToken); } } - public class SimpleDialog : Dialog + private class SimpleDialog : Dialog { - public async override Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default) + public override async Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default) { - await dc.Context.SendActivityAsync("simple"); - return await dc.EndDialogAsync(); + await dc.Context.SendActivityAsync("simple", cancellationToken: cancellationToken); + return await dc.EndDialogAsync(cancellationToken: cancellationToken); } } }