From f1d9e1d5de4c0b62448aee25dfa0cbb530d153c9 Mon Sep 17 00:00:00 2001 From: Gabo Gilabert Date: Wed, 18 Dec 2019 17:10:29 -0500 Subject: [PATCH] Skill Samples (dotnet) (#2032) * First pass at DialogToDialog sample * Updated nuget packages to latest nightly. * Updated to 4.7 RC0. Updated readmes Reorganized MainDialog, SkillDialog and SkillDialogArgs. * Updated readmes * Adds SimpleBotToBot sample. Updated readmes for DialotToDialog. * Added a readme for the root folder. * Addressed some of Jonathan's comments. Addressed LUIS warning. * Changed the conversation ID to avoid using hashcodes (the unique value will be given by the parent conversation ID). * Changed how the skill conversation ID key gets generated. * Fixed stylecop issues. * Updated nuget references to RC1 * Updated code for partiy with JS * Moved SimpleBotToBot in main samples solution. Updated SimpleBotToBot to send an eoc on error with a status and text message. * Rename skill adapter. Updated OnError Upated nuget packages to released versions. * Readded skill sample that was lost in mege from master * Updated readmes Added sample claim validators for skill and parent. Added status checks for skill call invocations. * Updated links. * Fixed some strings and comments. * Update samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/README.md Co-Authored-By: Jonathan Fingold <35047199+JonathanFingold@users.noreply.github.com> * Updated readme --- .gitignore | 4 + .../DialogRootBot/AdapterWithErrorHandler.cs | 58 +++ .../AllowedCallersClaimsValidator.cs | 47 ++ .../DialogRootBot/Bots/RootBot.cs | 78 ++++ .../DialogRootBot/Cards/welcomeCard.json | 128 ++++++ .../Controllers/BotController.cs | 35 ++ .../Controllers/SkillController.cs | 24 + .../DialogRootBot/DialogRootBot.csproj | 30 ++ .../DialogRootBot/Dialogs/BookingDetails.cs | 19 + .../DialogRootBot/Dialogs/Location.cs | 19 + .../DialogRootBot/Dialogs/MainDialog.cs | 226 ++++++++++ .../DialogRootBot/Dialogs/SkillDialog.cs | 148 ++++++ .../DialogRootBot/Dialogs/SkillDialogArgs.cs | 38 ++ .../Middleware/LoggerMiddleware.cs | 55 +++ .../DialogToDialog/DialogRootBot/Program.cs | 26 ++ .../Properties/launchSettings.json | 28 ++ .../DialogToDialog/DialogRootBot/README.md | 21 + .../SkillConversationIdFactory.cs | 74 +++ .../DialogRootBot/SkillsConfiguration.cs | 39 ++ .../DialogToDialog/DialogRootBot/Startup.cs | 81 ++++ .../DialogRootBot/appsettings.json | 13 + .../DialogRootBot/wwwroot/default.htm | 420 ++++++++++++++++++ .../Bots/ActivityRouterDialog.cs | 179 ++++++++ .../DialogSkillBot/Bots/SkillBot.cs | 80 ++++ .../CognitiveModels/FlightBooking.json | 339 ++++++++++++++ .../Controllers/BotController.cs | 34 ++ .../DialogSkillBot/DialogSkillBot.csproj | 24 + .../DialogSkillBot/Dialogs/BookingDetails.cs | 19 + .../DialogSkillBot/Dialogs/BookingDialog.cs | 105 +++++ .../Dialogs/CancelAndHelpDialog.cs | 58 +++ .../Dialogs/DateResolverDialog.cs | 88 ++++ .../Dialogs/DialogSkillBotRecognizer.cs | 42 ++ .../DialogSkillBot/Dialogs/Location.cs | 19 + .../DialogSkillBot/Dialogs/OAuthTestDialog.cs | 69 +++ .../DialogToDialog/DialogSkillBot/Program.cs | 26 ++ .../Properties/launchSettings.json | 28 ++ .../DialogToDialog/DialogSkillBot/README.md | 13 + .../SkillAdapterWithErrorHandler.cs | 61 +++ .../DialogToDialog/DialogSkillBot/Startup.cs | 59 +++ .../DialogSkillBot/appsettings.json | 9 + .../DialogSkillBot/wwwroot/default.htm | 420 ++++++++++++++++++ .../manifest/dialogchildbot-manifest-1.0.json | 116 +++++ .../skills/DialogToDialog/DialogToDialog.sln | 36 ++ experimental/skills/DialogToDialog/README.md | 28 ++ experimental/skills/README.md | 6 + .../AllowedCallersClaimsValidator.cs | 54 +++ .../EchoSkillBot/Bots/EchoBot.cs | 30 ++ .../EchoSkillBot/Controllers/BotController.cs | 33 ++ .../EchoSkillBot/EchoSkillBot.csproj | 26 ++ .../EchoSkillBot/Program.cs | 20 + .../Properties/launchSettings.json | 28 ++ .../EchoSkillBot/README.md | 3 + .../SkillAdapterWithErrorHandler.cs | 62 +++ .../EchoSkillBot/Startup.cs | 57 +++ .../EchoSkillBot/appsettings.json | 5 + .../EchoSkillBot/wwwroot/default.htm | 420 ++++++++++++++++++ .../manifest/echoskillbot-manifest-1.0.json | 25 ++ .../70.skills-simple-bot-to-bot/README.md | 61 +++ .../SimpleBotToBot.sln | 36 ++ .../SimpleRootBot/AdapterWithErrorHandler.cs | 54 +++ .../AllowedSkillsClaimsValidator.cs | 47 ++ .../SimpleRootBot/Bots/RootBot.cs | 140 ++++++ .../Controllers/BotController.cs | 33 ++ .../Controllers/SkillController.cs | 24 + .../SimpleRootBot/Program.cs | 20 + .../Properties/launchSettings.json | 28 ++ .../SimpleRootBot/README.md | 3 + .../SimpleRootBot/SimpleRootBot.csproj | 26 ++ .../SkillConversationIdFactory.cs | 41 ++ .../SimpleRootBot/SkillsConfiguration.cs | 39 ++ .../SimpleRootBot/Startup.cs | 74 +++ .../SimpleRootBot/appsettings.json | 12 + .../SimpleRootBot/wwwroot/default.htm | 420 ++++++++++++++++++ .../csharp_dotnetcore/csharp_dotnetcore.sln | 27 +- 74 files changed, 5313 insertions(+), 4 deletions(-) create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/AdapterWithErrorHandler.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Authentication/AllowedCallersClaimsValidator.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Bots/RootBot.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Cards/welcomeCard.json create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Controllers/BotController.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Controllers/SkillController.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/DialogRootBot.csproj create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Dialogs/BookingDetails.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Dialogs/Location.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Dialogs/MainDialog.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialog.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialogArgs.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Middleware/LoggerMiddleware.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Program.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Properties/launchSettings.json create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/README.md create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/SkillConversationIdFactory.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/SkillsConfiguration.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/Startup.cs create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/appsettings.json create mode 100644 experimental/skills/DialogToDialog/DialogRootBot/wwwroot/default.htm create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Bots/ActivityRouterDialog.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Bots/SkillBot.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/CognitiveModels/FlightBooking.json create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Controllers/BotController.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/DialogSkillBot.csproj create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/BookingDetails.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/BookingDialog.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/CancelAndHelpDialog.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/DateResolverDialog.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/DialogSkillBotRecognizer.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/Location.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/OAuthTestDialog.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Program.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Properties/launchSettings.json create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/README.md create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/SkillAdapterWithErrorHandler.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/Startup.cs create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/appsettings.json create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/wwwroot/default.htm create mode 100644 experimental/skills/DialogToDialog/DialogSkillBot/wwwroot/manifest/dialogchildbot-manifest-1.0.json create mode 100644 experimental/skills/DialogToDialog/DialogToDialog.sln create mode 100644 experimental/skills/DialogToDialog/README.md create mode 100644 experimental/skills/README.md create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Authentication/AllowedCallersClaimsValidator.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Bots/EchoBot.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Controllers/BotController.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/EchoSkillBot.csproj create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Program.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Properties/launchSettings.json create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/README.md create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/SkillAdapterWithErrorHandler.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Startup.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/appsettings.json create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/default.htm create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/manifest/echoskillbot-manifest-1.0.json create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/README.md create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleBotToBot.sln create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/AdapterWithErrorHandler.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Authentication/AllowedSkillsClaimsValidator.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Bots/RootBot.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Controllers/BotController.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Controllers/SkillController.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Program.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Properties/launchSettings.json create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/README.md create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SimpleRootBot.csproj create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SkillConversationIdFactory.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SkillsConfiguration.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Startup.cs create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/appsettings.json create mode 100644 samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/wwwroot/default.htm diff --git a/.gitignore b/.gitignore index 17599e9c84..242606d837 100644 --- a/.gitignore +++ b/.gitignore @@ -279,3 +279,7 @@ generators/generator-botbuilder/.npmrc /generators/generator-botbuilder/package-lock.json package-lock.json tsconfig.tsbuildinfo + +# Visual Studio +appsettings.Development.json +.config/ diff --git a/experimental/skills/DialogToDialog/DialogRootBot/AdapterWithErrorHandler.cs b/experimental/skills/DialogToDialog/DialogRootBot/AdapterWithErrorHandler.cs new file mode 100644 index 0000000000..52c8cfee9a --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/AdapterWithErrorHandler.cs @@ -0,0 +1,58 @@ +// 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.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.BotBuilderSamples.DialogRootBot.Middleware; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +namespace Microsoft.BotBuilderSamples.DialogRootBot +{ + public class AdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public AdapterWithErrorHandler(IConfiguration configuration, ICredentialProvider credentialProvider, AuthenticationConfiguration authConfig, ILogger logger, ConversationState conversationState = null) + : base(configuration, credentialProvider, authConfig, logger: logger) + { + 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 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); + + if (conversationState != null) + { + try + { + // Delete the conversationState for the current conversation to prevent the + // bot from getting stuck in a error-loop caused by being in a bad state. + // ConversationState should be thought of as similar to "cookie-state" in a Web pages. + await conversationState.DeleteAsync(turnContext); + } + catch (Exception e) + { + logger.LogError(e, $"Exception caught on attempting to Delete ConversationState : {e.Message}"); + } + } + + // 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"); + }; + + Use(new LoggerMiddleware(logger)); + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/Authentication/AllowedCallersClaimsValidator.cs b/experimental/skills/DialogToDialog/DialogRootBot/Authentication/AllowedCallersClaimsValidator.cs new file mode 100644 index 0000000000..4236dbaa8e --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Authentication/AllowedCallersClaimsValidator.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.DialogRootBot.Authentication +{ + /// + /// Sample claims validator that loads an allowed list from configuration if present + /// and checks that responses are coming from configured skills. + /// + public class AllowedCallersClaimsValidator : ClaimsValidator + { + private readonly List _allowedSkills; + + public AllowedCallersClaimsValidator(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/experimental/skills/DialogToDialog/DialogRootBot/Bots/RootBot.cs b/experimental/skills/DialogToDialog/DialogRootBot/Bots/RootBot.cs new file mode 100644 index 0000000000..d06f3e72a4 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Bots/RootBot.cs @@ -0,0 +1,78 @@ +// 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.DialogRootBot.Bots +{ + public class RootBot : ActivityHandler + where T : Dialog + { + private readonly ConversationState _conversationState; + private readonly Dialog _mainDialog; + + public RootBot(ConversationState conversationState, T mainDialog) + { + _conversationState = conversationState; + _mainDialog = mainDialog; + } + + public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + if (turnContext.Activity.Type != ActivityTypes.ConversationUpdate) + { + // Run the Dialog with the Activity. + await _mainDialog.RunAsync(turnContext, _conversationState.CreateProperty("DialogState"), cancellationToken); + } + else + { + await base.OnTurnAsync(turnContext, cancellationToken); + } + + // Save any state changes that might have occurred during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + + 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 for more details. + if (member.Id != turnContext.Activity.Recipient.Id) + { + var welcomeCard = CreateAdaptiveCardAttachment(); + var activity = MessageFactory.Attachment(welcomeCard); + await turnContext.SendActivityAsync(activity, cancellationToken); + await _mainDialog.RunAsync(turnContext, _conversationState.CreateProperty("DialogState"), cancellationToken); + } + } + } + + // Load attachment from embedded resource. + private Attachment CreateAdaptiveCardAttachment() + { + var cardResourcePath = "Microsoft.BotBuilderSamples.DialogRootBot.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/experimental/skills/DialogToDialog/DialogRootBot/Cards/welcomeCard.json b/experimental/skills/DialogToDialog/DialogRootBot/Cards/welcomeCard.json new file mode 100644 index 0000000000..4ea9648399 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Cards/welcomeCard.json @@ -0,0 +1,128 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "spacing": "Medium", + "size": "Medium", + "weight": "Bolder", + "text": "Welcome to the Dialog Skill Prototype!", + "wrap": true, + "maxLines": 0, + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "Here are some things you can do:", + "wrap": true, + "maxLines": 0 + }, + { + "type": "TextBlock", + "size": "default", + "text": "Send a text message to the bot (message, single-turn)", + "wrap": true, + "weight": "Bolder", + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "**m:some message** sends the text after **m:** directly to the skill and the skill does intent and entity recognition against LUIS.", + "wrap": true, + "spacing": "None" + }, + { + "type": "TextBlock", + "size": "default", + "text": "Send a BookFlight event (event, multi-turn)", + "wrap": true, + "weight": "Bolder", + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "**bookflight** sends an event to the skill with the name = 'BookFlight' to start a flight booking dialog.", + "wrap": true, + "spacing": "None" + }, + { + "type": "TextBlock", + "size": "default", + "text": "Send a GetWeather invoke activity (invoke, single-turn)", + "wrap": true, + "weight": "Bolder", + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "**getweather** sends an invoke activity to the skill with the name = 'GetWeather'. The skill replies right away (Invoke activities are Request/Response).", + "wrap": true, + "spacing": "None" + }, + { + "type": "TextBlock", + "size": "default", + "text": "OAuthTest (event, multi-turn)", + "wrap": true, + "weight": "Bolder", + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "**oauthtest** sends an event to the skill to start an OAuthCard dialog.", + "wrap": true, + "spacing": "None" + }, + { + "type": "TextBlock", + "size": "default", + "text": "Send a message to the bot with values (message, single-turn)", + "wrap": true, + "weight": "Bolder", + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "**mv:some message** sends some complex object in the Value property of the activity in addition to the message after **mv:**.", + "wrap": true, + "spacing": "None" + }, + { + "type": "TextBlock", + "size": "default", + "text": "Canceling multi-turn conversations", + "wrap": true, + "weight": "Bolder", + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "**abort** during a multi-turn dialog (i.e. BookFlight) sends an EndOfConversation activity to the skill to cancel the conversation.", + "wrap": true, + "spacing": "None" + }, + { + "type": "TextBlock", + "size": "default", + "text": "**cancel** during a multi-turn dialog (i.e. OAuthTest) is recognized by the skill, it cancels its current dialog and sends an EndOfConversation activity to the parent.", + "wrap": true, + "spacing": "None" + }, + { + "type": "TextBlock", + "size": "default", + "text": "Use the suggested actions bellow to test different parent to skill interactions.", + "wrap": true, + "maxLines": 0 + } + ] +} \ No newline at end of file diff --git a/experimental/skills/DialogToDialog/DialogRootBot/Controllers/BotController.cs b/experimental/skills/DialogToDialog/DialogRootBot/Controllers/BotController.cs new file mode 100644 index 0000000000..8c391de082 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Controllers/BotController.cs @@ -0,0 +1,35 @@ +// 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.DialogRootBot.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] + 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/experimental/skills/DialogToDialog/DialogRootBot/Controllers/SkillController.cs b/experimental/skills/DialogToDialog/DialogRootBot/Controllers/SkillController.cs new file mode 100644 index 0000000000..67466d4c26 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/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.DialogRootBot.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/experimental/skills/DialogToDialog/DialogRootBot/DialogRootBot.csproj b/experimental/skills/DialogToDialog/DialogRootBot/DialogRootBot.csproj new file mode 100644 index 0000000000..e94dc91496 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/DialogRootBot.csproj @@ -0,0 +1,30 @@ + + + + netcoreapp2.1 + latest + Microsoft.BotBuilderSamples.DialogRootBot + Microsoft.BotBuilderSamples.DialogRootBot + + + + + + + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/BookingDetails.cs b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/BookingDetails.cs new file mode 100644 index 0000000000..abfc961170 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/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.DialogRootBot.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/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/Location.cs b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/Location.cs new file mode 100644 index 0000000000..04961e657d --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/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.DialogRootBot.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/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/MainDialog.cs b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/MainDialog.cs new file mode 100644 index 0000000000..5ef0f30e6c --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/MainDialog.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Skills; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; + +namespace Microsoft.BotBuilderSamples.DialogRootBot.Dialogs +{ + /// + /// The main dialog for this bot. It uses a to call skills. + /// + public class MainDialog : ComponentDialog + { + // Note: this example uses only one skill. + private const string _targetSkillId = "DialogSkillBot"; + private readonly string _botId; + private readonly ConversationState _conversationState; + private readonly SkillHttpClient _skillClient; + private readonly SkillsConfiguration _skillsConfig; + + // Dependency injection uses this constructor to instantiate MainDialog + public MainDialog(ConversationState conversationState, SkillHttpClient skillClient, SkillsConfiguration skillsConfig, SkillDialog skillDialog, IConfiguration configuration) + : base(nameof(MainDialog)) + { + _botId = configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value; + _skillClient = skillClient; + _skillsConfig = skillsConfig; + _conversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState)); + AddDialog(new TextPrompt(nameof(TextPrompt))); + AddDialog(skillDialog); + AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { IntroStepAsync, ActStepAsync, FinalStepAsync })); + + // The initial child Dialog to run. + InitialDialogId = nameof(WaterfallDialog); + } + + private async Task IntroStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + // Use the text provided in the dialog options if it is present; otherwise, use the default text. + var messageText = stepContext.Options?.ToString() ?? "What can I help you with today?"; + var promptMessage = CreateTaskPromptMessageWithActions(messageText); + return await stepContext.PromptAsync(nameof(TextPrompt), new PromptOptions { Prompt = promptMessage }, cancellationToken); + } + + // Starts SkillDialog based on the user's selection + private async Task ActStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + // Send a message activity to the skill. + if (stepContext.Context.Activity.Text.StartsWith("m:", StringComparison.CurrentCultureIgnoreCase)) + { + var dialogArgs = new SkillDialogArgs + { + SkillId = _targetSkillId, + ActivityType = ActivityTypes.Message, + Text = stepContext.Context.Activity.Text.Substring(2).Trim() + }; + return await stepContext.BeginDialogAsync(nameof(SkillDialog), dialogArgs, cancellationToken); + } + + // Send a message activity to the skill with some artificial parameters in value + if (stepContext.Context.Activity.Text.StartsWith("mv:", StringComparison.CurrentCultureIgnoreCase)) + { + var dialogArgs = new SkillDialogArgs + { + SkillId = _targetSkillId, + ActivityType = ActivityTypes.Message, + Text = stepContext.Context.Activity.Text.Substring(3).Trim(), + Value = new BookingDetails { Destination = "New York" } + }; + return await stepContext.BeginDialogAsync(nameof(SkillDialog), dialogArgs, cancellationToken); + } + + // Send an event activity to the skill with "OAuthTest" in the name. + if (stepContext.Context.Activity.Text.Equals("OAuthTest", StringComparison.CurrentCultureIgnoreCase)) + { + var dialogArgs = new SkillDialogArgs + { + SkillId = _targetSkillId, + ActivityType = ActivityTypes.Event, + Name = "OAuthTest" + }; + return await stepContext.BeginDialogAsync(nameof(SkillDialog), dialogArgs, cancellationToken); + } + + // Send an event activity to the skill with "BookFlight" in the name. + if (stepContext.Context.Activity.Text.Equals("BookFlight", StringComparison.CurrentCultureIgnoreCase)) + { + var dialogArgs = new SkillDialogArgs + { + SkillId = _targetSkillId, + ActivityType = ActivityTypes.Event, + Name = "BookFlight" + }; + return await stepContext.BeginDialogAsync(nameof(SkillDialog), dialogArgs, cancellationToken); + } + + // Send an event activity to the skill "BookFlight" in the name and some testing values. + if (stepContext.Context.Activity.Text.Equals("BookFlightWithValues", StringComparison.CurrentCultureIgnoreCase)) + { + var dialogArgs = new SkillDialogArgs + { + SkillId = _targetSkillId, + ActivityType = ActivityTypes.Event, + Name = "BookFlight", + Value = new BookingDetails + { + Destination = "New York", + Origin = "Seattle" + } + }; + return await stepContext.BeginDialogAsync(nameof(SkillDialog), dialogArgs, cancellationToken); + } + + // Send an invoke activity to the skill with "GetWeather" in the name and some testing values. + // Note that this operation doesn't use SkillDialog, InvokeActivities are single turn Request/Response. + if (stepContext.Context.Activity.Text.Equals("GetWeather", StringComparison.CurrentCultureIgnoreCase)) + { + var invokeActivity = Activity.CreateInvokeActivity(); + invokeActivity.Name = "GetWeather"; + invokeActivity.Value = new Location + { + PostalCode = "11218" + }; + invokeActivity.ApplyConversationReference(stepContext.Context.Activity.GetConversationReference(), true); + + // Always save state before forwarding + await _conversationState.SaveChangesAsync(stepContext.Context, true, cancellationToken); + var skillInfo = _skillsConfig.Skills[_targetSkillId]; + var response = await _skillClient.PostActivityAsync(_botId, skillInfo, _skillsConfig.SkillHostEndpoint, (Activity)invokeActivity, cancellationToken); + if (!(response.Status >= 200 && response.Status <= 299)) + { + throw new HttpRequestException($"Error invoking the skill id: \"{skillInfo.Id}\" at \"{skillInfo.SkillEndpoint}\" (status is {response.Status}). \r\n {response.Body}"); + } + + var invokeResult = $"Invoke result: {JsonConvert.SerializeObject(response.Body)}"; + await stepContext.Context.SendActivityAsync(MessageFactory.Text(invokeResult, inputHint: InputHints.IgnoringInput), cancellationToken: cancellationToken); + return await stepContext.NextAsync(null, cancellationToken); + } + + // Catch all for unhandled intents + var didntUnderstandMessageText = "Sorry, I didn't get that. Please try asking in a different way."; + var didntUnderstandMessage = MessageFactory.Text(didntUnderstandMessageText, didntUnderstandMessageText, InputHints.IgnoringInput); + await stepContext.Context.SendActivityAsync(didntUnderstandMessage, cancellationToken); + + return await stepContext.NextAsync(null, cancellationToken); + } + + private async Task FinalStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken) + { + if (stepContext.Result != null) + { + var message = "Skill invocation complete."; + message += $" Result: {JsonConvert.SerializeObject(stepContext.Result)}"; + await stepContext.Context.SendActivityAsync(MessageFactory.Text(message, inputHint: InputHints.IgnoringInput), cancellationToken: cancellationToken); + } + + // Restart the main dialog with a different message the second time around + return await stepContext.ReplaceDialogAsync(InitialDialogId, "What else can I do for you?", cancellationToken); + } + + private Activity CreateTaskPromptMessageWithActions(string messageText) + { + var activity = MessageFactory.Text(messageText, messageText, InputHints.ExpectingInput); + + activity.SuggestedActions = new SuggestedActions + { + Actions = new List + { + new CardAction + { + Title = "Hi", + Type = ActionTypes.ImBack, + Value = "Hi" + }, + new CardAction + { + Title = "m:some message", + Type = ActionTypes.ImBack, + Value = "m:some message for tomorrow" + }, + new CardAction + { + Title = "Book a flight", + Type = ActionTypes.ImBack, + Value = "BookFlight" + }, + new CardAction + { + Title = "Get Weather", + Type = ActionTypes.ImBack, + Value = "GetWeather" + }, + new CardAction + { + Title = "OAuthTest", + Type = ActionTypes.ImBack, + Value = "OAuthTest" + }, + new CardAction + { + Title = "mv:some message with value", + Type = ActionTypes.ImBack, + Value = "mv:some message with value" + }, + new CardAction + { + Title = "Book a flight with values", + Type = ActionTypes.ImBack, + Value = "BookFlightWithValues" + } + } + }; + return activity; + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialog.cs b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialog.cs new file mode 100644 index 0000000000..601a1d740e --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialog.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Dialogs; +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.Extensions.Configuration; + +namespace Microsoft.BotBuilderSamples.DialogRootBot.Dialogs +{ + /// + /// A sample dialog that can wrap remote calls to a skill. + /// + /// + /// The options parameter in must be a instance + /// with the initial parameters for the dialog. + /// + public class SkillDialog : Dialog + { + private readonly IStatePropertyAccessor _activeSkillProperty; + private readonly string _botId; + private readonly ConversationState _conversationState; + private readonly SkillHttpClient _skillClient; + private readonly SkillsConfiguration _skillsConfig; + + public SkillDialog(ConversationState conversationState, SkillHttpClient skillClient, SkillsConfiguration skillsConfig, IConfiguration configuration) + : base(nameof(SkillDialog)) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + _botId = configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value; + if (string.IsNullOrWhiteSpace(_botId)) + { + throw new ArgumentException($"{MicrosoftAppCredentials.MicrosoftAppIdKey} is not in configuration"); + } + + _skillClient = skillClient ?? throw new ArgumentNullException(nameof(skillClient)); + _skillsConfig = skillsConfig ?? throw new ArgumentNullException(nameof(skillsConfig)); + _conversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState)); + _activeSkillProperty = conversationState.CreateProperty($"{typeof(SkillDialog).FullName}.ActiveSkillProperty"); + } + + public override async Task BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default) + { + if (!(options is SkillDialogArgs dialogArgs)) + { + throw new ArgumentNullException(nameof(options), $"Unable to cast {nameof(options)} to {nameof(SkillDialogArgs)}"); + } + + var skillId = dialogArgs.SkillId; + if (!_skillsConfig.Skills.TryGetValue(skillId, out var skillInfo)) + { + throw new KeyNotFoundException($"Unable to find \"{skillId}\" in the skill configuration."); + } + + // Store Skill ID for this dialog instance + await _activeSkillProperty.SetAsync(dc.Context, skillId, cancellationToken); + + await dc.Context.TraceActivityAsync($"{GetType().Name}.BeginDialogAsync()", label: $"Using activity of type: {dialogArgs.ActivityType}", cancellationToken: cancellationToken); + + Activity skillActivity; + switch (dialogArgs.ActivityType) + { + case ActivityTypes.Event: + var eventActivity = Activity.CreateEventActivity(); + eventActivity.Name = dialogArgs.Name; + eventActivity.ApplyConversationReference(dc.Context.Activity.GetConversationReference(), true); + skillActivity = (Activity)eventActivity; + break; + + case ActivityTypes.Message: + var messageActivity = Activity.CreateMessageActivity(); + messageActivity.Text = dialogArgs.Text; + skillActivity = (Activity)messageActivity; + break; + + default: + throw new ArgumentException($"Invalid activity type in {dialogArgs.ActivityType} in {nameof(SkillDialogArgs)}"); + } + + ApplyParentActivityProperties(dc, skillActivity, dialogArgs); + return await SendToSkill(dc, skillActivity, skillInfo, cancellationToken); + } + + public override async Task ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default) + { + await dc.Context.TraceActivityAsync($"{GetType().Name}.ContinueDialogAsync()", label: $"ActivityType: {dc.Context.Activity.Type}", cancellationToken: cancellationToken); + + var skillId = await _activeSkillProperty.GetAsync(dc.Context, () => null, cancellationToken); + + if (dc.Context.Activity.Type == ActivityTypes.Message && dc.Context.Activity.Text.Equals("abort", StringComparison.CurrentCultureIgnoreCase)) + { + // Send a message to the skill to let it do some cleanup + var eocActivity = Activity.CreateEndOfConversationActivity(); + eocActivity.ApplyConversationReference(dc.Context.Activity.GetConversationReference(), true); + await SendToSkill(dc, (Activity)eocActivity, _skillsConfig.Skills[skillId], cancellationToken); + + // End this dialog and return (we don't care if the skill responds or not) + await dc.Context.TraceActivityAsync($"{GetType().Name}.ContinueDialogAsync()", label: $"Canceled", cancellationToken: cancellationToken); + return await dc.EndDialogAsync(cancellationToken: cancellationToken); + } + + if (dc.Context.Activity.Type == ActivityTypes.EndOfConversation) + { + await dc.Context.TraceActivityAsync($"{GetType().Name}.ContinueDialogAsync()", label: $"Got EndOfConversation", cancellationToken: cancellationToken); + return await dc.EndDialogAsync(dc.Context.Activity.Value, cancellationToken); + } + + // Just forward to the remote skill + return await SendToSkill(dc, dc.Context.Activity, _skillsConfig.Skills[skillId], cancellationToken); + } + + private static void ApplyParentActivityProperties(DialogContext dc, Activity skillActivity, SkillDialogArgs dialogArgs) + { + // Apply conversation reference and common properties from incoming activity before sending. + skillActivity.ApplyConversationReference(dc.Context.Activity.GetConversationReference(), true); + skillActivity.Value = dialogArgs.Value; + skillActivity.ChannelData = dc.Context.Activity.ChannelData; + skillActivity.Properties = dc.Context.Activity.Properties; + } + + private async Task SendToSkill(DialogContext dc, Activity activity, BotFrameworkSkill skillInfo, CancellationToken cancellationToken) + { + // Always save state before forwarding + // (the dialog stack won't get updated with the skillDialog and things won't work if you don't) + await _conversationState.SaveChangesAsync(dc.Context, true, cancellationToken); + var response = await _skillClient.PostActivityAsync(_botId, skillInfo, _skillsConfig.SkillHostEndpoint, activity, cancellationToken); + if (!(response.Status >= 200 && response.Status <= 299)) + { + throw new HttpRequestException($"Error invoking the skill id: \"{skillInfo.Id}\" at \"{skillInfo.SkillEndpoint}\" (status is {response.Status}). \r\n {response.Body}"); + } + + return EndOfTurn; + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialogArgs.cs b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialogArgs.cs new file mode 100644 index 0000000000..a65ac5fee3 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Dialogs/SkillDialogArgs.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Bot.Schema; + +namespace Microsoft.BotBuilderSamples.DialogRootBot.Dialogs +{ + /// + /// A class with dialog arguments for a . + /// + public class SkillDialogArgs + { + /// + /// Gets or sets the ID of the skill to invoke. + /// + public string SkillId { get; set; } + + /// + /// Gets or sets the to send to the skill. + /// + public string ActivityType { get; set; } + + /// + /// Gets or sets the name of the event or invoke activity to send to the skill (this value is ignored for other types of activities) + /// + public string Name { get; set; } + + /// + /// Gets or sets the value property for the activity to send to the skill. + /// + public object Value { get; set; } + + /// + /// Gets or sets the text property for the to send to the skill (ignored for other types of activities). + /// + public string Text { get; set; } + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/Middleware/LoggerMiddleware.cs b/experimental/skills/DialogToDialog/DialogRootBot/Middleware/LoggerMiddleware.cs new file mode 100644 index 0000000000..7044149c43 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/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.DialogRootBot.Middleware +{ + /// + /// A middleware that logs to an ILogger instance filtering 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/experimental/skills/DialogToDialog/DialogRootBot/Program.cs b/experimental/skills/DialogToDialog/DialogRootBot/Program.cs new file mode 100644 index 0000000000..06e3183ccc --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Program.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples.DialogRootBot +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .ConfigureLogging((logging) => + { + logging.AddDebug(); + logging.AddConsole(); + }) + .UseStartup(); + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/Properties/launchSettings.json b/experimental/skills/DialogToDialog/DialogRootBot/Properties/launchSettings.json new file mode 100644 index 0000000000..4692526a0c --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:3978", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "EchoBot": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "https://localhost:3979;", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/README.md b/experimental/skills/DialogToDialog/DialogRootBot/README.md new file mode 100644 index 0000000000..5b2b264eed --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/README.md @@ -0,0 +1,21 @@ +# DialogRootBot (**DRAFT**) + +Bot Framework v4 Skills with Dialogs sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a root bot that +can consume a remote skill capabilities using a SkillDialog to manage the conversation state. + +## Key concepts + +- A [root dialog](Dialogs/MainDialog.cs) that can call different tasks on a skill using a [SkillDialog](Dialogs/SkillDialog.cs): + - Event Tasks + - Message Tasks + - Invoke Tasks +- How to send an EndOfConversation activity to remotely let a skill that it needs to end a conversation. +- How to Implement a [ClaimsValidator](Authentication/AllowedCallersClaimsValidator.cs) that allows a parent bot to validate that a response is coming from a skill that is allowed to talk to the parent. +- A sample [SkillDialog](Dialogs/SkillDialog.cs) that can be used to keep track of multiturn interactions with a skill using the dialog stack. +- A [Logger Middleware](Middleware/LoggerMiddleware.cs) that shows how to handle and log activities coming from a skill +- A [SkillConversationIdFactory](SkillConversationIdFactory.cs) based on IStorage to create and maintain conversation IDs to interact with a skill. +- A [SkillsConfiguration](SkillsConfiguration.cs) class that can load skill definitions from appsettings. +- A [startup](Startup.cs) class that shows how to register the different skills components for DI. +- A [SkillController](Controllers/SkillController.cs) that handles skill responses. diff --git a/experimental/skills/DialogToDialog/DialogRootBot/SkillConversationIdFactory.cs b/experimental/skills/DialogToDialog/DialogRootBot/SkillConversationIdFactory.cs new file mode 100644 index 0000000000..424a87e67f --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/SkillConversationIdFactory.cs @@ -0,0 +1,74 @@ +// 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 Microsoft.Bot.Schema; +using Newtonsoft.Json.Linq; + +namespace Microsoft.BotBuilderSamples.DialogRootBot +{ + /// + /// 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(ConversationReference conversationReference, CancellationToken cancellationToken) + { + if (conversationReference == null) + { + throw new ArgumentNullException(nameof(conversationReference)); + } + + if (string.IsNullOrWhiteSpace(conversationReference.Conversation.Id)) + { + throw new NullReferenceException($"ConversationId in {nameof(conversationReference)} can't be null."); + } + + if (string.IsNullOrWhiteSpace(conversationReference.ChannelId)) + { + throw new NullReferenceException($"ChannelId in {nameof(conversationReference)} can't be null."); + } + + var storageKey = $"{conversationReference.Conversation.Id}-{conversationReference.ChannelId}-skillconvo"; + var skillConversationInfo = new Dictionary { { storageKey, JObject.FromObject(conversationReference) } }; + await _storage.WriteAsync(skillConversationInfo, cancellationToken).ConfigureAwait(false); + + return storageKey; + } + + public override async Task GetConversationReferenceAsync(string skillConversationId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(skillConversationId)) + { + throw new ArgumentNullException(nameof(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) + { + await _storage.DeleteAsync(new[] { skillConversationId }, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/SkillsConfiguration.cs b/experimental/skills/DialogToDialog/DialogRootBot/SkillsConfiguration.cs new file mode 100644 index 0000000000..a82135a546 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/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.DialogRootBot +{ + /// + /// 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/experimental/skills/DialogToDialog/DialogRootBot/Startup.cs b/experimental/skills/DialogToDialog/DialogRootBot/Startup.cs new file mode 100644 index 0000000000..f27f3f850d --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/Startup.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +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.DialogRootBot.Authentication; +using Microsoft.BotBuilderSamples.DialogRootBot.Bots; +using Microsoft.BotBuilderSamples.DialogRootBot.Dialogs; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.BotBuilderSamples.DialogRootBot +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + + // 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 AllowedCallersClaimsValidator(sp.GetService()) }); + + // Register the Bot Framework Adapter with error handling enabled. + // Note: some classes use the base BotAdapter so we add an extra registration that pulls the same instance. + 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 Conversation state (used by the Dialog system itself). + services.AddSingleton(); + + // Register the SkillDialog (remote skill). + 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.AddTransient>(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseHsts(); + } + + app.UseDefaultFiles(); + app.UseStaticFiles(); + + // app.UseHttpsRedirection(); + app.UseMvc(); + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/appsettings.json b/experimental/skills/DialogToDialog/DialogRootBot/appsettings.json new file mode 100644 index 0000000000..a880c64a9b --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/appsettings.json @@ -0,0 +1,13 @@ +{ + "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": "DialogSkillBot", + "AppId": "TODO: Add here the App ID for the skill", + "SkillEndpoint": "http://localhost:39783/api/messages" + } + ] +} diff --git a/experimental/skills/DialogToDialog/DialogRootBot/wwwroot/default.htm b/experimental/skills/DialogToDialog/DialogRootBot/wwwroot/default.htm new file mode 100644 index 0000000000..15924d4207 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogRootBot/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/experimental/skills/DialogToDialog/DialogSkillBot/Bots/ActivityRouterDialog.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Bots/ActivityRouterDialog.cs new file mode 100644 index 0000000000..7dbde598b6 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.Dialogs; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; + +namespace Microsoft.BotBuilderSamples.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Bots/SkillBot.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Bots/SkillBot.cs new file mode 100644 index 0000000000..a734bbf5b0 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/Bots/SkillBot.cs @@ -0,0 +1,80 @@ +// 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.EntityFrameworkCore.Internal; + +namespace Microsoft.BotBuilderSamples.DialogSkillBot.Bots +{ + public class SkillBot : IBot + where T : Dialog + { + private readonly ConversationState _conversationState; + private readonly Dialog _mainDialog; + + public SkillBot(ConversationState conversationState, T mainDialog) + { + _conversationState = conversationState; + _mainDialog = mainDialog; + } + + public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + var dialogSet = new DialogSet(_conversationState.CreateProperty("DialogState")) { TelemetryClient = _mainDialog.TelemetryClient }; + dialogSet.Add(_mainDialog); + + var dialogContext = await dialogSet.CreateContextAsync(turnContext, cancellationToken).ConfigureAwait(false); + if (turnContext.Activity.Type == ActivityTypes.EndOfConversation && dialogContext.Stack.Any()) + { + // Handle remote cancellation request if we have something in the stack. + var activeDialogContext = GetActiveDialogContext(dialogContext); + + // Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the right order. + await activeDialogContext.CancelAllDialogsAsync(true, cancellationToken: cancellationToken); + var remoteCancelText = "**SkillBot.** The current mainDialog in the skill was **canceled** by a request **from the host**, do some cleanup here if needed."; + await turnContext.SendActivityAsync(MessageFactory.Text(remoteCancelText, inputHint: InputHints.IgnoringInput), cancellationToken); + } + else + { + // Run the Dialog with the new message Activity and capture the results so we can send end of conversation if needed. + var result = await dialogContext.ContinueDialogAsync(cancellationToken).ConfigureAwait(false); + if (result.Status == DialogTurnStatus.Empty) + { + var startMessageText = $"**SkillBot.** Starting {_mainDialog.Id}."; + await turnContext.SendActivityAsync(MessageFactory.Text(startMessageText, inputHint: InputHints.IgnoringInput), cancellationToken); + result = await dialogContext.BeginDialogAsync(_mainDialog.Id, null, cancellationToken).ConfigureAwait(false); + } + + // Send end of conversation if it is complete + if (result.Status == DialogTurnStatus.Complete || result.Status == DialogTurnStatus.Cancelled) + { + var endMessageText = "**SkillBot.** The mainDialog in the skill has **completed**. Sending EndOfConversation."; + await turnContext.SendActivityAsync(MessageFactory.Text(endMessageText, inputHint: InputHints.IgnoringInput), cancellationToken); + + // Send End of conversation at the end. + var activity = new Activity(ActivityTypes.EndOfConversation) { Value = result.Result }; + await turnContext.SendActivityAsync(activity, cancellationToken); + } + } + + // Save any state changes that might have occured during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + + // Recursively walk up the DC stack to find the active DC. + private DialogContext GetActiveDialogContext(DialogContext dialogContext) + { + var child = dialogContext.Child; + if (child == null) + { + return dialogContext; + } + + return GetActiveDialogContext(child); + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogSkillBot/CognitiveModels/FlightBooking.json b/experimental/skills/DialogToDialog/DialogSkillBot/CognitiveModels/FlightBooking.json new file mode 100644 index 0000000000..f0e4b97709 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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/experimental/skills/DialogToDialog/DialogSkillBot/Controllers/BotController.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Controllers/BotController.cs new file mode 100644 index 0000000000..12656f9252 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/DialogSkillBot.csproj b/experimental/skills/DialogToDialog/DialogSkillBot/DialogSkillBot.csproj new file mode 100644 index 0000000000..125adde7f0 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/DialogSkillBot.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp2.1 + latest + Microsoft.BotBuilderSamples.DialogSkillBot + Microsoft.BotBuilderSamples.DialogSkillBot + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/BookingDetails.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/BookingDetails.cs new file mode 100644 index 0000000000..4fdca6b567 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/BookingDialog.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/BookingDialog.cs new file mode 100644 index 0000000000..1480a89659 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/CancelAndHelpDialog.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/CancelAndHelpDialog.cs new file mode 100644 index 0000000000..6671b56b7d --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/DateResolverDialog.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/DateResolverDialog.cs new file mode 100644 index 0000000000..b25e917f6f --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/DialogSkillBotRecognizer.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/DialogSkillBotRecognizer.cs new file mode 100644 index 0000000000..62f628c70c --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/DialogSkillBotRecognizer.cs @@ -0,0 +1,42 @@ +// 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.Bot.Configuration; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.BotBuilderSamples.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/Location.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/Location.cs new file mode 100644 index 0000000000..38aa109b23 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/OAuthTestDialog.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Dialogs/OAuthTestDialog.cs new file mode 100644 index 0000000000..cac87c2408 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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.DialogSkillBot.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/experimental/skills/DialogToDialog/DialogSkillBot/Program.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Program.cs new file mode 100644 index 0000000000..c9d7ed6e52 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/Program.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples.DialogSkillBot +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .ConfigureLogging((logging) => + { + logging.AddDebug(); + logging.AddConsole(); + }) + .UseStartup(); + } +} diff --git a/experimental/skills/DialogToDialog/DialogSkillBot/Properties/launchSettings.json b/experimental/skills/DialogToDialog/DialogSkillBot/Properties/launchSettings.json new file mode 100644 index 0000000000..3f69f3b371 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:39783/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "DialogSkillBot": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:1205/", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogSkillBot/README.md b/experimental/skills/DialogToDialog/DialogSkillBot/README.md new file mode 100644 index 0000000000..1ad3d969cd --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/README.md @@ -0,0 +1,13 @@ +# DialogSkillBot (**DRAFT**) + +Bot Framework v4 Skills with Dialogs sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a skill bot that +can perform different tasks based on requests received from a root bot. + +## Key concepts + +- A sample [IBot](Bots/SkillBot.cs) that shows how to handle and return EndOfConversation based on the status of the dialog in the skill. +- An [ActivityRouterDialog](Bots/ActivityRouterDialog.cs) that handles Events, Messages and Invoke activities coming from a parent and perform different tasks. +- How to receive and return values in a skill. +- A [sample skill manifest](wwwroot/manifest/dialogchildbot-manifest-1.0.json) that describes what the skill can do. diff --git a/experimental/skills/DialogToDialog/DialogSkillBot/SkillAdapterWithErrorHandler.cs b/experimental/skills/DialogToDialog/DialogSkillBot/SkillAdapterWithErrorHandler.cs new file mode 100644 index 0000000000..d07a398709 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/SkillAdapterWithErrorHandler.cs @@ -0,0 +1,61 @@ +// 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.DialogSkillBot +{ + public class SkillAdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public SkillAdapterWithErrorHandler(IConfiguration configuration, ILogger logger, ConversationState conversationState = null) + : base(configuration, logger) + { + 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/experimental/skills/DialogToDialog/DialogSkillBot/Startup.cs b/experimental/skills/DialogToDialog/DialogSkillBot/Startup.cs new file mode 100644 index 0000000000..564cc2b1cb --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/Startup.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.BotBuilderSamples.DialogSkillBot.Bots; +using Microsoft.BotBuilderSamples.DialogSkillBot.Dialogs; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.BotBuilderSamples.DialogSkillBot +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + + // Create the 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(); + + // 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, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseHsts(); + } + + app.UseDefaultFiles(); + app.UseStaticFiles(); + + app.UseMvc(); + } + } +} diff --git a/experimental/skills/DialogToDialog/DialogSkillBot/appsettings.json b/experimental/skills/DialogToDialog/DialogSkillBot/appsettings.json new file mode 100644 index 0000000000..98ea20ab0d --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + "ConnectionName": "", + + "LuisAppId": "", + "LuisAPIKey": "", + "LuisAPIHostName": "" +} diff --git a/experimental/skills/DialogToDialog/DialogSkillBot/wwwroot/default.htm b/experimental/skills/DialogToDialog/DialogSkillBot/wwwroot/default.htm new file mode 100644 index 0000000000..13a72f652e --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/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/experimental/skills/DialogToDialog/DialogSkillBot/wwwroot/manifest/dialogchildbot-manifest-1.0.json b/experimental/skills/DialogToDialog/DialogSkillBot/wwwroot/manifest/dialogchildbot-manifest-1.0.json new file mode 100644 index 0000000000..7a08ff9b68 --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogSkillBot/wwwroot/manifest/dialogchildbot-manifest-1.0.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/botframework-sdk/master/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": "http://dialogskillbot.contoso.com/api/messages", + "msAppId": "00000000-0000-0000-0000-000000000000" + } + ], + "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/experimental/skills/DialogToDialog/DialogToDialog.sln b/experimental/skills/DialogToDialog/DialogToDialog.sln new file mode 100644 index 0000000000..c895f9393b --- /dev/null +++ b/experimental/skills/DialogToDialog/DialogToDialog.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29519.181 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DialogRootBot", "DialogRootBot\DialogRootBot.csproj", "{DA9725F4-10C5-4FEC-A37E-09CDB0F9B47F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DialogSkillBot", "DialogSkillBot\DialogSkillBot.csproj", "{997CE11F-3FB4-4CD9-9B00-46688D618C91}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{AC6F3BE3-D9B6-4BBC-AAEA-DC614595F7AA}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DA9725F4-10C5-4FEC-A37E-09CDB0F9B47F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA9725F4-10C5-4FEC-A37E-09CDB0F9B47F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA9725F4-10C5-4FEC-A37E-09CDB0F9B47F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA9725F4-10C5-4FEC-A37E-09CDB0F9B47F}.Release|Any CPU.Build.0 = Release|Any CPU + {997CE11F-3FB4-4CD9-9B00-46688D618C91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {997CE11F-3FB4-4CD9-9B00-46688D618C91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {997CE11F-3FB4-4CD9-9B00-46688D618C91}.Release|Any CPU.ActiveCfg = Release|Any CPU + {997CE11F-3FB4-4CD9-9B00-46688D618C91}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {686868C2-8D3C-479A-B1D3-020F9C227E54} + EndGlobalSection +EndGlobal diff --git a/experimental/skills/DialogToDialog/README.md b/experimental/skills/DialogToDialog/README.md new file mode 100644 index 0000000000..ba6787a8fd --- /dev/null +++ b/experimental/skills/DialogToDialog/README.md @@ -0,0 +1,28 @@ +# DialogToDialog (**DRAFT**) + +Bot Framework v4 Skills with Dialogs sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use skills from a rootbot. + +## Prerequisites + +- [.NET Core SDK](https://dotnet.microsoft.com/download) version 2.1 + + ```bash + # determine dotnet version + dotnet --version + ``` + +## Key concepts + +- A [DialogRootBot](DialogRootBot/README.md) that can consume skills. +- A [DialogSkillBot](DialogSkillBot/README.md) that handle requests from a parent bot. + +## To try this sample + +- Create a bot registration in the azure portal for the DialogSkillBot and update [DialogSkillBot/appsettings.json](DialogSkillBot/appsettings.json) with the AppId and password. +- Create a bot registration in the azure portal for the DialogRootBot and update [DialogRootBot/appsettings.json](DialogRootBot/appsettings.json) with the AppId and password. +- Update the BotFrameworkSkills section in [DialogRootBot/appsettings.json](DialogRootBot/appsettings.json) with the AppId for the skill you created in the previou step. +- Configure Visual Studio to run both applications at the same time. +- (Optional) Configure the bot registration for [DialogSkillBot](DialogSkillBot) with an OAuth connection if you want to test acquiring OAuth tokens from the skill. +- (Optional) Configure the LuisAppId, LuisAPIKey and LuisAPIHostName section in the [DialogSkillBot configuration](DialogSkillBot/appsettings.json) if you want to run message activities through LUIS. \ No newline at end of file diff --git a/experimental/skills/README.md b/experimental/skills/README.md new file mode 100644 index 0000000000..d0ca5277d3 --- /dev/null +++ b/experimental/skills/README.md @@ -0,0 +1,6 @@ +# Skill Examples + +This folder contains to sample scenarios for Skills: + +- A [simple bot to bot](SimpleBotToBot) example that shows the basics on how to consume an Echo Skill from a root bot. +- A more advanced [dialog based](DialogToDialog) example that shows how to consume a skill that can perform several activities using a SkillDialog. \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Authentication/AllowedCallersClaimsValidator.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Authentication/AllowedCallersClaimsValidator.cs new file mode 100644 index 0000000000..8775fca6d6 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Authentication/AllowedCallersClaimsValidator.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.BotBuilderSamples.EchoSkillBot.Authentication +{ + /// + /// Sample claims validator that loads an allowed list from configuration if present + /// and checks that requests are coming from allowed parent bots. + /// + public class AllowedCallersClaimsValidator : ClaimsValidator + { + private const string ConfigKey = "AllowedCallers"; + private readonly List _allowedCallers; + + public AllowedCallersClaimsValidator(IConfiguration config) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + // AllowedCallers is the setting in appsettings.json file + // that consists of the list of parent bot ids that are allowed to access the skill + // to add a new parent bot simply go to the AllowedCallers and add + // the parent bot's microsoft app id to the list + var section = config.GetSection(ConfigKey); + var appsList = section.Get(); + _allowedCallers = appsList != null ? new List(appsList) : null; + } + + public override Task ValidateClaimsAsync(IList claims) + { + // if _allowedCallers is null we allow all calls + if (_allowedCallers != null && 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 (!_allowedCallers.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/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Bots/EchoBot.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Bots/EchoBot.cs new file mode 100644 index 0000000000..dd1a66d53d --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Bots/EchoBot.cs @@ -0,0 +1,30 @@ +// 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.Schema; + +namespace Microsoft.BotBuilderSamples.EchoSkillBot.Bots +{ + public class EchoBot : ActivityHandler + { + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + if (turnContext.Activity.Text.Contains("end") || turnContext.Activity.Text.Contains("stop")) + { + // Send End of conversation at the end. + await turnContext.SendActivityAsync(MessageFactory.Text($"ending conversation from the skill..."), cancellationToken); + var endOfConversation = Activity.CreateEndOfConversationActivity(); + endOfConversation.Code = EndOfConversationCodes.CompletedSuccessfully; + await turnContext.SendActivityAsync(endOfConversation, cancellationToken); + } + else + { + await turnContext.SendActivityAsync(MessageFactory.Text($"Echo (dotnet) : {turnContext.Activity.Text}"), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text("Say \"end\" or \"stop\" and I'll end the conversation and back to the parent."), cancellationToken); + } + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Controllers/BotController.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Controllers/BotController.cs new file mode 100644 index 0000000000..1f277e9899 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Controllers/BotController.cs @@ -0,0 +1,33 @@ +// 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.EchoSkillBot.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; + } + + [HttpPost] + public async Task PostAsync() + { + await _adapter.ProcessAsync(Request, Response, _bot); + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/EchoSkillBot.csproj b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/EchoSkillBot.csproj new file mode 100644 index 0000000000..6486c79baf --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/EchoSkillBot.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp2.1 + latest + f94e58a2-e019-4a0c-a6c2-59ecb1115b80 + Microsoft.BotBuilderSamples.EchoSkillBot + Microsoft.BotBuilderSamples.EchoSkillBot + + + + DEBUG;TRACE + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Program.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Program.cs new file mode 100644 index 0000000000..5fea40a049 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Program.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace Microsoft.BotBuilderSamples.EchoSkillBot +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup(); + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Properties/launchSettings.json b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Properties/launchSettings.json new file mode 100644 index 0000000000..5a1996270b --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:39783/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "EchoSkillBot": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:1205/", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/README.md b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/README.md new file mode 100644 index 0000000000..449130d373 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/README.md @@ -0,0 +1,3 @@ +# EchoSkillBot + +See [SkillSimpleBotToBot](../) for details on how to configure and run this sample. \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/SkillAdapterWithErrorHandler.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/SkillAdapterWithErrorHandler.cs new file mode 100644 index 0000000000..1efdc689d1 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/SkillAdapterWithErrorHandler.cs @@ -0,0 +1,62 @@ +// 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.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples.EchoSkillBot +{ + public class SkillAdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public SkillAdapterWithErrorHandler(IConfiguration configuration, ICredentialProvider credentialProvider, AuthenticationConfiguration authConfig, ILogger logger, ConversationState conversationState = null) + : base(configuration, credentialProvider, authConfig, logger: logger) + { + 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/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Startup.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Startup.cs new file mode 100644 index 0000000000..1f67161980 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/Startup.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.BotFramework; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.BotBuilderSamples.EchoSkillBot.Authentication; +using Microsoft.BotBuilderSamples.EchoSkillBot.Bots; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.BotBuilderSamples.EchoSkillBot +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + + // Configure credentials + services.AddSingleton(); + + // Register AuthConfiguration to enable custom claim validation. + services.AddSingleton(sp => new AuthenticationConfiguration { ClaimsValidator = new AllowedCallersClaimsValidator(sp.GetService()) }); + + // Create the Bot Framework Adapter with error handling enabled. + 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, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseHsts(); + } + + app.UseDefaultFiles(); + app.UseStaticFiles(); + + // app.UseHttpsRedirection(); + app.UseMvc(); + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/appsettings.json b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/appsettings.json new file mode 100644 index 0000000000..25020d1fd2 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/appsettings.json @@ -0,0 +1,5 @@ +{ + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + "AllowedCallers": [] +} \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/default.htm b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/default.htm new file mode 100644 index 0000000000..66b6c2b2da --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/default.htm @@ -0,0 +1,420 @@ + + + + + + + EchoSkillBot + + + + + +
+
+
+
EchoSkillBot 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/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/manifest/echoskillbot-manifest-1.0.json b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/manifest/echoskillbot-manifest-1.0.json new file mode 100644 index 0000000000..1878fade29 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/EchoSkillBot/wwwroot/manifest/echoskillbot-manifest-1.0.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/botframework-sdk/master/schemas/skills/skill-manifest-2.0.0.json", + "$id": "EchoSkillBot", + "name": "Echo Skill bot", + "version": "1.0", + "description": "This is a sample echo skill", + "publisherName": "Microsoft", + "privacyUrl": "https://echoskillbot.contoso.com/privacy.html", + "copyright": "Copyright (c) Microsoft Corporation. All rights reserved.", + "license": "", + "iconUrl": "https://echoskillbot.contoso.com/icon.png", + "tags": [ + "sample", + "echo" + ], + "endpoints": [ + { + "name": "default", + "protocol": "BotFrameworkV3", + "description": "Default endpoint for the skill", + "endpointUrl": "http://echoskillbot.contoso.com/api/messages", + "msAppId": "00000000-0000-0000-0000-000000000000" + } + ] +} \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/README.md b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/README.md new file mode 100644 index 0000000000..017fd0f504 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/README.md @@ -0,0 +1,61 @@ +# SimpleBotToBot Echo Skill + +Bot Framework v4 skills echo sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple skill consumer (SimpleRootBot) that sends message activities to a skill (EchoSkillBot) that echoes it back. + +## Prerequisites + +- [.NET Core SDK](https://dotnet.microsoft.com/download) version 2.1 + + ```bash + # determine dotnet version + dotnet --version + ``` + +## Key concepts in this sample + +The solution includes a parent bot (`SimpleRootBot`) and a skill bot (`EchoSkillBot`) and shows how the parent bot can post activities to the skill bot and returns the skill responses to the user. + +- `SimpleRootBot`: this project shows how to consume an echo skill and includes: + - A [RootBot](SimpleRootBot/Bots/RootBot.cs) that calls the echo skill and keeps the conversation active until the user says "end" or "stop". [RootBot](SimpleRootBot/Bots/RootBot.cs) also keeps track of the conversation with the skill and handles the `EndOfConversation` activity received from the skill to terminate the conversation + - A simple [SkillConversationIdFactory](SimpleRootBot/SkillConversationIdFactory.cs) based on an in memory `ConcurrentDictionary` that creates and maintains conversation IDs used to interact with a skill + - A [SkillsConfiguration](SimpleRootBot/SkillsConfiguration.cs) class that can load skill definitions from `appsettings` + - A [SkillController](SimpleRootBot/Controllers/SkillController.cs) that handles skill responses + - An [AllowedSkillsClaimsValidator](SimpleRootBot/Authentication/AllowedSkillsClaimsValidator.cs) class that is used to authenticate that responses sent to the bot are coming from the configured skills + - A [Startup](SimpleRootBot/Startup.cs) class that shows how to register the different skill components for dependency injection +- `EchoSkillBot`: this project shows a simple echo skill that receives message activities from the parent bot and echoes what the user said. This project includes: + - A sample [EchoBot](EchoSkillBot/Bots/EchoBot.cs) that shows how to send EndOfConversation based on the message sent to the skill and yield control back to the parent bot + - A sample [AllowedCallersClaimsValidator](EchoSkillBot/Authentication/AllowedCallersClaimsValidator.cs) that shows how validate that the skill is only invoked from a list of allowed callers + - A [sample skill manifest](EchoSkillBot/wwwroot/manifest/echoskillbot-manifest-1.0.json) that describes what the skill can do + +## To try this sample + +- Clone the repository + + ```bash + git clone https://github.com/microsoft/botbuilder-samples.git + ``` + +- Create a bot registration in the azure portal for the `EchoSkillBot` and update [EchoSkillBot/appsettings.json](EchoSkillBot/appsettings.json) with the `MicrosoftAppId` and `MicrosoftAppPassword` of the new bot registration +- Create a bot registration in the azure portal for the `SimpleRootBot` and update [SimpleRootBot/appsettings.json](SimpleRootBot/appsettings.json) with the `MicrosoftAppId` and `MicrosoftAppPassword` of the new bot registration +- Update the `BotFrameworkSkills` section in [SimpleRootBot/appsettings.json](SimpleRootBot/appsettings.json) with the app ID for the skill you created in the previous step +- (Optionally) Add the `SimpleRootBot` `MicrosoftAppId` to the `AllowedCallers` list in [EchoSkillBot/appsettings.json](EchoSkillBot/appsettings.json) +- Open the `SimpleBotToBot.sln` solution and configure it to [start debugging with multiple processes](https://docs.microsoft.com/en-us/visualstudio/debugger/debug-multiple-processes?view=vs-2019#start-debugging-with-multiple-processes) + + +## Testing the bot using Bot Framework Emulator + +[Bot Framework Emulator](https://github.com/microsoft/botframework-emulator) is a desktop application that allows bot developers to test and debug their bots on localhost or running remotely through a tunnel. + +- Install the Bot Framework Emulator version 4.7.0 or greater from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + +### Connect to the bot using Bot Framework Emulator + +- Launch Bot Framework Emulator +- File -> Open Bot +- Enter a Bot URL of `http://localhost:3978/api/messages`, the `MicrosoftAppId` and `MicrosoftAppPassword` for the `SimpleRootBot` + +## Deploy the bots to Azure + +To learn more about deploying a bot to Azure, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) for a complete list of deployment instructions. diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleBotToBot.sln b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleBotToBot.sln new file mode 100644 index 0000000000..319e8a1d4a --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleBotToBot.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29519.181 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleRootBot", "SimpleRootBot\SimpleRootBot.csproj", "{966E5AB4-0697-4F8B-99CE-A80AE262A10A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EchoSkillBot", "EchoSkillBot\EchoSkillBot.csproj", "{5345ACF8-8695-4148-A8A5-44F335450297}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EC72D0A6-71CD-4A94-A1D8-F30D92633514}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {966E5AB4-0697-4F8B-99CE-A80AE262A10A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {966E5AB4-0697-4F8B-99CE-A80AE262A10A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {966E5AB4-0697-4F8B-99CE-A80AE262A10A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {966E5AB4-0697-4F8B-99CE-A80AE262A10A}.Release|Any CPU.Build.0 = Release|Any CPU + {5345ACF8-8695-4148-A8A5-44F335450297}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5345ACF8-8695-4148-A8A5-44F335450297}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5345ACF8-8695-4148-A8A5-44F335450297}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5345ACF8-8695-4148-A8A5-44F335450297}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {978F195B-2048-4D19-AA0E-025407640208} + EndGlobalSection +EndGlobal diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/AdapterWithErrorHandler.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/AdapterWithErrorHandler.cs new file mode 100644 index 0000000000..d6e18bb3f6 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/AdapterWithErrorHandler.cs @@ -0,0 +1,54 @@ +// 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.SimpleRootBot +{ + public class AdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public AdapterWithErrorHandler(IConfiguration configuration, ILogger logger, ConversationState conversationState = null) + : base(configuration, logger) + { + 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 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); + + 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 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"); + + }; + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Authentication/AllowedSkillsClaimsValidator.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Authentication/AllowedSkillsClaimsValidator.cs new file mode 100644 index 0000000000..0a113dacff --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/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.SimpleRootBot.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/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Bots/RootBot.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Bots/RootBot.cs new file mode 100644 index 0000000000..84c1fcf617 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Bots/RootBot.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder; +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; + +namespace Microsoft.BotBuilderSamples.SimpleRootBot.Bots +{ + public class RootBot : ActivityHandler + { + private readonly IStatePropertyAccessor _activeSkillProperty; + private readonly string _botId; + private readonly ConversationState _conversationState; + private readonly SkillHttpClient _skillClient; + private readonly SkillsConfiguration _skillsConfig; + private readonly BotFrameworkSkill _targetSkill; + + public RootBot(ConversationState conversationState, SkillsConfiguration skillsConfig, SkillHttpClient skillClient, IConfiguration configuration) + { + _conversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState)); + _skillsConfig = skillsConfig ?? throw new ArgumentNullException(nameof(skillsConfig)); + _skillClient = skillClient ?? throw new ArgumentNullException(nameof(skillsConfig)); + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + _botId = configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value; + if (string.IsNullOrWhiteSpace(_botId)) + { + throw new ArgumentException($"{MicrosoftAppCredentials.MicrosoftAppIdKey} is not set in configuration"); + } + + // We use a single skill in this example. + var targetSkillId = "EchoSkillBot"; + if (!_skillsConfig.Skills.TryGetValue(targetSkillId, out _targetSkill)) + { + throw new ArgumentException($"Skill with ID \"{targetSkillId}\" not found in configuration"); + } + + // Create state property to track the active skill + _activeSkillProperty = conversationState.CreateProperty("activeSkillProperty"); + } + + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + // Try to get the active skill + var activeSkill = await _activeSkillProperty.GetAsync(turnContext, () => null, cancellationToken); + + if (activeSkill != null) + { + // Send the activity to the skill + await SendToSkill(turnContext, activeSkill, cancellationToken); + return; + } + + if (turnContext.Activity.Text.Contains("skill")) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Got it, connecting you to the skill..."), cancellationToken); + + // Save active skill in state + activeSkill = _targetSkill; + await _activeSkillProperty.SetAsync(turnContext, activeSkill, cancellationToken); + + // Send the activity to the skill + await SendToSkill(turnContext, activeSkill, cancellationToken); + return; + } + + // just respond + await turnContext.SendActivityAsync(MessageFactory.Text("Me no nothin'. Say \"skill\" and I'll patch you through"), cancellationToken); + + // Save conversation state + await _conversationState.SaveChangesAsync(turnContext, force: true, cancellationToken: cancellationToken); + } + + protected override async Task OnEndOfConversationActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + // forget skill invocation + await _activeSkillProperty.DeleteAsync(turnContext, cancellationToken); + + // Show status message, text and value returned by the skill + var eocActivityMessage = $"Received {ActivityTypes.EndOfConversation}.\n\nCode: {turnContext.Activity.Code}"; + if (!string.IsNullOrWhiteSpace(turnContext.Activity.Text)) + { + eocActivityMessage += $"\n\nText: {turnContext.Activity.Text}"; + } + + if ((turnContext.Activity as Activity)?.Value != null) + { + eocActivityMessage += $"\n\nValue: {JsonConvert.SerializeObject((turnContext.Activity as Activity)?.Value)}"; + } + + await turnContext.SendActivityAsync(MessageFactory.Text(eocActivityMessage), cancellationToken); + + // We are back at the root + await turnContext.SendActivityAsync(MessageFactory.Text("Back in the root bot. Say \"skill\" and I'll patch you through"), cancellationToken); + + // Save conversation state + await _conversationState.SaveChangesAsync(turnContext, cancellationToken: cancellationToken); + } + + protected override async Task OnMembersAddedAsync(IList membersAdded, ITurnContext turnContext, CancellationToken cancellationToken) + { + foreach (var member in membersAdded) + { + if (member.Id != turnContext.Activity.Recipient.Id) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Hello and welcome!"), cancellationToken); + } + } + } + + private async Task SendToSkill(ITurnContext turnContext, BotFrameworkSkill targetSkill, CancellationToken cancellationToken) + { + // NOTE: Always SaveChanges() before calling a skill so that any activity generated by the skill + // will have access to current accurate state. + await _conversationState.SaveChangesAsync(turnContext, force: true, cancellationToken: cancellationToken); + + // route the activity to the skill + var response = await _skillClient.PostActivityAsync(_botId, targetSkill, _skillsConfig.SkillHostEndpoint, (Activity)turnContext.Activity, cancellationToken); + + // Check response status + if (!(response.Status >= 200 && response.Status <= 299)) + { + throw new HttpRequestException($"Error invoking the skill id: \"{targetSkill.Id}\" at \"{targetSkill.SkillEndpoint}\" (status is {response.Status}). \r\n {response.Body}"); + } + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Controllers/BotController.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Controllers/BotController.cs new file mode 100644 index 0000000000..46b95cf4c2 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Controllers/BotController.cs @@ -0,0 +1,33 @@ +// 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.SimpleRootBot.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 BotFrameworkHttpAdapter _adapter; + private readonly IBot _bot; + + public BotController(BotFrameworkHttpAdapter adapter, IBot bot) + { + _adapter = adapter; + _bot = bot; + } + + [HttpPost] + public async Task PostAsync() + { + await _adapter.ProcessAsync(Request, Response, _bot); + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Controllers/SkillController.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Controllers/SkillController.cs new file mode 100644 index 0000000000..724fc8e506 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/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.SimpleRootBot.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/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Program.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Program.cs new file mode 100644 index 0000000000..5f0f923f9c --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Program.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace Microsoft.BotBuilderSamples.SimpleRootBot +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup(); + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Properties/launchSettings.json b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Properties/launchSettings.json new file mode 100644 index 0000000000..1c2c8cddb1 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:3978", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "SimpleRootBot": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/README.md b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/README.md new file mode 100644 index 0000000000..9986382b5e --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/README.md @@ -0,0 +1,3 @@ +# SimpleRootBot + +See [SkillSimpleBotToBot](../) for details on how to configure and run this sample. \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SimpleRootBot.csproj b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SimpleRootBot.csproj new file mode 100644 index 0000000000..52b5249df4 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SimpleRootBot.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp2.1 + latest + 6310bd04-2272-4a74-82fa-6791f5c7e115 + Microsoft.BotBuilderSamples.SimpleRootBot + Microsoft.BotBuilderSamples.SimpleRootBot + + + + DEBUG;TRACE + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SkillConversationIdFactory.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SkillConversationIdFactory.cs new file mode 100644 index 0000000000..0bccd1b8be --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SkillConversationIdFactory.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Builder.Skills; +using Microsoft.Bot.Schema; +using Newtonsoft.Json; + +namespace Microsoft.BotBuilderSamples.SimpleRootBot +{ + /// + /// A that uses an in memory + /// to store and retrieve instances. + /// + public class SkillConversationIdFactory : SkillConversationIdFactoryBase + { + private readonly ConcurrentDictionary _conversationRefs = new ConcurrentDictionary(); + + public override Task CreateSkillConversationIdAsync(ConversationReference conversationReference, CancellationToken cancellationToken) + { + var crJson = JsonConvert.SerializeObject(conversationReference); + var key = $"{conversationReference.Conversation.Id}-{conversationReference.ChannelId}-skillconvo"; + _conversationRefs.GetOrAdd(key, crJson); + return Task.FromResult(key); + } + + public override Task GetConversationReferenceAsync(string skillConversationId, CancellationToken cancellationToken) + { + var conversationReference = JsonConvert.DeserializeObject(_conversationRefs[skillConversationId]); + return Task.FromResult(conversationReference); + } + + public override Task DeleteConversationReferenceAsync(string skillConversationId, CancellationToken cancellationToken) + { + _conversationRefs.TryRemove(skillConversationId, out _); + return Task.CompletedTask; + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SkillsConfiguration.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/SkillsConfiguration.cs new file mode 100644 index 0000000000..258af9ebce --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/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.SimpleRootBot +{ + /// + /// 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/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Startup.cs b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Startup.cs new file mode 100644 index 0000000000..0ba4eb19ea --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/Startup.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +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.SimpleRootBot.Authentication; +using Microsoft.BotBuilderSamples.SimpleRootBot.Bots; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.BotBuilderSamples.SimpleRootBot +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + + // Configure credentials + 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 use the base BotAdapter so we add an extra registration that pulls the same instance. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetService()); + + // Register the skills client and skills 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 Conversation state (used by the Dialog system itself). + services.AddSingleton(); + + // Register 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, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseHsts(); + } + + app.UseDefaultFiles(); + app.UseStaticFiles(); + + // app.UseHttpsRedirection(); + app.UseMvc(); + } + } +} diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/appsettings.json b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/appsettings.json new file mode 100644 index 0000000000..785965334b --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/appsettings.json @@ -0,0 +1,12 @@ +{ + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + "SkillHostEndpoint": "http://localhost:3978/api/skills/", + "BotFrameworkSkills": [ + { + "Id": "EchoSkillBot", + "AppId": "TODO: Add here the App ID for the skill", + "SkillEndpoint": "http://localhost:39783/api/messages" + } + ] +} \ No newline at end of file diff --git a/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/wwwroot/default.htm b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/wwwroot/default.htm new file mode 100644 index 0000000000..6c9a7a46b7 --- /dev/null +++ b/samples/csharp_dotnetcore/70.skills-simple-bot-to-bot/SimpleRootBot/wwwroot/default.htm @@ -0,0 +1,420 @@ + + + + + + + SimpleRootBot + + + + + +
+
+
+
SimpleRootBot 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/samples/csharp_dotnetcore/csharp_dotnetcore.sln b/samples/csharp_dotnetcore/csharp_dotnetcore.sln index fb83c8be99..aa24d83847 100644 --- a/samples/csharp_dotnetcore/csharp_dotnetcore.sln +++ b/samples/csharp_dotnetcore/csharp_dotnetcore.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.28307.902 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29609.76 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Console-EchoBot", "01.console-echo\Console-EchoBot.csproj", "{ECFF9720-28CF-4F7D-896B-DA489D1032EA}" EndProject @@ -67,8 +67,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamsTaskModule", "54.teams EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamsLinkUnfurling", "55.teams-link-unfurling\TeamsLinkUnfurling.csproj", "{F07C474E-B23D-4CB9-90E4-59E33BD82461}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A76D8B99-E1D9-4802-998F-1AFBED734ABD}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamsConversationBot", "57.teams-conversation-bot\TeamsConversationBot.csproj", "{3F435255-0B27-439A-9C62-259959E19042}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamsFileUpload", "56.teams-file-upload\TeamsFileUpload.csproj", "{7DDAB89B-025E-4E3F-BCC9-36F5B8C64C44}" @@ -79,6 +77,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QnABot", "11.qnamaker\QnABo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessageReaction", "25.message-reaction\MessageReaction.csproj", "{4F6B070A-029F-4A93-A229-43C8198DE85E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SkillsSimpleBotToBot", "SkillsSimpleBotToBot", "{A5567E91-578D-4212-A3CA-2EFEA116E109}" + ProjectSection(SolutionItems) = preProject + 70.skills-simple-bot-to-bot\README.md = 70.skills-simple-bot-to-bot\README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EchoSkillBot", "70.skills-simple-bot-to-bot\EchoSkillBot\EchoSkillBot.csproj", "{2FBBEF64-E34C-486A-B98E-65B5B722C24E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleRootBot", "70.skills-simple-bot-to-bot\SimpleRootBot\SimpleRootBot.csproj", "{13406183-D1CB-4C2E-A3A6-95559CFB922F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -233,10 +240,22 @@ Global {4F6B070A-029F-4A93-A229-43C8198DE85E}.Debug|Any CPU.Build.0 = Debug|Any CPU {4F6B070A-029F-4A93-A229-43C8198DE85E}.Release|Any CPU.ActiveCfg = Release|Any CPU {4F6B070A-029F-4A93-A229-43C8198DE85E}.Release|Any CPU.Build.0 = Release|Any CPU + {2FBBEF64-E34C-486A-B98E-65B5B722C24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FBBEF64-E34C-486A-B98E-65B5B722C24E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FBBEF64-E34C-486A-B98E-65B5B722C24E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FBBEF64-E34C-486A-B98E-65B5B722C24E}.Release|Any CPU.Build.0 = Release|Any CPU + {13406183-D1CB-4C2E-A3A6-95559CFB922F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13406183-D1CB-4C2E-A3A6-95559CFB922F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13406183-D1CB-4C2E-A3A6-95559CFB922F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13406183-D1CB-4C2E-A3A6-95559CFB922F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2FBBEF64-E34C-486A-B98E-65B5B722C24E} = {A5567E91-578D-4212-A3CA-2EFEA116E109} + {13406183-D1CB-4C2E-A3A6-95559CFB922F} = {A5567E91-578D-4212-A3CA-2EFEA116E109} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6AF4AF72-D00E-45C4-B3B2-E30FBE53245A} EndGlobalSection