From c434ce3a120e82caaf03be50bf95000efdd96d8f Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 10 Mar 2020 17:51:13 -0700 Subject: [PATCH] SkillDialog dev testing samples --- .../dialog-echo-skill-bot/README.md | 38 +++ .../dialog-echo-skill-bot/app.py | 66 ++++ .../authentication/__init__.py | 3 + .../allow_callers_claims_validation.py | 39 +++ .../dialog-echo-skill-bot/bots/__init__.py | 6 + .../dialog-echo-skill-bot/bots/echo_bot.py | 35 +++ .../dialog-echo-skill-bot/config.py | 18 ++ .../template-with-new-rg.json | 264 ++++++++++++++++ .../template-with-preexisting-rg.json | 242 +++++++++++++++ .../dialog-echo-skill-bot/requirements.txt | 2 + .../skill_adapter_with_error_handler.py | 92 ++++++ .../adapter_with_error_handler.py | 140 +++++++++ .../dialog-to-dialog/dialog-root-bot/app.py | 87 ++++++ .../authentication/__init__.py | 3 + .../allowed_skills_claims_validator.py | 30 ++ .../dialog-root-bot/bots/__init__.py | 4 + .../dialog-root-bot/bots/root_bot.py | 60 ++++ .../dialog-root-bot/cards/welcomeCard.json | 46 +++ .../dialog-root-bot/config.py | 35 +++ .../dialog-root-bot/dialogs/__init__.py | 6 + .../dialogs/booking_details.py | 18 ++ .../dialog-root-bot/dialogs/location.py | 11 + .../dialog-root-bot/dialogs/main_dialog.py | 291 ++++++++++++++++++ .../dialog-root-bot/dialogs/tangent_dialog.py | 42 +++ .../dialog-root-bot/helpers/__init__.py | 6 + .../helpers/activity_helper.py | 37 +++ .../dialog-root-bot/helpers/dialog_helper.py | 19 ++ .../dialog-root-bot/helpers/luis_helper.py | 102 ++++++ .../dialog-root-bot/middleware/__init__.py | 4 + .../middleware/dummy_middleware.py | 32 ++ .../skill_conversation_id_factory.py | 70 +++++ .../dialog-to-dialog/dialog-skill-bot/app.py | 69 +++++ .../authentication/__init__.py | 3 + .../allow_callers_claims_validation.py | 39 +++ .../dialog-skill-bot/bots/__init__.py | 7 + .../bots/activity_router_dialog.py | 180 +++++++++++ .../dialog-skill-bot/bots/skill_bot.py | 25 ++ .../dialog-skill-bot/config.py | 25 ++ .../template-with-new-rg.json | 264 ++++++++++++++++ .../template-with-preexisting-rg.json | 242 +++++++++++++++ .../dialog-skill-bot/dialogs/__init__.py | 18 ++ .../dialogs/booking_details.py | 24 ++ .../dialogs/booking_dialog.py | 136 ++++++++ .../dialogs/cancel_and_help_dialog.py | 47 +++ .../dialogs/date_resolver_dialog.py | 82 +++++ .../dialogs/dialog_skill_bot_recognizer.py | 34 ++ .../dialog-skill-bot/dialogs/location.py | 16 + .../dialogs/oauth_test_dialog.py | 89 ++++++ .../dialog-skill-bot/requirements.txt | 3 + .../skill_adapter_with_error_handler.py | 92 ++++++ 50 files changed, 3243 insertions(+) create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/README.md create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/app.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/allow_callers_claims_validation.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/echo_bot.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/config.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-new-rg.json create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/requirements.txt create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/skill_adapter_with_error_handler.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/adapter_with_error_handler.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/app.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/allowed_skills_claims_validator.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/root_bot.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/cards/welcomeCard.json create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/config.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/booking_details.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/location.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/main_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/tangent_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/activity_helper.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/dialog_helper.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/luis_helper.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/dummy_middleware.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/skill_conversation_id_factory.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/app.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/allow_callers_claims_validation.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/activity_router_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/skill_bot.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/config.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-new-rg.json create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_details.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/cancel_and_help_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/date_resolver_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/dialog_skill_bot_recognizer.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/location.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/oauth_test_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/requirements.txt create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/skill_adapter_with_error_handler.py diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/README.md b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/README.md new file mode 100644 index 000000000..299259d5b --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/README.md @@ -0,0 +1,38 @@ +# EchoBot + +Bot Framework v4 echo bot sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple bot that accepts input from the user and echoes it back. + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-samples.git +``` +- Bring up a terminal, navigate to `botbuilder-samples\samples\python\02.echo-bot` folder +- Activate your desired virtual environment +- In the terminal, type `pip install -r requirements.txt` +- Run your bot with `python app.py` + +## Testing the bot using Bot Framework Emulator +[Microsoft 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 from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + +### Connect to bot using Bot Framework Emulator +- Launch Bot Framework Emulator +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## Deploy the bot 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. + +# Further reading + +- [Bot Framework Documentation](https://docs.botframework.com) +- [Bot Basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) +- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Azure Bot Service Documentation](https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0) +- [Azure CLI](https://docs.microsoft.com/cli/azure/?view=azure-cli-latest) +- [Azure Portal](https://portal.azure.com) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/app.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/app.py new file mode 100644 index 000000000..17a9410a1 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/app.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp import web +from aiohttp.web import Request, Response, json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, +) +from botbuilder.core.integration import aiohttp_error_middleware +from botbuilder.schema import Activity +from botframework.connector.auth import AuthenticationConfiguration + +from authentication import AllowedCallersClaimsValidator +from bots import EchoBot +from config import DefaultConfig +from skill_adapter_with_error_handler import SkillAdapterWithErrorHandler + +CONFIG = DefaultConfig() + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +VALIDATOR = AllowedCallersClaimsValidator(CONFIG).claims_validator +SETTINGS = BotFrameworkAdapterSettings( + CONFIG.APP_ID, + CONFIG.APP_PASSWORD, + auth_configuration=AuthenticationConfiguration(claims_validator=VALIDATOR), +) +ADAPTER = SkillAdapterWithErrorHandler(SETTINGS, CONVERSATION_STATE) + +# Create the Bot +BOT = EchoBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) + return Response(status=201) + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/__init__.py new file mode 100644 index 000000000..ebbe2ac15 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/__init__.py @@ -0,0 +1,3 @@ +from .allow_callers_claims_validation import AllowedCallersClaimsValidator + +__all__ = ["AllowedCallersClaimsValidator"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/allow_callers_claims_validation.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/allow_callers_claims_validation.py new file mode 100644 index 000000000..3403a32bc --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/authentication/allow_callers_claims_validation.py @@ -0,0 +1,39 @@ +from typing import Awaitable, Callable, Dict, List + +from botframework.connector.auth import JwtTokenValidation, SkillValidation + +from config import DefaultConfig + + +class AllowedCallersClaimsValidator: + + config_key = "ALLOWED_CALLERS" + + def __init__(self, config: DefaultConfig): + if not config: + raise TypeError( + "AllowedCallersClaimsValidator: config object cannot be None." + ) + + # ALLOWED_CALLERS is the setting in config.py 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 + self._allowed_callers = config.ALLOWED_CALLERS + + @property + def claims_validator(self) -> Callable[[List[Dict]], Awaitable]: + async def allow_callers_claims_validator(claims: Dict[str, object]): + # if _allowedCallers is None we allow all calls + if self._allowed_callers and SkillValidation.is_skill_claim(claims): + # Check that the appId claim in the skill request is in the list of skills configured for this bot. + app_id = JwtTokenValidation.get_app_id_from_claims(claims) + if app_id not in self._allowed_callers: + raise PermissionError( + f'Received a request from a bot with an app ID of "{app_id}".' + f" To enable requests from this caller, add the app ID to your configuration file." + ) + + return + + return allow_callers_claims_validator diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/__init__.py new file mode 100644 index 000000000..f95fbbbad --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/echo_bot.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/echo_bot.py new file mode 100644 index 000000000..89c37604b --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/bots/echo_bot.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, TurnContext +from botbuilder.schema import ( + ChannelAccount, + Activity, + ActivityTypes, + EndOfConversationCodes, +) + + +class EchoBot(ActivityHandler): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + if "end" in turn_context.activity.text or "stop" in turn_context.activity.text: + await turn_context.send_activity("ending conversation from the skill...") + end_of_conversation = Activity( + type=ActivityTypes.end_of_conversation, + code=EndOfConversationCodes.completed_successfully, + ) + await turn_context.send_activity(end_of_conversation) + else: + await turn_context.send_activity( + f"Echo: (Python) : {turn_context.activity.text}" + ) + await turn_context.send_activity( + 'Say "end" or "stop" and I\'ll end the conversation and back to the parent.' + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/config.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/config.py new file mode 100644 index 000000000..500488e6b --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/config.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 39793 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + + # If ALLOWED_CALLERS is empty, any bot can call this Skill. Add MicrosoftAppIds to restrict + # callers to only those specified. + # Example: os.environ.get("AllowedCallers", ["54d3bb6a-3b6d-4ccd-bbfd-cad5c72fb53a"]) + ALLOWED_CALLERS = os.environ.get("AllowedCallers", []) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-new-rg.json b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..b68e51ec6 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,264 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": { + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('appServicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2015-08-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "siteConfig": { + "appSettings": [ + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "gunicorn --bind 0.0.0.0 --worker-class aiohttp.worker.GunicornWebWorker --timeout 600 app:APP", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} \ No newline at end of file diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..66b044b52 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "gunicorn --bind 0.0.0.0 --worker-class aiohttp.worker.GunicornWebWorker --timeout 600 app:APP", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/requirements.txt b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/requirements.txt new file mode 100644 index 000000000..52eb5fe1e --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/requirements.txt @@ -0,0 +1,2 @@ +aiohttp +botbuilder-core>=4.7.0 diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/skill_adapter_with_error_handler.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/skill_adapter_with_error_handler.py new file mode 100644 index 000000000..d7cf87c73 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-echo-skill-bot/skill_adapter_with_error_handler.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys +import traceback +from datetime import datetime + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MessageFactory, + TurnContext, +) +from botbuilder.schema import ActivityTypes, Activity, InputHints + + +class SkillAdapterWithErrorHandler(BotFrameworkAdapter): + def __init__( + self, + settings: BotFrameworkAdapterSettings, + conversation_state: ConversationState = None, + ): + super().__init__(settings) + self._conversation_state = conversation_state + + # Catch-all for errors. + async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + error_message_text = "The skill encountered an error or bug." + error_message = MessageFactory.text( + error_message_text, error_message_text, InputHints.ignoring_input + ) + await context.send_activity(error_message) + + error_message_text = ( + "To continue to run this bot, please fix the bot source code." + ) + error_message = MessageFactory.text( + error_message_text, error_message_text, InputHints.ignoring_input + ) + await context.send_activity(error_message) + + # Send a trace activity if we're talking to the Bot Framework Emulator + if context.activity.channel_id == "emulator": + # Create a trace activity that contains the error object + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.utcnow(), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error", + ) + # Send a trace activity, which will be displayed in Bot Framework Emulator + await context.send_activity(trace_activity) + + # Clear out state + nonlocal self + if self._conversation_state: + try: + await self._conversation_state.delete(context) + except Exception as exception: + print( + f"\n Exception caught on attempting to Delete ConversationState : {exception}", + file=sys.stderr, + ) + traceback.print_exc() + + # Send and EndOfConversation activity to the skill caller with the error to end the conversation + # and let the caller decide what to do. + end_of_conversation = Activity(type=ActivityTypes.end_of_conversation) + end_of_conversation.code = "SkillError" + end_of_conversation.text = str(error) + await context.send_activity(end_of_conversation) + + # 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 context.send_trace_activity( + "OnTurnError Trace", + str(error), + "https://www.botframework.com/schemas/error", + "TurnError", + ) + + self.on_turn_error = on_error diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/adapter_with_error_handler.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/adapter_with_error_handler.py new file mode 100644 index 000000000..37ace8545 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/adapter_with_error_handler.py @@ -0,0 +1,140 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys +import traceback +from datetime import datetime + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MessageFactory, + TurnContext, +) +from botbuilder.integration.aiohttp.skills import SkillHttpClient +from botbuilder.schema import ActivityTypes, Activity, InputHints + +from config import DefaultConfig, SkillConfiguration +from dialogs import MainDialog + + +class AdapterWithErrorHandler(BotFrameworkAdapter): + def __init__( + self, + settings: BotFrameworkAdapterSettings, + config: DefaultConfig, + conversation_state: ConversationState = None, + skill_client: SkillHttpClient = None, + skill_config: SkillConfiguration = None, + ): + super().__init__(settings) + self._config = config + self._conversation_state = conversation_state + self._skill_client = skill_client + self._skill_config = skill_config + + self.on_turn_error = self._handle_turn_error + + async def _handle_turn_error(self, turn_context: TurnContext, error: Exception): + # This check writes out errors to console log + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + await self._send_error_message(turn_context, error) + await self._end_skill_conversation(turn_context, error) + await self._clear_conversation_state(turn_context) + + async def _send_error_message(self, turn_context: TurnContext, error: Exception): + try: + # Send a message to the user + error_message_text = "The skill encountered an error or bug." + error_message = MessageFactory.text( + error_message_text, error_message_text, InputHints.ignoring_input + ) + await turn_context.send_activity(error_message) + + error_message_text = ( + "To continue to run this bot, please fix the bot source code." + ) + error_message = MessageFactory.text( + error_message_text, error_message_text, InputHints.ignoring_input + ) + await turn_context.send_activity(error_message) + + # Send a trace activity if we're talking to the Bot Framework Emulator + if turn_context.activity.channel_id == "emulator": + # Create a trace activity that contains the error object + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.utcnow(), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error", + ) + # Send a trace activity, which will be displayed in Bot Framework Emulator + await turn_context.send_activity(trace_activity) + except Exception as exception: + print( + f"\n Exception caught on _send_error_message : {exception}", + file=sys.stderr, + ) + traceback.print_exc() + + async def _end_skill_conversation( + self, turn_context: TurnContext, error: Exception + ): + if ( + not self._conversation_state + or not self._skill_client + or not self._skill_config + ): + return + + try: + # Inform the active skill that the conversation is ended so that it has + # a chance to clean up. + # Note: ActiveSkillPropertyName is set by the RooBot while messages are being + # forwarded to a Skill. + active_skill = await self._conversation_state.create_property( + MainDialog.ACTIVE_SKILL_PROPERTY_NAME + ) + + if active_skill: + bot_id = self._config.APP_ID + end_of_conversation = Activity(type=ActivityTypes.end_of_conversation) + end_of_conversation.code = "RootSkillError" + TurnContext.apply_conversation_reference( + end_of_conversation, + TurnContext.get_conversation_reference(turn_context.activity), + True, + ) + + await self._conversation_state.save_changes(turn_context, True) + await self._skill_client.post_activity_to_skill( + bot_id, + active_skill, + self._skill_config.SKILL_HOST_ENDPOINT, + end_of_conversation, + ) + except Exception as exception: + print( + f"\n Exception caught on _end_skill_conversation : {exception}", + file=sys.stderr, + ) + traceback.print_exc() + + async def _clear_conversation_state(self, turn_context: TurnContext): + if self._conversation_state: + 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 self._conversation_state.delete(turn_context) + except Exception as exception: + print( + f"\n Exception caught on _clear_conversation_state : {exception}", + file=sys.stderr, + ) + traceback.print_exc() diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/app.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/app.py new file mode 100644 index 000000000..037e5da25 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/app.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, +) +from botbuilder.core.integration import ( + aiohttp_channel_service_routes, + aiohttp_error_middleware, +) +from botbuilder.core.skills import SkillHandler +from botbuilder.schema import Activity +from botbuilder.integration.aiohttp.skills import SkillHttpClient +from botframework.connector.auth import ( + AuthenticationConfiguration, + SimpleCredentialProvider, +) + +from authentication import AllowedSkillsClaimsValidator +from bots import RootBot +from dialogs import MainDialog +from config import DefaultConfig, SkillConfiguration +from adapter_with_error_handler import AdapterWithErrorHandler +from skill_conversation_id_factory import SkillConversationIdFactory + +CONFIG = DefaultConfig() +SKILL_CONFIG = SkillConfiguration() + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) +ID_FACTORY = SkillConversationIdFactory(MEMORY) + +CREDENTIAL_PROVIDER = SimpleCredentialProvider(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +CLIENT = SkillHttpClient(CREDENTIAL_PROVIDER, ID_FACTORY) + + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +ADAPTER = AdapterWithErrorHandler(SETTINGS, CONVERSATION_STATE) + +DIALOG = MainDialog(CONVERSATION_STATE, ID_FACTORY, CLIENT, SKILL_CONFIG, CONFIG) + +# Create the Bot +BOT = RootBot(CONVERSATION_STATE, DIALOG) # , SKILL_CONFIG, ID_FACTORY, CLIENT, CONFIG) + +AUTH_CONFIG = AuthenticationConfiguration( + claims_validator=AllowedSkillsClaimsValidator(CONFIG).validate_claims +) + +SKILL_HANDLER = SkillHandler(ADAPTER, BOT, ID_FACTORY, CREDENTIAL_PROVIDER, AUTH_CONFIG) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) +APP.router.add_routes(aiohttp_channel_service_routes(SKILL_HANDLER, "/api/skills")) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/__init__.py new file mode 100644 index 000000000..f60882d12 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/__init__.py @@ -0,0 +1,3 @@ +from .allowed_skills_claims_validator import AllowedSkillsClaimsValidator + +__all__ = ["AllowedSkillsClaimsValidator"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/allowed_skills_claims_validator.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/allowed_skills_claims_validator.py new file mode 100644 index 000000000..55b0af43d --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/authentication/allowed_skills_claims_validator.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botframework.connector.auth import JwtTokenValidation, SkillValidation + +from config import DefaultConfig + + +class AllowedSkillsClaimsValidator: + """ + Sample claims validator that loads an allowed list from config if present and checks + that requests are coming from allowed skills. + """ + + def __init__(self, config: DefaultConfig): + self.allowed_callers = config.SKILLS + + # Check AppIds for the configured callers (we will only allow responses from skills we have configured). + # SkillConfiguration.SKILLS is the list of Skill app Ids that are allowed to access the parent. + # To add a new skill simply go to the config.py file and add + # the skill's id, Microsoft AppId and skill_endpoint to the array under SKILLS. + async def validate_claims(self, claims: dict): + if SkillValidation.is_skill_claim(claims) and self.allowed_callers: + # Check that the appId claim in the request is in the list of skills configured for this bot. + app_id = JwtTokenValidation.get_app_id_from_claims(claims) + if app_id not in self.allowed_callers: + raise ValueError( + f'Received a request from an application with an appID of "{ app_id }". To enable requests ' + f"from this skill, add the id to your configuration file." + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/__init__.py new file mode 100644 index 000000000..be7e157a7 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/__init__.py @@ -0,0 +1,4 @@ +from .root_bot import RootBot + + +__all__ = ["RootBot"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/root_bot.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/root_bot.py new file mode 100644 index 000000000..402fe128d --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/bots/root_bot.py @@ -0,0 +1,60 @@ +import json +import os.path + +from typing import List + +from botbuilder.core import ( + ActivityHandler, + ConversationState, + MessageFactory, + TurnContext, +) +from botbuilder.dialogs import Dialog, DialogExtensions +from botbuilder.schema import ActivityTypes, Attachment, ChannelAccount + + +class RootBot(ActivityHandler): + def __init__( + self, conversation_state: ConversationState, main_dialog: Dialog, + ): + self._conversation_state = conversation_state + self._main_dialog = main_dialog + + async def on_turn(self, turn_context: TurnContext): + if turn_context.activity.type != ActivityTypes.conversation_update: + # Handle end of conversation back from the skill + # forget skill invocation + await DialogExtensions.run_dialog( + self._main_dialog, + turn_context, + self._conversation_state.create_property("DialogState"), + ) + else: + await super().on_turn(turn_context) + + # Save any state changes that might have occurred during the turn. + await self._conversation_state.save_changes(turn_context) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + welcome_card = self._create_adaptive_card_attachment() + activity = MessageFactory.attachment(welcome_card) + await turn_context.send_activity(activity) + await DialogExtensions.run_dialog( + self._main_dialog, + turn_context, + self._conversation_state.create_property("DialogState"), + ) + + def _create_adaptive_card_attachment(self) -> Attachment: + relative_path = os.path.abspath(os.path.dirname(__file__)) + path = os.path.join(relative_path, "../cards/welcomeCard.json") + with open(path) as in_file: + card = json.load(in_file) + + return Attachment( + content_type="application/vnd.microsoft.card.adaptive", content=card + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/cards/welcomeCard.json b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/cards/welcomeCard.json new file mode 100644 index 000000000..cc10cda9f --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/cards/welcomeCard.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Image", + "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", + "size": "stretch" + }, + { + "type": "TextBlock", + "spacing": "medium", + "size": "default", + "weight": "bolder", + "text": "Welcome to Bot Framework!", + "wrap": true, + "maxLines": 0 + }, + { + "type": "TextBlock", + "size": "default", + "isSubtle": "true", + "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", + "wrap": true, + "maxLines": 0 + } + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Get an overview", + "url": "https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0" + }, + { + "type": "Action.OpenUrl", + "title": "Ask a question", + "url": "https://stackoverflow.com/questions/tagged/botframework" + }, + { + "type": "Action.OpenUrl", + "title": "Learn how to deploy", + "url": "https://docs.microsoft.com/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" + } + ] +} \ No newline at end of file diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/config.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/config.py new file mode 100644 index 000000000..000d3e481 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/config.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from typing import Dict +from botbuilder.core.skills import BotFrameworkSkill + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3978 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + SKILL_HOST_ENDPOINT = "http://localhost:3978/api/skills" + SKILLS = [ + { + "id": "EchoSkillBot", + "app_id": "", + "skill_endpoint": "http://localhost:39793/api/messages", + }, + { + "id": "DialogSkillBot", + "app_id": "", + "skill_endpoint": "http://localhost:39783/api/messages", + }, + ] + + +class SkillConfiguration: + SKILL_HOST_ENDPOINT = DefaultConfig.SKILL_HOST_ENDPOINT + SKILLS: Dict[str, BotFrameworkSkill] = { + skill["id"]: BotFrameworkSkill(**skill) for skill in DefaultConfig.SKILLS + } diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/__init__.py new file mode 100644 index 000000000..27e913909 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/__init__.py @@ -0,0 +1,6 @@ +from .booking_details import BookingDetails +from .location import Location +from .main_dialog import MainDialog +from .tangent_dialog import TangentDialog + +__all__ = ["BookingDetails", "Location", "MainDialog", "TangentDialog"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/booking_details.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/booking_details.py new file mode 100644 index 000000000..db7c4c60c --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/booking_details.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class BookingDetails: + def __init__( + self, + destination: str = None, + origin: str = None, + travel_date: str = None, + unsupported_airports=None, + ): + if unsupported_airports is None: + unsupported_airports = [] + self.destination = destination + self.origin = origin + self.travel_date = travel_date + self.unsupported_airports = unsupported_airports diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/location.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/location.py new file mode 100644 index 000000000..045aaedfa --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/location.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class Location: + def __init__( + self, latitude: str = None, longitude: str = None, postal_code: str = None, + ): + self.latitude = latitude + self.longitude = longitude + self.postal_code = postal_code diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/main_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/main_dialog.py new file mode 100644 index 000000000..0af4de2ab --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/main_dialog.py @@ -0,0 +1,291 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import json +from typing import List + +from jsonpickle import encode +from botbuilder.dialogs import ( + ComponentDialog, + DialogContext, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from botbuilder.dialogs.choices import Choice, ListStyle +from botbuilder.dialogs.prompts import PromptOptions, ChoicePrompt +from botbuilder.dialogs.skills import ( + SkillDialogOptions, + SkillDialog, + BeginSkillDialogOptions, +) +from botbuilder.core import ConversationState, MessageFactory +from botbuilder.core.skills import BotFrameworkSkill, ConversationIdFactoryBase +from botbuilder.schema import Activity, ActivityTypes, InputHints, DeliveryModes +from botbuilder.integration.aiohttp.skills import SkillHttpClient + +from config import SkillConfiguration, DefaultConfig +from .booking_details import BookingDetails +from .tangent_dialog import TangentDialog + + +class MainDialog(ComponentDialog): + + ACTIVE_SKILL_PROPERTY_NAME = f"MainDialog.ActiveSkillProperty" + + def __init__( + self, + conversation_state: ConversationState, + conversation_id_factory: ConversationIdFactoryBase, + skill_client: SkillHttpClient, + skills_config: SkillConfiguration, + configuration: DefaultConfig, + ): + super(MainDialog, self).__init__(MainDialog.__name__) + + self._selected_skill_key = ( + f"{MainDialog.__module__}.{MainDialog.__name__}.SelectedSkillKey" + ) + + bot_id = configuration.APP_ID + if not bot_id: + raise TypeError("App Id is not in configuration") + + self._skills_config = skills_config + if not self._skills_config: + raise TypeError("Skills configuration cannot be None") + + if not skill_client: + raise TypeError("skill_client cannot be None") + + if not conversation_state: + raise TypeError("conversation_state cannot be None") + + # ChoicePrompt to render available skills and skill actions + self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) + + # Register the tangent + self.add_dialog(TangentDialog()) + + # Create SkillDialog instances for the configured skills + for _, skill_info in skills_config.SKILLS.items(): + # SkillDialog used to wrap interaction with the selected skill + skill_dialog_options = SkillDialogOptions( + bot_id=bot_id, + conversation_id_factory=conversation_id_factory, + skill_client=skill_client, + skill=skill_info, + skill_host_endpoint=skills_config.SKILL_HOST_ENDPOINT, + conversation_state=conversation_state, + ) + + self.add_dialog(SkillDialog(skill_dialog_options, skill_info.id)) + + # Main waterfall dialog for this bot + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, + [ + self.select_skill_step, + self.select_skill_action_step, + self.call_skill_action_step, + self.final_step, + ], + ) + ) + + self._active_skill_property = conversation_state.create_property( + MainDialog.ACTIVE_SKILL_PROPERTY_NAME + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + # This is an example on how to cancel a SkillDialog that is currently in progress from the parent bot + active_skill = await self._active_skill_property.get(inner_dc.context) + activity = inner_dc.context.activity + + if ( + active_skill + and activity.type == ActivityTypes.message + and "abort" in activity.text + ): + # Cancel all dialog when the user says abort. + await inner_dc.cancel_all_dialogs() + return await inner_dc.replace_dialog(self.initial_dialog_id) + + if ( + active_skill + and activity.type == ActivityTypes.message + and "tangent" in activity.text + ): + # Begin Tangent + return await inner_dc.replace_dialog(TangentDialog.__name__) + + return await super().on_continue_dialog(inner_dc) + + async def select_skill_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + # Create the PromptOptions from the skill configuration which contain the list of configured skills. + options = PromptOptions( + prompt=MessageFactory.text("What skill would you like to call?"), + retry_prompt=MessageFactory.text( + "That was not a valid choice, please select a valid skill." + ), + choices=[ + Choice(value=skill.id) + for _, skill in self._skills_config.SKILLS.items() + ], + ) + + # Prompt the user to select a skill. + return await step_context.prompt(ChoicePrompt.__name__, options) + + async def select_skill_action_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + # Get the skill info based on the selected skill. + selected_skill_id = step_context.result + selected_skill = self._skills_config.SKILLS.get(selected_skill_id.value) + + # Remember the skill selected by the user. + step_context.values[self._selected_skill_key] = selected_skill + + # Create the PromptOptions with the actions supported by the selected skill. + options = PromptOptions( + prompt=MessageFactory.text( + f"What action would you like to call in **{selected_skill.id}**?" + ), + retry_prompt=MessageFactory.text( + "That was not a valid choice, please select a valid action." + ), + choices=self._get_skill_actions(selected_skill), + style=ListStyle.suggested_action, + ) + + # Prompt the user to select a skill action. + return await step_context.prompt(ChoicePrompt.__name__, options) + + async def call_skill_action_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + # Starts SkillDialog based on the user's selections + selected_skill: BotFrameworkSkill = step_context.values[ + self._selected_skill_key + ] + + if selected_skill.id == "EchoSkillBot": + # Echo skill only handles message activities, send a dummy utterance to get it started. + skill_activity = Activity( + type=ActivityTypes.message, + attachments=[], + entities=[], + text="Start echo skill", + ) + elif selected_skill.id == "DialogSkillBot": + skill_activity = self._create_dialog_skill_bot_activity( + step_context.result.value + ) + else: + raise Exception(f"Unknown target skill id: {selected_skill.id}.") + + skill_dialog_args = BeginSkillDialogOptions(skill_activity) + + # We are manually creating the activity to send to the skill, ensure we add the ChannelData and Properties + # from the original activity so the skill gets them. + # Note: this is not necessary if we are just forwarding the current activity from context. + skill_dialog_args.activity.channel_data = ( + step_context.context.activity.channel_data + ) + skill_dialog_args.activity.additional_properties = ( + step_context.context.activity.additional_properties + ) + + skill_dialog_args.activity.delivery_mode = DeliveryModes.expect_replies + + await self._active_skill_property.set(step_context.context, selected_skill) + + return await step_context.begin_dialog(selected_skill.id, skill_dialog_args) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + active_skill = await self._active_skill_property.get(step_context.context) + + if step_context.result: + message = "Skill invocation complete." + message += f" Result: {encode(step_context.result)}" + await step_context.context.send_activity( + MessageFactory.text(message, input_hint=InputHints.ignoring_input) + ) + + # Clear the skill selected by the user. + step_context.values[self._selected_skill_key] = None + + # Clear active skill in state + await self._active_skill_property.delete(step_context.context) + + # Restart the main dialog with a different message the second time around + return await step_context.replace_dialog(self.initial_dialog_id) + + # Helper method to create Choice elements for the actions supported by the skill + def _get_skill_actions(self, skill: BotFrameworkSkill) -> List[Choice]: + # Note: the bot would probably render this by readying the skill manifest + # we are just using hardcoded skill actions here for simplicity. + + choices = [] + if skill.id == "EchoSkillBot": + choices.append(Choice("Messages")) + + elif skill.id == "DialogSkillBot": + choices.append(Choice("m:some message for tomorrow")) + choices.append(Choice("BookFlight")) + choices.append(Choice("OAuthTest")) + choices.append(Choice("mv:some message with value")) + choices.append(Choice("BookFlightWithValues")) + + return choices + + # Helper method to create the activity to be sent to the DialogSkillBot + def _create_dialog_skill_bot_activity(self, selected_option: str) -> Activity: + # Note: in a real bot, the dialogArgs will be created dynamically based on the conversation + # and what each action requires, this code hardcodes the values to make things simpler. + + selected_option = selected_option.lower() + + # Send a message activity to the skill. + if selected_option.startswith("m:"): + return Activity( + type=ActivityTypes.message, + attachments=[], + entities=[], + text=selected_option[:2].strip(), + ) + + # Send a message activity to the skill with some artificial parameters in value + elif selected_option.startswith("mv:"): + return Activity( + type=ActivityTypes.message, + attachments=[], + entities=[], + text=selected_option[:3].strip(), + value=json.dumps(BookingDetails(destination="New York").__dict__), + ) + + # Send an event activity to the skill with "OAuthTest" in the name. + elif selected_option == "oauthtest": + return Activity(type=ActivityTypes.event, name="OAuthTest") + + # Send an event activity to the skill with "BookFlight" in the name. + elif selected_option == "bookflight": + return Activity(type=ActivityTypes.event, name="BookFlight") + + # Send an event activity to the skill "BookFlight" in the name and some testing values. + elif selected_option == "bookflightwithvalues": + return Activity( + type=ActivityTypes.event, + name="BookFlight", + value=json.dumps( + BookingDetails(destination="New York", origin="Seattle").__dict__ + ), + ) + + raise Exception(f'Unable to create dialogArgs for "{selected_option}".') diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/tangent_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/tangent_dialog.py new file mode 100644 index 000000000..ed399da84 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/dialogs/tangent_dialog.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import ( + ComponentDialog, + DialogTurnResult, + WaterfallDialog, + WaterfallStepContext, +) +from botbuilder.dialogs.prompts import TextPrompt, PromptOptions +from botbuilder.schema import InputHints + + +class TangentDialog(ComponentDialog): + def __init__(self, dialog_id: str = "TangentDialog"): + super().__init__(dialog_id) + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog( + WaterfallDialog(WaterfallDialog.__name__, [self.step_1, self.step_2],) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def step_1(self, step_context: WaterfallStepContext) -> DialogTurnResult: + prompt_message = MessageFactory.text( + "Tangent step 1 of 2", InputHints.expecting_input + ) + + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + + async def step_2(self, step_context: WaterfallStepContext) -> DialogTurnResult: + prompt_message = MessageFactory.text( + "Tangent step 2 of 2", InputHints.expecting_input + ) + + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/__init__.py new file mode 100644 index 000000000..699f8693c --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import activity_helper, luis_helper, dialog_helper + +__all__ = ["activity_helper", "dialog_helper", "luis_helper"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/activity_helper.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/activity_helper.py new file mode 100644 index 000000000..29a24823e --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/activity_helper.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) + + +def create_activity_reply(activity: Activity, text: str = None, locale: str = None): + + return Activity( + type=ActivityTypes.message, + timestamp=datetime.utcnow(), + from_property=ChannelAccount( + id=getattr(activity.recipient, "id", None), + name=getattr(activity.recipient, "name", None), + ), + recipient=ChannelAccount( + id=activity.from_property.id, name=activity.from_property.name + ), + reply_to_id=activity.id, + service_url=activity.service_url, + channel_id=activity.channel_id, + conversation=ConversationAccount( + is_group=activity.conversation.is_group, + id=activity.conversation.id, + name=activity.conversation.name, + ), + text=text or "", + locale=locale or "", + attachments=[], + entities=[], + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/dialog_helper.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/luis_helper.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/luis_helper.py new file mode 100644 index 000000000..3e28bc47e --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/helpers/luis_helper.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from enum import Enum +from typing import Dict +from botbuilder.ai.luis import LuisRecognizer +from botbuilder.core import IntentScore, TopIntent, TurnContext + +from booking_details import BookingDetails + + +class Intent(Enum): + BOOK_FLIGHT = "BookFlight" + CANCEL = "Cancel" + GET_WEATHER = "GetWeather" + NONE_INTENT = "NoneIntent" + + +def top_intent(intents: Dict[Intent, dict]) -> TopIntent: + max_intent = Intent.NONE_INTENT + max_value = 0.0 + + for intent, value in intents: + intent_score = IntentScore(value) + if intent_score.score > max_value: + max_intent, max_value = intent, intent_score.score + + return TopIntent(max_intent, max_value) + + +class LuisHelper: + @staticmethod + async def execute_luis_query( + luis_recognizer: LuisRecognizer, turn_context: TurnContext + ) -> (Intent, object): + """ + Returns an object with preformatted LUIS results for the bot's dialogs to consume. + """ + result = None + intent = None + + try: + recognizer_result = await luis_recognizer.recognize(turn_context) + + intent = ( + sorted( + recognizer_result.intents, + key=recognizer_result.intents.get, + reverse=True, + )[:1][0] + if recognizer_result.intents + else None + ) + + if intent == Intent.BOOK_FLIGHT.value: + result = BookingDetails() + + # We need to get the result from the LUIS JSON which at every level returns an array. + to_entities = recognizer_result.entities.get("$instance", {}).get( + "To", [] + ) + if len(to_entities) > 0: + if recognizer_result.entities.get("To", [{"$instance": {}}])[0][ + "$instance" + ]: + result.destination = to_entities[0]["text"].capitalize() + else: + result.unsupported_airports.append( + to_entities[0]["text"].capitalize() + ) + + from_entities = recognizer_result.entities.get("$instance", {}).get( + "From", [] + ) + if len(from_entities) > 0: + if recognizer_result.entities.get("From", [{"$instance": {}}])[0][ + "$instance" + ]: + result.origin = from_entities[0]["text"].capitalize() + else: + result.unsupported_airports.append( + from_entities[0]["text"].capitalize() + ) + + # 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. + date_entities = recognizer_result.entities.get("datetime", []) + if date_entities: + timex = date_entities[0]["timex"] + + if timex: + datetime = timex[0].split("T")[0] + + result.travel_date = datetime + + else: + result.travel_date = None + + except Exception as exception: + print(exception) + + return intent, result diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/__init__.py new file mode 100644 index 000000000..c23b52ce2 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/__init__.py @@ -0,0 +1,4 @@ +from .dummy_middleware import DummyMiddleware + + +__all__ = ["DummyMiddleware"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/dummy_middleware.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/dummy_middleware.py new file mode 100644 index 000000000..4d38fe79f --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/middleware/dummy_middleware.py @@ -0,0 +1,32 @@ +from typing import Awaitable, Callable, List + +from botbuilder.core import Middleware, TurnContext +from botbuilder.schema import Activity, ResourceResponse + + +class DummyMiddleware(Middleware): + def __init__(self, label: str): + self._label = label + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + message = f"{self._label} {context.activity.type} {context.activity.text}" + print(message) + + # Register outgoing handler + context.on_send_activities(self._outgoing_handler) + + await logic() + + async def _outgoing_handler( + self, + context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + logic: Callable[[TurnContext], Awaitable[List[ResourceResponse]]], + ): + for activity in activities: + message = f"{self._label} {activity.type} {activity.text}" + print(message) + + return await logic() diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/skill_conversation_id_factory.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/skill_conversation_id_factory.py new file mode 100644 index 000000000..e34c7f391 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-root-bot/skill_conversation_id_factory.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Union + +from botbuilder.core import Storage, TurnContext +from botbuilder.core.skills import ( + ConversationIdFactoryBase, + SkillConversationIdFactoryOptions, + SkillConversationReference, +) +from botbuilder.schema import ConversationReference + + +class SkillConversationIdFactory(ConversationIdFactoryBase): + def __init__(self, storage: Storage): + if not storage: + raise TypeError("storage can't be None") + + self._storage = storage + + async def create_skill_conversation_id( + self, + options_or_conversation_reference: Union[ + SkillConversationIdFactoryOptions, ConversationReference + ], + ) -> str: + if not options_or_conversation_reference: + raise TypeError("Need options or conversation reference") + + if not isinstance( + options_or_conversation_reference, SkillConversationIdFactoryOptions + ): + raise TypeError( + "This SkillConversationIdFactory can only handle SkillConversationIdFactoryOptions" + ) + + options = options_or_conversation_reference + conversation_reference = TurnContext.get_conversation_reference( + options.activity + ) + storage_key = ( + f"{conversation_reference.conversation.id}" + f"-{options.bot_framework_skill.id}" + f"-{conversation_reference.channel_id}" + f"-skillconvo" + ) + + skill_conversation_reference = SkillConversationReference( + conversation_reference=conversation_reference, + oauth_scope=options.from_bot_oauth_scope, + ) + + skill_conversation_info = {storage_key: skill_conversation_reference} + + await self._storage.write(skill_conversation_info) + + return storage_key + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> Union[SkillConversationReference, ConversationReference]: + if not skill_conversation_id: + raise TypeError("skill_conversation_id can't be None") + + skill_conversation_info = await self._storage.read([skill_conversation_id]) + + return skill_conversation_info.get(skill_conversation_id) + + async def delete_conversation_reference(self, skill_conversation_id: str): + await self._storage.delete([skill_conversation_id]) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/app.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/app.py new file mode 100644 index 000000000..a877a4e11 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/app.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp import web +from aiohttp.web import Request, Response, json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, +) +from botbuilder.core.integration import aiohttp_error_middleware +from botbuilder.schema import Activity +from botframework.connector.auth import AuthenticationConfiguration + +from authentication import AllowedCallersClaimsValidator +from bots import SkillBot, ActivityRouterDialog +from config import DefaultConfig +from dialogs import DialogSkillBotRecognizer +from skill_adapter_with_error_handler import SkillAdapterWithErrorHandler + +CONFIG = DefaultConfig() + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +VALIDATOR = AllowedCallersClaimsValidator(CONFIG).claims_validator +SETTINGS = BotFrameworkAdapterSettings( + CONFIG.APP_ID, + CONFIG.APP_PASSWORD, + auth_configuration=AuthenticationConfiguration(claims_validator=VALIDATOR), +) +ADAPTER = SkillAdapterWithErrorHandler(SETTINGS, CONVERSATION_STATE) + +# Create the Bot +RECOGNIZER = DialogSkillBotRecognizer(CONFIG) +ROUTER = ActivityRouterDialog(RECOGNIZER, CONFIG) +BOT = SkillBot(CONVERSATION_STATE, ROUTER) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) + return Response(status=201) + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/__init__.py new file mode 100644 index 000000000..ebbe2ac15 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/__init__.py @@ -0,0 +1,3 @@ +from .allow_callers_claims_validation import AllowedCallersClaimsValidator + +__all__ = ["AllowedCallersClaimsValidator"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/allow_callers_claims_validation.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/allow_callers_claims_validation.py new file mode 100644 index 000000000..3403a32bc --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/authentication/allow_callers_claims_validation.py @@ -0,0 +1,39 @@ +from typing import Awaitable, Callable, Dict, List + +from botframework.connector.auth import JwtTokenValidation, SkillValidation + +from config import DefaultConfig + + +class AllowedCallersClaimsValidator: + + config_key = "ALLOWED_CALLERS" + + def __init__(self, config: DefaultConfig): + if not config: + raise TypeError( + "AllowedCallersClaimsValidator: config object cannot be None." + ) + + # ALLOWED_CALLERS is the setting in config.py 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 + self._allowed_callers = config.ALLOWED_CALLERS + + @property + def claims_validator(self) -> Callable[[List[Dict]], Awaitable]: + async def allow_callers_claims_validator(claims: Dict[str, object]): + # if _allowedCallers is None we allow all calls + if self._allowed_callers and SkillValidation.is_skill_claim(claims): + # Check that the appId claim in the skill request is in the list of skills configured for this bot. + app_id = JwtTokenValidation.get_app_id_from_claims(claims) + if app_id not in self._allowed_callers: + raise PermissionError( + f'Received a request from a bot with an app ID of "{app_id}".' + f" To enable requests from this caller, add the app ID to your configuration file." + ) + + return + + return allow_callers_claims_validator diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/__init__.py new file mode 100644 index 000000000..4d7e002ab --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .skill_bot import SkillBot +from .activity_router_dialog import ActivityRouterDialog + +__all__ = ["SkillBot", "ActivityRouterDialog"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/activity_router_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/activity_router_dialog.py new file mode 100644 index 000000000..1df72b267 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/activity_router_dialog.py @@ -0,0 +1,180 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import json +from http import HTTPStatus + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + DialogTurnStatus, +) +from botbuilder.core import MessageFactory, InvokeResponse +from botbuilder.schema import Activity, ActivityTypes, InputHints + +from config import DefaultConfig +from dialogs import ( + DialogSkillBotRecognizer, + BookingDialog, + OAuthTestDialog, + BookingDetails, + Location, +) + + +class ActivityRouterDialog(ComponentDialog): + """ + A root dialog that can route activities sent to the skill to different dialogs. + """ + + def __init__( + self, luis_recognizer: DialogSkillBotRecognizer, configuration: DefaultConfig + ): + super().__init__(ActivityRouterDialog.__name__) + + self._luis_recognizer = luis_recognizer + + self.add_dialog(BookingDialog()) + self.add_dialog(OAuthTestDialog(configuration)) + self.add_dialog( + WaterfallDialog(WaterfallDialog.__name__, [self.process_activity]) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def process_activity( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + current_activity_type = step_context.context.activity.type + + # A skill can send trace activities if needed + await step_context.context.send_trace_activity( + f"{ActivityRouterDialog.__name__}.process_activity()", + label=f"Got ActivityType: {current_activity_type}", + ) + + if current_activity_type == ActivityTypes.message: + return await self._on_message_activity(step_context) + if current_activity_type == ActivityTypes.invoke: + return await self._on_invoke_activity(step_context) + if current_activity_type == ActivityTypes.event: + return await self._on_event_activity(step_context) + + # We didn't get an activity type we can handle. + await step_context.context.send_activity( + MessageFactory.text( + f'Unrecognized ActivityType: "{current_activity_type}".', + input_hint=InputHints.ignoring_input, + ) + ) + return DialogTurnResult(DialogTurnStatus.Complete) + + async def _on_event_activity( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + activity = step_context.context.activity + + # Resolve what to execute based on the event name. + if activity.name == "BookFlight": + booking_details = BookingDetails() + if activity.value: + booking_details.from_json(json.loads(activity.value)) + + # Start the booking dialog + booking_dialog = await self.find_dialog(BookingDialog.__name__) + return await step_context.begin_dialog(booking_dialog.id, booking_details) + + if activity.name == "OAuthTest": + # Start the oauth dialog + oauth_dialog = await self.find_dialog(OAuthTestDialog.__name__) + return await step_context.begin_dialog(oauth_dialog.id, None) + + await step_context.context.send_activity( + MessageFactory.text( + f'Unrecognized EventName: "{activity.name}".', + input_hint=InputHints.ignoring_input, + ) + ) + return DialogTurnResult(DialogTurnStatus.Complete) + + async def _on_invoke_activity( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + activity = step_context.context.activity + + # Resolve what to execute based on the event name. + if activity.name == "GetWeather": + location = Location() + if activity.value: + location.from_json(json.loads(activity.value)) + + looking_into_it_message = "Getting your weather forecast..." + await step_context.context.send_activity( + MessageFactory.text( + looking_into_it_message, + looking_into_it_message, + InputHints.ignoring_input, + ) + ) + + # Create and return an invoke activity with the weather results. + invoke_response_activity = Activity( + type="invokeResponse", + value=InvokeResponse( + body=[ + "New York, NY, Clear, 56 F", + "Bellevue, WA, Mostly Cloudy, 48 F", + ], + status=HTTPStatus.OK, + ), + ) + + await step_context.context.send_activity(invoke_response_activity) + else: + # We didn't get an invoke name we can handle. + await step_context.context.send_activity( + MessageFactory.text( + f'Unrecognized InvokeName: "{activity.name}".', + input_hint=InputHints.ignoring_input, + ) + ) + + return DialogTurnResult(DialogTurnStatus.Complete) + + async def _on_message_activity( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + activity = step_context.context.activity + + if not self._luis_recognizer.is_configured: + await step_context.context.send_activity( + MessageFactory.text( + "NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and" + " 'LuisAPIHostName' to the config.py file.", + input_hint=InputHints.ignoring_input, + ) + ) + else: + # Call LUIS with the utterance. + luis_result = await self._luis_recognizer.recognize(step_context.context) + + message = f'LUIS results for "{activity.Text}":\n' + intent, intent_score = None, None + if luis_result.intents: + max_value_key = max( + luis_result.intents, key=lambda key: luis_result.intents[key] + ) + intent, intent_score = max_value_key, luis_result.intents[max_value_key] + + message += f'Intent: "{intent}" Score: {intent_score}\n' + message += f"Entities found: {len(luis_result.entities) - 1}\n" + for entity_key, entity_val in luis_result.entities: + if not entity_key == "$instance": + message += f"* {entity_val}\n" + + await step_context.context.send_activity( + MessageFactory.text(message, input_hint=InputHints.ignoring_input,) + ) + + return DialogTurnResult(DialogTurnStatus.Complete) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/skill_bot.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/skill_bot.py new file mode 100644 index 000000000..538344764 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/bots/skill_bot.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ( + Bot, + ConversationState, + TurnContext, +) +from botbuilder.dialogs import Dialog, DialogExtensions + + +class SkillBot(Bot): + def __init__(self, conversation_state: ConversationState, main_dialog: Dialog): + self._conversation_state = conversation_state + self._main_dialog = main_dialog + + async def on_turn(self, context: TurnContext): + await DialogExtensions.run_dialog( + self._main_dialog, + context, + self._conversation_state.create_property("DialogState"), + ) + + # Save any state changes that might have occurred during the turn. + await self._conversation_state.save_changes(context) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/config.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/config.py new file mode 100644 index 000000000..31662c36b --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/config.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 39783 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + + CONNECTION_NAME = "" + + # If ALLOWED_CALLERS is empty, any bot can call this Skill. Add MicrosoftAppIds to restrict + # callers to only those specified. + # Example: os.environ.get("AllowedCallers", ["54d3bb6a-3b6d-4ccd-bbfd-cad5c72fb53a"]) + ALLOWED_CALLERS = os.environ.get("AllowedCallers", []) + + LUIS_APP_ID = os.environ.get("LuisAppId", "") + LUIS_API_KEY = os.environ.get("LuisAPIKey", "") + # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" + LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-new-rg.json b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..b68e51ec6 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,264 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": { + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('appServicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2015-08-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "siteConfig": { + "appSettings": [ + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "gunicorn --bind 0.0.0.0 --worker-class aiohttp.worker.GunicornWebWorker --timeout 600 app:APP", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} \ No newline at end of file diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..66b044b52 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "gunicorn --bind 0.0.0.0 --worker-class aiohttp.worker.GunicornWebWorker --timeout 600 app:APP", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/__init__.py new file mode 100644 index 000000000..b6282e719 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/__init__.py @@ -0,0 +1,18 @@ +from .booking_details import BookingDetails +from .booking_dialog import BookingDialog +from .cancel_and_help_dialog import CancelAndHelpDialog +from .date_resolver_dialog import DateResolverDialog +from .dialog_skill_bot_recognizer import DialogSkillBotRecognizer +from .location import Location +from .oauth_test_dialog import OAuthTestDialog + + +__all__ = [ + "BookingDetails", + "BookingDialog", + "CancelAndHelpDialog", + "DateResolverDialog", + "DialogSkillBotRecognizer", + "Location", + "OAuthTestDialog", +] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_details.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_details.py new file mode 100644 index 000000000..3e0a1012f --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_details.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class BookingDetails: + def __init__( + self, + destination: str = None, + origin: str = None, + travel_date: str = None, + unsupported_airports=None, + ): + if unsupported_airports is None: + unsupported_airports = [] + self.destination = destination + self.origin = origin + self.travel_date = travel_date + self.unsupported_airports = unsupported_airports + + def from_json(self, json: dict): + self.destination = json.get("destination", None) + self.origin = json.get("origin", None) + self.travel_date = json.get("travel_date", None) + self.unsupported_airports = json.get("unsupported_airports", []) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_dialog.py new file mode 100644 index 000000000..5b4381919 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/booking_dialog.py @@ -0,0 +1,136 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datatypes_date_time.timex import Timex + +from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult +from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions +from botbuilder.core import MessageFactory +from botbuilder.schema import InputHints +from .cancel_and_help_dialog import CancelAndHelpDialog +from .date_resolver_dialog import DateResolverDialog + + +class BookingDialog(CancelAndHelpDialog): + def __init__(self, dialog_id: str = None): + super(BookingDialog, self).__init__(dialog_id or BookingDialog.__name__) + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + self.add_dialog(DateResolverDialog(DateResolverDialog.__name__)) + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, + [ + self.destination_step, + self.origin_step, + self.travel_date_step, + self.confirm_step, + self.final_step, + ], + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def destination_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + If a destination city has not been provided, prompt for one. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + if booking_details.destination is None: + message_text = "Where would you like to travel to?" + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + return await step_context.next(booking_details.destination) + + async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + If an origin city has not been provided, prompt for one. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + # Capture the response to the previous step's prompt + booking_details.destination = step_context.result + if booking_details.origin is None: + message_text = "From what city will you be travelling?" + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + return await step_context.next(booking_details.origin) + + async def travel_date_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + If a travel date has not been provided, prompt for one. + This will use the DATE_RESOLVER_DIALOG. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + # Capture the results of the previous step + booking_details.origin = step_context.result + if not booking_details.travel_date or self.is_ambiguous( + booking_details.travel_date + ): + return await step_context.begin_dialog( + DateResolverDialog.__name__, booking_details.travel_date + ) + return await step_context.next(booking_details.travel_date) + + async def confirm_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + Confirm the information the user has provided. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + # Capture the results of the previous step + booking_details.travel_date = step_context.result + message_text = ( + f"Please confirm, I have you traveling to: { booking_details.destination } from: " + f"{ booking_details.origin } on: { booking_details.travel_date}." + ) + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + + # Offer a YES/NO prompt. + return await step_context.prompt( + ConfirmPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + Complete the interaction and end the dialog. + :param step_context: + :return DialogTurnResult: + """ + if step_context.result: + booking_details = step_context.options + + return await step_context.end_dialog(booking_details) + return await step_context.end_dialog() + + def is_ambiguous(self, timex: str) -> bool: + timex_property = Timex(timex) + return "definite" not in timex_property.types diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/cancel_and_help_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/cancel_and_help_dialog.py new file mode 100644 index 000000000..f8bcc77d0 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/cancel_and_help_dialog.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + DialogContext, + DialogTurnResult, + DialogTurnStatus, +) +from botbuilder.schema import ActivityTypes, InputHints +from botbuilder.core import MessageFactory + + +class CancelAndHelpDialog(ComponentDialog): + def __init__(self, dialog_id: str): + super(CancelAndHelpDialog, self).__init__(dialog_id) + + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + result = await self.interrupt(inner_dc) + if result is not None: + return result + + return await super(CancelAndHelpDialog, self).on_continue_dialog(inner_dc) + + async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: + if inner_dc.context.activity.type == ActivityTypes.message: + text = inner_dc.context.activity.text.lower() + + help_message_text = "Show Help..." + help_message = MessageFactory.text( + help_message_text, help_message_text, InputHints.expecting_input + ) + + if text in ("help", "?"): + await inner_dc.context.send_activity(help_message) + return DialogTurnResult(DialogTurnStatus.Waiting) + + cancel_message_text = "Cancelling" + cancel_message = MessageFactory.text( + cancel_message_text, cancel_message_text, InputHints.ignoring_input + ) + + if text in ("cancel", "quit"): + await inner_dc.context.send_activity(cancel_message) + return await inner_dc.cancel_all_dialogs() + + return None diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/date_resolver_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/date_resolver_dialog.py new file mode 100644 index 000000000..c5c50463d --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/date_resolver_dialog.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datatypes_date_time.timex import Timex + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext +from botbuilder.dialogs.prompts import ( + DateTimePrompt, + PromptValidatorContext, + PromptOptions, + DateTimeResolution, +) +from botbuilder.schema import InputHints +from .cancel_and_help_dialog import CancelAndHelpDialog + + +class DateResolverDialog(CancelAndHelpDialog): + def __init__(self, dialog_id: str = None): + super(DateResolverDialog, self).__init__( + dialog_id or DateResolverDialog.__name__ + ) + + self.add_dialog( + DateTimePrompt( + DateTimePrompt.__name__, DateResolverDialog.datetime_prompt_validator + ) + ) + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__ + "2", [self.initial_step, self.final_step] + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + "2" + + async def initial_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + timex = step_context.options + + prompt_msg_text = "On what date would you like to travel?" + prompt_msg = MessageFactory.text( + prompt_msg_text, prompt_msg_text, InputHints.expecting_input + ) + + reprompt_msg_text = ( + "I'm sorry, for best results, please enter your travel date including the month, " + "day and year. " + ) + reprompt_msg = MessageFactory.text( + reprompt_msg_text, reprompt_msg_text, InputHints.expecting_input + ) + + if timex is None: + # We were not given any date at all so prompt the user. + return await step_context.prompt( + DateTimePrompt.__name__, + PromptOptions(prompt=prompt_msg, retry_prompt=reprompt_msg), + ) + # We have a Date we just need to check it is unambiguous. + if "definite" not in Timex(timex).types: + # This is essentially a "reprompt" of the data we were given up front. + return await step_context.prompt( + DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) + ) + + return await step_context.next(DateTimeResolution(timex=timex)) + + async def final_step(self, step_context: WaterfallStepContext): + timex = step_context.result[0].timex + return await step_context.end_dialog(timex) + + @staticmethod + async def datetime_prompt_validator(prompt_context: PromptValidatorContext) -> bool: + if prompt_context.recognized.succeeded: + timex = prompt_context.recognized.value[0].timex.split("T")[0] + + # TODO: Needs TimexProperty + return "definite" in Timex(timex).types + + return False diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/dialog_skill_bot_recognizer.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/dialog_skill_bot_recognizer.py new file mode 100644 index 000000000..6434fb13d --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/dialog_skill_bot_recognizer.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.ai.luis import LuisApplication, LuisRecognizer +from botbuilder.core import Recognizer, RecognizerResult, TurnContext + +from config import DefaultConfig + + +class DialogSkillBotRecognizer(Recognizer): + def __init__(self, configuration: DefaultConfig): + self._recognizer = None + + luis_is_configured = ( + configuration.LUIS_APP_ID + and configuration.LUIS_API_KEY + and configuration.LUIS_API_HOST_NAME + ) + if luis_is_configured: + luis_application = LuisApplication( + configuration.LUIS_APP_ID, + configuration.LUIS_API_KEY, + "https://" + configuration.LUIS_API_HOST_NAME, + ) + + self._recognizer = LuisRecognizer(luis_application) + + @property + def is_configured(self) -> bool: + # Returns true if luis is configured in the config.py and initialized. + return self._recognizer is not None + + async def recognize(self, turn_context: TurnContext) -> RecognizerResult: + return await self._recognizer.recognize(turn_context) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/location.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/location.py new file mode 100644 index 000000000..3ee3406ea --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/location.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class Location: + def __init__( + self, latitude: str = None, longitude: str = None, postal_code: str = None, + ): + self.latitude = latitude + self.longitude = longitude + self.postal_code = postal_code + + def from_json(self, json: dict): + self.latitude = json.get("latitude", None) + self.longitude = json.get("longitude", None) + self.postal_code = json.get("postal_code", None) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/oauth_test_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/oauth_test_dialog.py new file mode 100644 index 000000000..53321bcd8 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/dialogs/oauth_test_dialog.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from botbuilder.dialogs.prompts import ( + ConfirmPrompt, + PromptOptions, + OAuthPrompt, + OAuthPromptSettings, +) +from botbuilder.core import BotFrameworkAdapter, MessageFactory +from botbuilder.schema import InputHints + +from config import DefaultConfig +from .cancel_and_help_dialog import CancelAndHelpDialog + + +class OAuthTestDialog(CancelAndHelpDialog): + def __init__(self, configuration: DefaultConfig): + super().__init__(OAuthTestDialog.__name__) + + self._connection_name = configuration.CONNECTION_NAME + + self.add_dialog( + OAuthPrompt( + OAuthPrompt.__name__, + OAuthPromptSettings( + connection_name=self._connection_name, + text=f"Please Sign In to connection: '{self._connection_name}'", + title="Sign In", + timeout=300000, + ), + ) + ) + + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, [self.prompt_step, self.login_step] + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + return await step_context.begin_dialog(OAuthPrompt.__name__) + + async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Get the token from the previous step. + token_response = step_context.result + if token_response: + # Show the token + logged_in_message = "You are now logged in." + show_token_message = "Here is your token:" + await step_context.context.send_activity( + MessageFactory.text( + logged_in_message, logged_in_message, InputHints.ignoring_input + ) + ) + await step_context.context.send_activity( + MessageFactory.text( + f"{show_token_message} {token_response.token}", + show_token_message, + InputHints.ignoring_input, + ) + ) + + # Sign out + bot_adapter: BotFrameworkAdapter = step_context.context.adapter + await bot_adapter.sign_out_user(step_context.context, self._connection_name) + sign_out_message = "You have been signed out." + await step_context.context.send_activity( + MessageFactory.text( + sign_out_message, sign_out_message, InputHints.ignoring_input + ) + ) + + return await step_context.end_dialog() + + try_again_message = "Login was not successful please try again." + await step_context.context.send_activity( + MessageFactory.text( + try_again_message, try_again_message, InputHints.ignoring_input + ) + ) + return await step_context.end_dialog() diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/requirements.txt b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/requirements.txt new file mode 100644 index 000000000..353ad3a20 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/requirements.txt @@ -0,0 +1,3 @@ +aiohttp +botbuilder-core>=4.7.1 +datatypes-date-time>=1.0.0a2 diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/skill_adapter_with_error_handler.py b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/skill_adapter_with_error_handler.py new file mode 100644 index 000000000..d7cf87c73 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/dialog-skill-bot/skill_adapter_with_error_handler.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys +import traceback +from datetime import datetime + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MessageFactory, + TurnContext, +) +from botbuilder.schema import ActivityTypes, Activity, InputHints + + +class SkillAdapterWithErrorHandler(BotFrameworkAdapter): + def __init__( + self, + settings: BotFrameworkAdapterSettings, + conversation_state: ConversationState = None, + ): + super().__init__(settings) + self._conversation_state = conversation_state + + # Catch-all for errors. + async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + error_message_text = "The skill encountered an error or bug." + error_message = MessageFactory.text( + error_message_text, error_message_text, InputHints.ignoring_input + ) + await context.send_activity(error_message) + + error_message_text = ( + "To continue to run this bot, please fix the bot source code." + ) + error_message = MessageFactory.text( + error_message_text, error_message_text, InputHints.ignoring_input + ) + await context.send_activity(error_message) + + # Send a trace activity if we're talking to the Bot Framework Emulator + if context.activity.channel_id == "emulator": + # Create a trace activity that contains the error object + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.utcnow(), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error", + ) + # Send a trace activity, which will be displayed in Bot Framework Emulator + await context.send_activity(trace_activity) + + # Clear out state + nonlocal self + if self._conversation_state: + try: + await self._conversation_state.delete(context) + except Exception as exception: + print( + f"\n Exception caught on attempting to Delete ConversationState : {exception}", + file=sys.stderr, + ) + traceback.print_exc() + + # Send and EndOfConversation activity to the skill caller with the error to end the conversation + # and let the caller decide what to do. + end_of_conversation = Activity(type=ActivityTypes.end_of_conversation) + end_of_conversation.code = "SkillError" + end_of_conversation.text = str(error) + await context.send_activity(end_of_conversation) + + # 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 context.send_trace_activity( + "OnTurnError Trace", + str(error), + "https://www.botframework.com/schemas/error", + "TurnError", + ) + + self.on_turn_error = on_error