From 4c293cf68eb7194d0a83d7c5cf3ed028295301cd Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Fri, 1 Nov 2019 14:51:35 -0500 Subject: [PATCH] Added 19.custom-dialogs --- samples/19.custom-dialogs/README.md | 48 ++++++ samples/19.custom-dialogs/app.py | 99 +++++++++++++ samples/19.custom-dialogs/bots/__init__.py | 6 + samples/19.custom-dialogs/bots/dialog_bot.py | 29 ++++ samples/19.custom-dialogs/config.py | 15 ++ samples/19.custom-dialogs/dialogs/__init__.py | 7 + .../19.custom-dialogs/dialogs/root_dialog.py | 138 ++++++++++++++++++ .../19.custom-dialogs/dialogs/slot_details.py | 21 +++ .../dialogs/slot_filling_dialog.py | 89 +++++++++++ samples/19.custom-dialogs/helpers/__init__.py | 6 + .../helpers/dialog_helper.py | 19 +++ samples/19.custom-dialogs/requirements.txt | 2 + 12 files changed, 479 insertions(+) create mode 100644 samples/19.custom-dialogs/README.md create mode 100644 samples/19.custom-dialogs/app.py create mode 100644 samples/19.custom-dialogs/bots/__init__.py create mode 100644 samples/19.custom-dialogs/bots/dialog_bot.py create mode 100644 samples/19.custom-dialogs/config.py create mode 100644 samples/19.custom-dialogs/dialogs/__init__.py create mode 100644 samples/19.custom-dialogs/dialogs/root_dialog.py create mode 100644 samples/19.custom-dialogs/dialogs/slot_details.py create mode 100644 samples/19.custom-dialogs/dialogs/slot_filling_dialog.py create mode 100644 samples/19.custom-dialogs/helpers/__init__.py create mode 100644 samples/19.custom-dialogs/helpers/dialog_helper.py create mode 100644 samples/19.custom-dialogs/requirements.txt diff --git a/samples/19.custom-dialogs/README.md b/samples/19.custom-dialogs/README.md new file mode 100644 index 000000000..14874d971 --- /dev/null +++ b/samples/19.custom-dialogs/README.md @@ -0,0 +1,48 @@ +# Custom Dialogs + +Bot Framework v4 custom dialogs bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to sub-class the `Dialog` class to create different bot control mechanism like simple slot filling. + +BotFramework provides a built-in base class called `Dialog`. By subclassing `Dialog`, developers can create new ways to define and control dialog flows used by the bot. + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +- Activate your desired virtual environment +- Bring up a terminal, navigate to `botbuilder-python\samples\19.custom-dialogs` folder +- In the terminal, type `pip install -r requirements.txt` +- In the terminal, type `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 +- File -> Open Bot +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## Custom Dialogs + +BotFramework provides a built-in base class called `Dialog`. By subclassing Dialog, developers +can create new ways to define and control dialog flows used by the bot. By adhering to the +features of this class, developers will create custom dialogs that can be used side-by-side +with other dialog types, as well as built-in or custom prompts. + +This example demonstrates a custom Dialog class called `SlotFillingDialog`, which takes a +series of "slots" which define a value the bot needs to collect from the user, as well +as the prompt it should use. The bot will iterate through all of the slots until they are +all full, at which point the dialog completes. + +# 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) +- [Dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) +- [Dialog class reference](https://docs.microsoft.com/en-us/javascript/api/botbuilder-dialogs/dialog) +- [Manage complex conversation flows with dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-dialog-manage-complex-conversation-flow?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) diff --git a/samples/19.custom-dialogs/app.py b/samples/19.custom-dialogs/app.py new file mode 100644 index 000000000..880dd8a85 --- /dev/null +++ b/samples/19.custom-dialogs/app.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +from datetime import datetime +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import DialogBot + +# Create the loop and Flask app +from dialogs.root_dialog import RootDialog + +LOOP = asyncio.get_event_loop() +APP = Flask(__name__, instance_relative_config=True) +APP.config.from_object("config.DefaultConfig") + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"]) +ADAPTER = BotFrameworkAdapter(SETTINGS) + + +# Catch-all for errors. +async def on_error(self, context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # 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) + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") + await context.send_activity("To continue to run this bot, please fix the bot source code.") + # 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) + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +# Create MemoryStorage and state +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create Dialog and Bot +DIALOG = RootDialog(USER_STATE) +BOT = DialogBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + +# Listen for incoming requests on /api/messages. +@APP.route("/api/messages", methods=["POST"]) +def messages(): + # Main bot message handler. + if "application/json" in request.headers["Content-Type"]: + body = request.json + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = ( + request.headers["Authorization"] if "Authorization" in request.headers else "" + ) + + try: + task = LOOP.create_task( + ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + ) + LOOP.run_until_complete(task) + return Response(status=201) + except Exception as exception: + raise exception + + +if __name__ == "__main__": + try: + APP.run(debug=False, port=APP.config["PORT"]) # nosec debug + except Exception as exception: + raise exception diff --git a/samples/19.custom-dialogs/bots/__init__.py b/samples/19.custom-dialogs/bots/__init__.py new file mode 100644 index 000000000..306aca22c --- /dev/null +++ b/samples/19.custom-dialogs/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_bot import DialogBot + +__all__ = ["DialogBot"] diff --git a/samples/19.custom-dialogs/bots/dialog_bot.py b/samples/19.custom-dialogs/bots/dialog_bot.py new file mode 100644 index 000000000..b9648661c --- /dev/null +++ b/samples/19.custom-dialogs/bots/dialog_bot.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState +from botbuilder.dialogs import Dialog + +from helpers.dialog_helper import DialogHelper + + +class DialogBot(ActivityHandler): + def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): + self.conversation_state = conversation_state + self.user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + 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) + await self.user_state.save_changes(turn_context) + + async def on_message_activity(self, turn_context: TurnContext): + # Run the Dialog with the new message Activity. + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/samples/19.custom-dialogs/config.py b/samples/19.custom-dialogs/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/19.custom-dialogs/config.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +""" Bot Configuration """ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3978 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") diff --git a/samples/19.custom-dialogs/dialogs/__init__.py b/samples/19.custom-dialogs/dialogs/__init__.py new file mode 100644 index 000000000..83d4d61d3 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .slot_filling_dialog import SlotFillingDialog +from .root_dialog import RootDialog + +__all__ = ["RootDialog", "SlotFillingDialog"] diff --git a/samples/19.custom-dialogs/dialogs/root_dialog.py b/samples/19.custom-dialogs/dialogs/root_dialog.py new file mode 100644 index 000000000..e7ab55ec8 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/root_dialog.py @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + NumberPrompt, PromptValidatorContext) +from botbuilder.dialogs.prompts import TextPrompt +from botbuilder.core import MessageFactory, UserState +from recognizers_text import Culture + +from dialogs import SlotFillingDialog +from dialogs.slot_details import SlotDetails + + +class RootDialog(ComponentDialog): + def __init__( + self, user_state: UserState + ): + super(RootDialog, self).__init__(RootDialog.__name__) + + self.user_state_accessor = user_state.create_property("result") + + # Rather than explicitly coding a Waterfall we have only to declare what properties we want collected. + # In this example we will want two text prompts to run, one for the first name and one for the last + fullname_slots = [ + SlotDetails( + name="first", + dialog_id="text", + prompt="Please enter your first name." + ), + SlotDetails( + name="last", + dialog_id="text", + prompt="Please enter your last name." + ) + ] + + # This defines an address dialog that collects street, city and zip properties. + address_slots = [ + SlotDetails( + name="street", + dialog_id="text", + prompt="Please enter the street address." + ), + SlotDetails( + name="city", + dialog_id="text", + prompt="Please enter the city." + ), + SlotDetails( + name="zip", + dialog_id="text", + prompt="Please enter the zip." + ) + ] + + # Dialogs can be nested and the slot filling dialog makes use of that. In this example some of the child + # dialogs are slot filling dialogs themselves. + slots = [ + SlotDetails( + name="fullname", + dialog_id="fullname", + ), + SlotDetails( + name="age", + dialog_id="number", + prompt="Please enter your age." + ), + SlotDetails( + name="shoesize", + dialog_id="shoesize", + prompt="Please enter your shoe size.", + retry_prompt="You must enter a size between 0 and 16. Half sizes are acceptable." + ), + SlotDetails( + name="address", + dialog_id="address" + ) + ] + + # Add the various dialogs that will be used to the DialogSet. + self.add_dialog(SlotFillingDialog("address", address_slots)) + self.add_dialog(SlotFillingDialog("fullname", fullname_slots)) + self.add_dialog(TextPrompt("text")) + self.add_dialog(NumberPrompt("number", default_locale=Culture.English)) + self.add_dialog(NumberPrompt("shoesize", RootDialog.shoe_size_validator, default_locale=Culture.English)) + self.add_dialog(SlotFillingDialog("slot-dialog", slots)) + + # Defines a simple two step Waterfall to test the slot dialog. + self.add_dialog( + WaterfallDialog( + "waterfall", [self.start_dialog, self.process_result] + ) + ) + + # The initial child Dialog to run. + self.initial_dialog_id = "waterfall" + + async def start_dialog(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Start the child dialog. This will run the top slot dialog than will complete when all the properties are + # gathered. + return await step_context.begin_dialog("slot-dialog") + + async def process_result(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # To demonstrate that the slot dialog collected all the properties we will echo them back to the user. + if type(step_context.result) is dict and len(step_context.result) > 0: + fullname: Dict[str, object] = step_context.result["fullname"] + shoe_size: float = step_context.result["shoesize"] + address: dict = step_context.result["address"] + + # store the response on UserState + obj: dict = await self.user_state_accessor.get(step_context.context, dict) + obj["data"] = {} + obj["data"]["fullname"] = f"{fullname.get('first')} {fullname.get('last')}" + obj["data"]["shoesize"] = f"{shoe_size}" + obj["data"]["address"] = f"{address['street']}, {address['city']}, {address['zip']}" + + # show user the values + await step_context.context.send_activity(MessageFactory.text(obj["data"]["fullname"])) + await step_context.context.send_activity(MessageFactory.text(obj["data"]["shoesize"])) + await step_context.context.send_activity(MessageFactory.text(obj["data"]["address"])) + + return await step_context.end_dialog() + + @staticmethod + async def shoe_size_validator(prompt_context: PromptValidatorContext) -> bool: + shoe_size = round(prompt_context.recognized.value, 1) + + # show sizes can range from 0 to 16, whole or half sizes only + if 0 <= shoe_size <= 16 and (shoe_size * 2) % 1 == 0: + prompt_context.recognized.value = shoe_size + return True + return False diff --git a/samples/19.custom-dialogs/dialogs/slot_details.py b/samples/19.custom-dialogs/dialogs/slot_details.py new file mode 100644 index 000000000..3478f8b55 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/slot_details.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import PromptOptions + + +class SlotDetails: + def __init__(self, + name: str, + dialog_id: str, + options: PromptOptions = None, + prompt: str = None, + retry_prompt: str = None + ): + self.name = name + self.dialog_id = dialog_id + self.options = options if options else PromptOptions( + prompt=MessageFactory.text(prompt), + retry_prompt=None if retry_prompt is None else MessageFactory.text(retry_prompt) + ) diff --git a/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py b/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py new file mode 100644 index 000000000..7f7043055 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List, Dict + +from botbuilder.dialogs import ( + DialogContext, + DialogTurnResult, + Dialog, DialogInstance, DialogReason) +from botbuilder.schema import ActivityTypes + +from dialogs.slot_details import SlotDetails + +""" +This is an example of implementing a custom Dialog class. This is similar to the Waterfall dialog in the +framework; however, it is based on a Dictionary rather than a sequential set of functions. The dialog is defined by a +list of 'slots', each slot represents a property we want to gather and the dialog we will be using to collect it. +Often the property is simply an atomic piece of data such as a number or a date. But sometimes the property is itself +a complex object, in which case we can use the slot dialog to collect that compound property. +""" + + +class SlotFillingDialog(Dialog): + def __init__(self, dialog_id: str, slots: List[SlotDetails]): + super(SlotFillingDialog, self).__init__(dialog_id) + + # Custom dialogs might define their own custom state. Similarly to the Waterfall dialog we will have a set of + # values in the ConversationState. However, rather than persisting an index we will persist the last property + # we prompted for. This way when we resume this code following a prompt we will have remembered what property + # we were filling. + self.SLOT_NAME = "slot" + self.PERSISTED_VALUES = "values" + + # The list of slots defines the properties to collect and the dialogs to use to collect them. + self.slots = slots + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + if dialog_context.context.activity.type != ActivityTypes.message: + return await dialog_context.end_dialog({}) + return await self._run_prompt(dialog_context) + + async def continue_dialog(self, dialog_context: DialogContext, options: object = None): + if dialog_context.context.activity.type != ActivityTypes.message: + return Dialog.end_of_turn + return await self._run_prompt(dialog_context) + + async def resume_dialog(self, dialog_context: DialogContext, reason: DialogReason, result: object): + slot_name = dialog_context.active_dialog.state[self.SLOT_NAME] + values = self._get_persisted_values(dialog_context.active_dialog) + values[slot_name] = result + + return await self._run_prompt(dialog_context) + + async def _run_prompt(self, dialog_context: DialogContext) -> DialogTurnResult: + """ + This helper function contains the core logic of this dialog. The main idea is to compare the state we have + gathered with the list of slots we have been asked to fill. When we find an empty slot we execute the + corresponding prompt. + :param dialog_context: + :return: + """ + state = self._get_persisted_values(dialog_context.active_dialog) + + # Run through the list of slots until we find one that hasn't been filled yet. + unfilled_slot = None + for slot_detail in self.slots: + if slot_detail.name not in state: + unfilled_slot = slot_detail + break + + # If we have an unfilled slot we will try to fill it + if unfilled_slot: + # The name of the slot we will be prompting to fill. + dialog_context.active_dialog.state[self.SLOT_NAME] = unfilled_slot.name + + # Run the child dialog + return await dialog_context.begin_dialog(unfilled_slot.dialog_id, unfilled_slot.options) + else: + # No more slots to fill so end the dialog. + return await dialog_context.end_dialog(state) + + def _get_persisted_values(self, dialog_instance: DialogInstance) -> Dict[str, object]: + obj = dialog_instance.state.get(self.PERSISTED_VALUES) + + if not obj: + obj = {} + dialog_instance.state[self.PERSISTED_VALUES] = obj + + return obj diff --git a/samples/19.custom-dialogs/helpers/__init__.py b/samples/19.custom-dialogs/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/19.custom-dialogs/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/19.custom-dialogs/helpers/dialog_helper.py b/samples/19.custom-dialogs/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/19.custom-dialogs/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/19.custom-dialogs/requirements.txt b/samples/19.custom-dialogs/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/19.custom-dialogs/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3