From c885f7f5dbfbdfe964f349dfeee9500bda31520e Mon Sep 17 00:00:00 2001 From: John Taylor Date: Tue, 7 Jul 2020 16:59:00 -0700 Subject: [PATCH 1/3] three test projects Bot1 Bot2 Bot3 --- Microsoft.Bot.Builder.sln | 49 +++++- tests/Skills/Bot1/AdapterWithErrorHandler.cs | 24 +++ .../Bot1/AllowedSkillsClaimsValidator.cs | 47 ++++++ tests/Skills/Bot1/Bot1.cs | 153 ++++++++++++++++++ tests/Skills/Bot1/Bot1.csproj | 19 +++ .../Skills/Bot1/Controllers/BotController.cs | 34 ++++ .../Bot1/Controllers/SkillController.cs | 24 +++ tests/Skills/Bot1/Program.cs | 29 ++++ .../Skills/Bot1/SkillConversationIdFactory.cs | 45 ++++++ tests/Skills/Bot1/SkillsConfiguration.cs | 39 +++++ tests/Skills/Bot1/Startup.cs | 76 +++++++++ tests/Skills/Bot1/appsettings.json | 12 ++ tests/Skills/Bot2/AdapterWithErrorHandler.cs | 24 +++ .../Bot2/AllowedSkillsClaimsValidator.cs | 47 ++++++ tests/Skills/Bot2/Bot2.cs | 153 ++++++++++++++++++ tests/Skills/Bot2/Bot2.csproj | 19 +++ .../Skills/Bot2/Controllers/BotController.cs | 34 ++++ .../Bot2/Controllers/SkillController.cs | 24 +++ tests/Skills/Bot2/Program.cs | 29 ++++ .../Skills/Bot2/SkillConversationIdFactory.cs | 45 ++++++ tests/Skills/Bot2/SkillsConfiguration.cs | 39 +++++ tests/Skills/Bot2/Startup.cs | 76 +++++++++ tests/Skills/Bot2/appsettings.json | 12 ++ tests/Skills/Bot3/AdapterWithErrorHandler.cs | 24 +++ tests/Skills/Bot3/Bot3.cs | 21 +++ tests/Skills/Bot3/Bot3.csproj | 15 ++ .../Skills/Bot3/Controllers/BotController.cs | 34 ++++ tests/Skills/Bot3/Program.cs | 29 ++++ tests/Skills/Bot3/Startup.cs | 44 +++++ tests/Skills/Bot3/appsettings.json | 4 + 30 files changed, 1219 insertions(+), 5 deletions(-) create mode 100644 tests/Skills/Bot1/AdapterWithErrorHandler.cs create mode 100644 tests/Skills/Bot1/AllowedSkillsClaimsValidator.cs create mode 100644 tests/Skills/Bot1/Bot1.cs create mode 100644 tests/Skills/Bot1/Bot1.csproj create mode 100644 tests/Skills/Bot1/Controllers/BotController.cs create mode 100644 tests/Skills/Bot1/Controllers/SkillController.cs create mode 100644 tests/Skills/Bot1/Program.cs create mode 100644 tests/Skills/Bot1/SkillConversationIdFactory.cs create mode 100644 tests/Skills/Bot1/SkillsConfiguration.cs create mode 100644 tests/Skills/Bot1/Startup.cs create mode 100644 tests/Skills/Bot1/appsettings.json create mode 100644 tests/Skills/Bot2/AdapterWithErrorHandler.cs create mode 100644 tests/Skills/Bot2/AllowedSkillsClaimsValidator.cs create mode 100644 tests/Skills/Bot2/Bot2.cs create mode 100644 tests/Skills/Bot2/Bot2.csproj create mode 100644 tests/Skills/Bot2/Controllers/BotController.cs create mode 100644 tests/Skills/Bot2/Controllers/SkillController.cs create mode 100644 tests/Skills/Bot2/Program.cs create mode 100644 tests/Skills/Bot2/SkillConversationIdFactory.cs create mode 100644 tests/Skills/Bot2/SkillsConfiguration.cs create mode 100644 tests/Skills/Bot2/Startup.cs create mode 100644 tests/Skills/Bot2/appsettings.json create mode 100644 tests/Skills/Bot3/AdapterWithErrorHandler.cs create mode 100644 tests/Skills/Bot3/Bot3.cs create mode 100644 tests/Skills/Bot3/Bot3.csproj create mode 100644 tests/Skills/Bot3/Controllers/BotController.cs create mode 100644 tests/Skills/Bot3/Program.cs create mode 100644 tests/Skills/Bot3/Startup.cs create mode 100644 tests/Skills/Bot3/appsettings.json diff --git a/Microsoft.Bot.Builder.sln b/Microsoft.Bot.Builder.sln index 1cb4601a3f..415b42601d 100644 --- a/Microsoft.Bot.Builder.sln +++ b/Microsoft.Bot.Builder.sln @@ -181,9 +181,6 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Bot.Builder.Dialogs.Adaptive.Testing", "libraries\Microsoft.Bot.Builder.Dialogs.Adaptive.Testing\Microsoft.Bot.Builder.Dialogs.Adaptive.Testing.csproj", "{D921D320-0450-455F-8DF2-70EDAC5CCE68}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Skills", "Skills", "{54DA838C-8BB8-4038-8BDB-D887C02B2D9A}" - ProjectSection(SolutionItems) = preProject - tests\Skills\ReadMeForSSOTesting.md = tests\Skills\ReadMeForSSOTesting.md - EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Parent", "tests\Skills\Parent\Parent.csproj", "{7F8ED2E7-A4BE-4855-BAF2-95657220E419}" EndProject @@ -193,6 +190,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Bot.Builder.Dialo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Bot.Builder.Dialogs.Adaptive.Teams.Tests", "tests\Microsoft.Bot.Builder.Dialogs.Adaptive.Teams.Tests\Microsoft.Bot.Builder.Dialogs.Adaptive.Teams.Tests.csproj", "{DD5071A9-BAE1-45EF-814C-D071BE63CD47}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SSO", "SSO", "{AA545986-D3E6-406D-8BD5-305B809FB399}" + ProjectSection(SolutionItems) = preProject + tests\Skills\ReadMeForSSOTesting.md = tests\Skills\ReadMeForSSOTesting.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "End2End", "End2End", "{1024840D-8B5A-4E39-8F6D-F7430DD3749E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bot1", "tests\Skills\Bot1\Bot1.csproj", "{2367D12D-2A9B-4F52-8948-78C1EDE9059A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bot2", "tests\Skills\Bot2\Bot2.csproj", "{36D6E1D7-2506-4096-8D76-55C3CCD32DFE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bot3", "tests\Skills\Bot3\Bot3.csproj", "{85463C41-8D08-4A9D-A475-68A83730431C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -769,6 +779,30 @@ Global {DD5071A9-BAE1-45EF-814C-D071BE63CD47}.Release|Any CPU.Build.0 = Release|Any CPU {DD5071A9-BAE1-45EF-814C-D071BE63CD47}.Release-Windows|Any CPU.ActiveCfg = Release|Any CPU {DD5071A9-BAE1-45EF-814C-D071BE63CD47}.Release-Windows|Any CPU.Build.0 = Release|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Debug-Windows|Any CPU.ActiveCfg = Debug|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Debug-Windows|Any CPU.Build.0 = Debug|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Release|Any CPU.Build.0 = Release|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Release-Windows|Any CPU.ActiveCfg = Release|Any CPU + {2367D12D-2A9B-4F52-8948-78C1EDE9059A}.Release-Windows|Any CPU.Build.0 = Release|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Debug-Windows|Any CPU.ActiveCfg = Debug|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Debug-Windows|Any CPU.Build.0 = Debug|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Release|Any CPU.Build.0 = Release|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Release-Windows|Any CPU.ActiveCfg = Release|Any CPU + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE}.Release-Windows|Any CPU.Build.0 = Release|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Debug-Windows|Any CPU.ActiveCfg = Debug|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Debug-Windows|Any CPU.Build.0 = Debug|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Release|Any CPU.Build.0 = Release|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Release-Windows|Any CPU.ActiveCfg = Release|Any CPU + {85463C41-8D08-4A9D-A475-68A83730431C}.Release-Windows|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -848,10 +882,15 @@ Global {2FBA2BB7-73C8-45CD-99B7-D65F817C9051} = {AD743B78-D61F-4FBF-B620-FA83CE599A50} {D921D320-0450-455F-8DF2-70EDAC5CCE68} = {4269F3C3-6B42-419B-B64A-3E6DC0F1574A} {54DA838C-8BB8-4038-8BDB-D887C02B2D9A} = {AD743B78-D61F-4FBF-B620-FA83CE599A50} - {7F8ED2E7-A4BE-4855-BAF2-95657220E419} = {54DA838C-8BB8-4038-8BDB-D887C02B2D9A} - {1958FBA4-BF2D-48D9-A5DB-8915F553EBD3} = {54DA838C-8BB8-4038-8BDB-D887C02B2D9A} + {7F8ED2E7-A4BE-4855-BAF2-95657220E419} = {AA545986-D3E6-406D-8BD5-305B809FB399} + {1958FBA4-BF2D-48D9-A5DB-8915F553EBD3} = {AA545986-D3E6-406D-8BD5-305B809FB399} {23BF6935-D648-4874-91C7-1BF4D6FF64E3} = {4269F3C3-6B42-419B-B64A-3E6DC0F1574A} {DD5071A9-BAE1-45EF-814C-D071BE63CD47} = {AD743B78-D61F-4FBF-B620-FA83CE599A50} + {AA545986-D3E6-406D-8BD5-305B809FB399} = {54DA838C-8BB8-4038-8BDB-D887C02B2D9A} + {1024840D-8B5A-4E39-8F6D-F7430DD3749E} = {54DA838C-8BB8-4038-8BDB-D887C02B2D9A} + {2367D12D-2A9B-4F52-8948-78C1EDE9059A} = {1024840D-8B5A-4E39-8F6D-F7430DD3749E} + {36D6E1D7-2506-4096-8D76-55C3CCD32DFE} = {1024840D-8B5A-4E39-8F6D-F7430DD3749E} + {85463C41-8D08-4A9D-A475-68A83730431C} = {1024840D-8B5A-4E39-8F6D-F7430DD3749E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7173C9F3-A7F9-496E-9078-9156E35D6E16} diff --git a/tests/Skills/Bot1/AdapterWithErrorHandler.cs b/tests/Skills/Bot1/AdapterWithErrorHandler.cs new file mode 100644 index 0000000000..ca3238dcb1 --- /dev/null +++ b/tests/Skills/Bot1/AdapterWithErrorHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples +{ + public class AdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public AdapterWithErrorHandler(IConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + OnTurnError = (turnContext, exception) => + { + // Log any leaked exception from the application. + logger.LogError($"Exception caught : {exception.Message}"); + return Task.CompletedTask; + }; + } + } +} diff --git a/tests/Skills/Bot1/AllowedSkillsClaimsValidator.cs b/tests/Skills/Bot1/AllowedSkillsClaimsValidator.cs new file mode 100644 index 0000000000..0a113dacff --- /dev/null +++ b/tests/Skills/Bot1/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/tests/Skills/Bot1/Bot1.cs b/tests/Skills/Bot1/Bot1.cs new file mode 100644 index 0000000000..320c68e4b5 --- /dev/null +++ b/tests/Skills/Bot1/Bot1.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +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.BotBuilderSamples.SimpleRootBot; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; + +namespace Bot1 +{ + public class Bot1 : ActivityHandler + { + public static readonly string ActiveSkillPropertyName = $"{typeof(Bot1).FullName}.ActiveSkillProperty"; + 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 Bot1(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 = "NextBot"; + 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(ActiveSkillPropertyName); + } + + public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + // Forward all activities except EndOfConversation to the skill. + if (turnContext.Activity.Type != ActivityTypes.EndOfConversation) + { + // 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; + } + } + + await base.OnTurnAsync(turnContext, cancellationToken); + + // Save any state changes that might have occured during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + if (turnContext.Activity.Text.Contains("skill")) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Got it, connecting you to the skill..."), cancellationToken); + + // Save active skill in state + await _activeSkillProperty.SetAsync(turnContext, _targetSkill, cancellationToken); + + // Send the activity to the skill + await SendToSkill(turnContext, _targetSkill, 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, 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/tests/Skills/Bot1/Bot1.csproj b/tests/Skills/Bot1/Bot1.csproj new file mode 100644 index 0000000000..c28bca6a2b --- /dev/null +++ b/tests/Skills/Bot1/Bot1.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + + + + diff --git a/tests/Skills/Bot1/Controllers/BotController.cs b/tests/Skills/Bot1/Controllers/BotController.cs new file mode 100644 index 0000000000..a1b978577a --- /dev/null +++ b/tests/Skills/Bot1/Controllers/BotController.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Microsoft.BotBuilderSamples +{ + // 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 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/tests/Skills/Bot1/Controllers/SkillController.cs b/tests/Skills/Bot1/Controllers/SkillController.cs new file mode 100644 index 0000000000..724fc8e506 --- /dev/null +++ b/tests/Skills/Bot1/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/tests/Skills/Bot1/Program.cs b/tests/Skills/Bot1/Program.cs new file mode 100644 index 0000000000..7086e5b546 --- /dev/null +++ b/tests/Skills/Bot1/Program.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bot1 +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/tests/Skills/Bot1/SkillConversationIdFactory.cs b/tests/Skills/Bot1/SkillConversationIdFactory.cs new file mode 100644 index 0000000000..ad42157340 --- /dev/null +++ b/tests/Skills/Bot1/SkillConversationIdFactory.cs @@ -0,0 +1,45 @@ +// 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(SkillConversationIdFactoryOptions options, CancellationToken cancellationToken) + { + var skillConversationReference = new SkillConversationReference + { + ConversationReference = options.Activity.GetConversationReference(), + OAuthScope = options.FromBotOAuthScope + }; + var key = $"{options.FromBotId}-{options.BotFrameworkSkill.AppId}-{skillConversationReference.ConversationReference.Conversation.Id}-{skillConversationReference.ConversationReference.ChannelId}-skillconvo"; + _conversationRefs.GetOrAdd(key, JsonConvert.SerializeObject(skillConversationReference)); + return Task.FromResult(key); + } + + public override Task GetSkillConversationReferenceAsync(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/tests/Skills/Bot1/SkillsConfiguration.cs b/tests/Skills/Bot1/SkillsConfiguration.cs new file mode 100644 index 0000000000..258af9ebce --- /dev/null +++ b/tests/Skills/Bot1/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/tests/Skills/Bot1/Startup.cs b/tests/Skills/Bot1/Startup.cs new file mode 100644 index 0000000000..8daa5be3a4 --- /dev/null +++ b/tests/Skills/Bot1/Startup.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.BotFramework; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Skills; +using Microsoft.Bot.Builder.Skills; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.BotBuilderSamples; +using Microsoft.BotBuilderSamples.SimpleRootBot; +using Microsoft.BotBuilderSamples.SimpleRootBot.Authentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bot1 +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers().AddNewtonsoftJson(); + + // 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 => (BotFrameworkAdapter)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(); + + services.AddTransient(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseDefaultFiles() + .UseStaticFiles() + .UseRouting() + .UseAuthorization() + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/tests/Skills/Bot1/appsettings.json b/tests/Skills/Bot1/appsettings.json new file mode 100644 index 0000000000..61ac4ec1f5 --- /dev/null +++ b/tests/Skills/Bot1/appsettings.json @@ -0,0 +1,12 @@ +{ + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + "SkillHostEndpoint": "http://localhost:3978/api/skills/", + "BotFrameworkSkills": [ + { + "Id": "NextBot", + "AppId": "", + "SkillEndpoint": "http://localhost:39782/api/messages" + } + ] +} diff --git a/tests/Skills/Bot2/AdapterWithErrorHandler.cs b/tests/Skills/Bot2/AdapterWithErrorHandler.cs new file mode 100644 index 0000000000..ca3238dcb1 --- /dev/null +++ b/tests/Skills/Bot2/AdapterWithErrorHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples +{ + public class AdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public AdapterWithErrorHandler(IConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + OnTurnError = (turnContext, exception) => + { + // Log any leaked exception from the application. + logger.LogError($"Exception caught : {exception.Message}"); + return Task.CompletedTask; + }; + } + } +} diff --git a/tests/Skills/Bot2/AllowedSkillsClaimsValidator.cs b/tests/Skills/Bot2/AllowedSkillsClaimsValidator.cs new file mode 100644 index 0000000000..0a113dacff --- /dev/null +++ b/tests/Skills/Bot2/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/tests/Skills/Bot2/Bot2.cs b/tests/Skills/Bot2/Bot2.cs new file mode 100644 index 0000000000..04cae6dec5 --- /dev/null +++ b/tests/Skills/Bot2/Bot2.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +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.BotBuilderSamples.SimpleRootBot; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; + +namespace Bot2 +{ + public class Bot2 : ActivityHandler + { + public static readonly string ActiveSkillPropertyName = $"{typeof(Bot2).FullName}.ActiveSkillProperty"; + 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 Bot2(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 = "NextBot"; + 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(ActiveSkillPropertyName); + } + + public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + // Forward all activities except EndOfConversation to the skill. + if (turnContext.Activity.Type != ActivityTypes.EndOfConversation) + { + // 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; + } + } + + await base.OnTurnAsync(turnContext, cancellationToken); + + // Save any state changes that might have occured during the turn. + await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken); + } + + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + if (turnContext.Activity.Text.Contains("skill")) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Got it, connecting you to the skill..."), cancellationToken); + + // Save active skill in state + await _activeSkillProperty.SetAsync(turnContext, _targetSkill, cancellationToken); + + // Send the activity to the skill + await SendToSkill(turnContext, _targetSkill, 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, 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/tests/Skills/Bot2/Bot2.csproj b/tests/Skills/Bot2/Bot2.csproj new file mode 100644 index 0000000000..c28bca6a2b --- /dev/null +++ b/tests/Skills/Bot2/Bot2.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + + + + diff --git a/tests/Skills/Bot2/Controllers/BotController.cs b/tests/Skills/Bot2/Controllers/BotController.cs new file mode 100644 index 0000000000..a1b978577a --- /dev/null +++ b/tests/Skills/Bot2/Controllers/BotController.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Microsoft.BotBuilderSamples +{ + // 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 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/tests/Skills/Bot2/Controllers/SkillController.cs b/tests/Skills/Bot2/Controllers/SkillController.cs new file mode 100644 index 0000000000..724fc8e506 --- /dev/null +++ b/tests/Skills/Bot2/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/tests/Skills/Bot2/Program.cs b/tests/Skills/Bot2/Program.cs new file mode 100644 index 0000000000..aa78393f1c --- /dev/null +++ b/tests/Skills/Bot2/Program.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bot2 +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/tests/Skills/Bot2/SkillConversationIdFactory.cs b/tests/Skills/Bot2/SkillConversationIdFactory.cs new file mode 100644 index 0000000000..ad42157340 --- /dev/null +++ b/tests/Skills/Bot2/SkillConversationIdFactory.cs @@ -0,0 +1,45 @@ +// 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(SkillConversationIdFactoryOptions options, CancellationToken cancellationToken) + { + var skillConversationReference = new SkillConversationReference + { + ConversationReference = options.Activity.GetConversationReference(), + OAuthScope = options.FromBotOAuthScope + }; + var key = $"{options.FromBotId}-{options.BotFrameworkSkill.AppId}-{skillConversationReference.ConversationReference.Conversation.Id}-{skillConversationReference.ConversationReference.ChannelId}-skillconvo"; + _conversationRefs.GetOrAdd(key, JsonConvert.SerializeObject(skillConversationReference)); + return Task.FromResult(key); + } + + public override Task GetSkillConversationReferenceAsync(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/tests/Skills/Bot2/SkillsConfiguration.cs b/tests/Skills/Bot2/SkillsConfiguration.cs new file mode 100644 index 0000000000..258af9ebce --- /dev/null +++ b/tests/Skills/Bot2/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/tests/Skills/Bot2/Startup.cs b/tests/Skills/Bot2/Startup.cs new file mode 100644 index 0000000000..055c16b389 --- /dev/null +++ b/tests/Skills/Bot2/Startup.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.BotFramework; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Bot.Builder.Integration.AspNet.Core.Skills; +using Microsoft.Bot.Builder.Skills; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.BotBuilderSamples; +using Microsoft.BotBuilderSamples.SimpleRootBot; +using Microsoft.BotBuilderSamples.SimpleRootBot.Authentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bot2 +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers().AddNewtonsoftJson(); + + // 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 => (BotFrameworkAdapter)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(); + + services.AddTransient(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseDefaultFiles() + .UseStaticFiles() + .UseRouting() + .UseAuthorization() + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/tests/Skills/Bot2/appsettings.json b/tests/Skills/Bot2/appsettings.json new file mode 100644 index 0000000000..fc6072fb44 --- /dev/null +++ b/tests/Skills/Bot2/appsettings.json @@ -0,0 +1,12 @@ +{ + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + "SkillHostEndpoint": "http://localhost:39782/api/skills/", + "BotFrameworkSkills": [ + { + "Id": "NextBot", + "AppId": "", + "SkillEndpoint": "http://localhost:39783/api/messages" + } + ] +} \ No newline at end of file diff --git a/tests/Skills/Bot3/AdapterWithErrorHandler.cs b/tests/Skills/Bot3/AdapterWithErrorHandler.cs new file mode 100644 index 0000000000..ca3238dcb1 --- /dev/null +++ b/tests/Skills/Bot3/AdapterWithErrorHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.BotBuilderSamples +{ + public class AdapterWithErrorHandler : BotFrameworkHttpAdapter + { + public AdapterWithErrorHandler(IConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + OnTurnError = (turnContext, exception) => + { + // Log any leaked exception from the application. + logger.LogError($"Exception caught : {exception.Message}"); + return Task.CompletedTask; + }; + } + } +} diff --git a/tests/Skills/Bot3/Bot3.cs b/tests/Skills/Bot3/Bot3.cs new file mode 100644 index 0000000000..e70caafd15 --- /dev/null +++ b/tests/Skills/Bot3/Bot3.cs @@ -0,0 +1,21 @@ +// 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.Schema; + +namespace Bot3 +{ + public class Bot3 : ActivityHandler + { + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync(MessageFactory.Text("hello from bot3"), cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/tests/Skills/Bot3/Bot3.csproj b/tests/Skills/Bot3/Bot3.csproj new file mode 100644 index 0000000000..a0f432060e --- /dev/null +++ b/tests/Skills/Bot3/Bot3.csproj @@ -0,0 +1,15 @@ + + + + netcoreapp3.1 + + + + + + + + + + + diff --git a/tests/Skills/Bot3/Controllers/BotController.cs b/tests/Skills/Bot3/Controllers/BotController.cs new file mode 100644 index 0000000000..a1b978577a --- /dev/null +++ b/tests/Skills/Bot3/Controllers/BotController.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Microsoft.BotBuilderSamples +{ + // 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 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/tests/Skills/Bot3/Program.cs b/tests/Skills/Bot3/Program.cs new file mode 100644 index 0000000000..6596b69843 --- /dev/null +++ b/tests/Skills/Bot3/Program.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bot3 +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/tests/Skills/Bot3/Startup.cs b/tests/Skills/Bot3/Startup.cs new file mode 100644 index 0000000000..11e498b909 --- /dev/null +++ b/tests/Skills/Bot3/Startup.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.BotBuilderSamples; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bot3 +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + services.AddSingleton(); + services.AddTransient(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseDefaultFiles() + .UseStaticFiles() + .UseRouting() + .UseAuthorization() + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/tests/Skills/Bot3/appsettings.json b/tests/Skills/Bot3/appsettings.json new file mode 100644 index 0000000000..97dff81a1b --- /dev/null +++ b/tests/Skills/Bot3/appsettings.json @@ -0,0 +1,4 @@ +{ + "MicrosoftAppId": "", + "MicrosoftAppPassword": "" +} From 592c2b60b235e6b57a4a64214d40d287c5a54d70 Mon Sep 17 00:00:00 2001 From: Gabo Gilabert Date: Wed, 8 Jul 2020 14:31:58 -0400 Subject: [PATCH 2/3] Added code to get app id of the parent in Bot2. Added checks for status code. --- tests/Skills/Bot1/Bot1.cs | 2 +- tests/Skills/Bot2/Bot2.cs | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/Skills/Bot1/Bot1.cs b/tests/Skills/Bot1/Bot1.cs index 320c68e4b5..62dbbbe644 100644 --- a/tests/Skills/Bot1/Bot1.cs +++ b/tests/Skills/Bot1/Bot1.cs @@ -144,7 +144,7 @@ private async Task SendToSkill(ITurnContext turnContext, BotFrameworkSkill targe var response = await _skillClient.PostActivityAsync(_botId, targetSkill, _skillsConfig.SkillHostEndpoint, turnContext.Activity, cancellationToken); // Check response status - if (!(response.Status >= 200 && response.Status <= 299)) + if (!response.IsSuccessStatusCode()) { throw new HttpRequestException($"Error invoking the skill id: \"{targetSkill.Id}\" at \"{targetSkill.SkillEndpoint}\" (status is {response.Status}). \r\n {response.Body}"); } diff --git a/tests/Skills/Bot2/Bot2.cs b/tests/Skills/Bot2/Bot2.cs index 04cae6dec5..17f9912a86 100644 --- a/tests/Skills/Bot2/Bot2.cs +++ b/tests/Skills/Bot2/Bot2.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Security.Claims; +using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder; @@ -141,10 +143,20 @@ private async Task SendToSkill(ITurnContext turnContext, BotFrameworkSkill targe await _conversationState.SaveChangesAsync(turnContext, force: true, cancellationToken: cancellationToken); // route the activity to the skill - var response = await _skillClient.PostActivityAsync(_botId, targetSkill, _skillsConfig.SkillHostEndpoint, turnContext.Activity, cancellationToken); + InvokeResponse response; + if (turnContext.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims)) + { + // Check that the appId claim in the skill request is in the list of skills configured for this bot. + var appId = JwtTokenValidation.GetAppIdFromClaims(claimIdentity.Claims); + response = await _skillClient.PostActivityAsync(appId, _botId, targetSkill, _skillsConfig.SkillHostEndpoint, turnContext.Activity, cancellationToken); + } + else + { + response = await _skillClient.PostActivityAsync(_botId, targetSkill, _skillsConfig.SkillHostEndpoint, turnContext.Activity, cancellationToken); + } // Check response status - if (!(response.Status >= 200 && response.Status <= 299)) + if (!response.IsSuccessStatusCode()) { throw new HttpRequestException($"Error invoking the skill id: \"{targetSkill.Id}\" at \"{targetSkill.SkillEndpoint}\" (status is {response.Status}). \r\n {response.Body}"); } From 83ed43bc9ffd9a82fd50c0ce67db8adb810ece0d Mon Sep 17 00:00:00 2001 From: John Taylor Date: Mon, 13 Jul 2020 12:30:28 -0700 Subject: [PATCH 3/3] return ResourceResponse from send --- .../Microsoft.Bot.Builder/Skills/SkillHandler.cs | 7 +++++-- .../Skills/SkillHandlerTests.cs | 14 +++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/libraries/Microsoft.Bot.Builder/Skills/SkillHandler.cs b/libraries/Microsoft.Bot.Builder/Skills/SkillHandler.cs index 55c6eab39a..eb157ef2c6 100644 --- a/libraries/Microsoft.Bot.Builder/Skills/SkillHandler.cs +++ b/libraries/Microsoft.Bot.Builder/Skills/SkillHandler.cs @@ -177,6 +177,8 @@ private async Task ProcessActivityAsync(ClaimsIdentity claimsI throw new KeyNotFoundException(); } + ResourceResponse resourceResponse = null; + var callback = new BotCallbackHandler(async (turnContext, ct) => { turnContext.TurnState.Add(SkillConversationReferenceKey, skillConversationReference); @@ -195,13 +197,14 @@ private async Task ProcessActivityAsync(ClaimsIdentity claimsI await _bot.OnTurnAsync(turnContext, ct).ConfigureAwait(false); break; default: - await turnContext.SendActivityAsync(activity, cancellationToken).ConfigureAwait(false); + resourceResponse = await turnContext.SendActivityAsync(activity, cancellationToken).ConfigureAwait(false); break; } }); await _adapter.ContinueConversationAsync(claimsIdentity, skillConversationReference.ConversationReference, skillConversationReference.OAuthScope, callback, cancellationToken).ConfigureAwait(false); - return new ResourceResponse(Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + + return resourceResponse ?? new ResourceResponse(Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); } } } diff --git a/tests/Microsoft.Bot.Builder.Tests/Skills/SkillHandlerTests.cs b/tests/Microsoft.Bot.Builder.Tests/Skills/SkillHandlerTests.cs index f393a4dd64..2be316a468 100644 --- a/tests/Microsoft.Bot.Builder.Tests/Skills/SkillHandlerTests.cs +++ b/tests/Microsoft.Bot.Builder.Tests/Skills/SkillHandlerTests.cs @@ -96,23 +96,27 @@ public async Task LegacyConversationIdFactoryTest() [Fact] public async Task OnSendToConversationAsyncTest() { - BotCallbackHandler botCallback = null; _mockAdapter.Setup(x => x.ContinueConversationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((identity, reference, audience, callback, cancellationToken) => { - botCallback = callback; + callback(new TurnContext(_mockAdapter.Object, _conversationReference.GetContinuationActivity()), CancellationToken.None).Wait(); }); + _mockAdapter.Setup(x => x.SendActivitiesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((turnContext, activities, cancellationToken) => + { + }) + .Returns(Task.FromResult(new[] { new ResourceResponse { Id = "resourceId" } })); + var sut = CreateSkillHandlerForTesting(); var activity = (Activity)Activity.CreateMessageActivity(); activity.ApplyConversationReference(_conversationReference); Assert.Null(activity.CallerId); - await sut.TestOnSendToConversationAsync(_claimsIdentity, _conversationId, activity, CancellationToken.None); - Assert.NotNull(botCallback); - await botCallback.Invoke(new TurnContext(_mockAdapter.Object, _conversationReference.GetContinuationActivity()), CancellationToken.None); + var resourceResponse = await sut.TestOnSendToConversationAsync(_claimsIdentity, _conversationId, activity, CancellationToken.None); Assert.Null(activity.CallerId); + Assert.Equal("resourceId", resourceResponse.Id); } [Fact]