diff --git a/samples/05.multi-turn-prompt/README.md b/samples/05.multi-turn-prompt/README.md new file mode 100644 index 000000000..405a70f2a --- /dev/null +++ b/samples/05.multi-turn-prompt/README.md @@ -0,0 +1,50 @@ +# multi-turn prompt + +Bot Framework v4 welcome users bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use the prompts classes included in `botbuilder-dialogs`. This bot will ask for the user's name and age, then store the responses. It demonstrates a multi-turn dialog flow using a text prompt, a number prompt, and state accessors to store and retrieve values. + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +- Run `pip install -r requirements.txt` to install all dependencies +- Run `python app.py` +- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` + + +### Visual studio code +- Activate your desired virtual environment +- Open `botbuilder-python\samples\45.state-management` folder +- Bring up a terminal, navigate to `botbuilder-python\samples\05.multi-turn-prompt` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + + +## Prompts + +A conversation between a bot and a user often involves asking (prompting) the user for information, parsing the user's response, +and then acting on that information. This sample demonstrates how to prompt users for information using the different prompt types +included in the [botbuilder-dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) library +and supported by the SDK. + +The `botbuilder-dialogs` library includes a variety of pre-built prompt classes, including text, number, and datetime types. This +sample demonstrates using a text prompt to collect the user's name, then using a number prompt to collect an age. + +# 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) +- [Gathering Input Using Prompts](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) +- [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/05.multi-turn-prompt/app.py b/samples/05.multi-turn-prompt/app.py new file mode 100644 index 000000000..2a358711d --- /dev/null +++ b/samples/05.multi-turn-prompt/app.py @@ -0,0 +1,102 @@ +# 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 dialogs import UserProfileDialog +from bots import DialogBot + +# Create the loop and Flask app +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(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]: { 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) + + # Clear out state + await CONVERSATION_STATE.delete(context) + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +CONVERSATION_STATE = ConversationState(MEMORY) +USER_STATE = UserState(MEMORY) + +# create main dialog and bot +DIALOG = UserProfileDialog(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.s + 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/05.multi-turn-prompt/bots/__init__.py b/samples/05.multi-turn-prompt/bots/__init__.py new file mode 100644 index 000000000..306aca22c --- /dev/null +++ b/samples/05.multi-turn-prompt/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/05.multi-turn-prompt/bots/dialog_bot.py b/samples/05.multi-turn-prompt/bots/dialog_bot.py new file mode 100644 index 000000000..37a140966 --- /dev/null +++ b/samples/05.multi-turn-prompt/bots/dialog_bot.py @@ -0,0 +1,51 @@ +# 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 + +""" +This Bot implementation can run any type of Dialog. The use of type parameterization is to allows multiple +different bots to be run at different endpoints within the same project. This can be achieved by defining distinct +Controller types each with dependency on distinct Bot types. The ConversationState is used by the Dialog system. The +UserState isn't, however, it might have been used in a Dialog implementation, and the requirement is that all +BotState objects are saved at the end of a turn. +""" + + +class DialogBot(ActivityHandler): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog + ): + if conversation_state is None: + raise TypeError( + "[DialogBot]: Missing parameter. conversation_state is required but None was given" + ) + if user_state is None: + raise TypeError( + "[DialogBot]: Missing parameter. user_state is required but None was given" + ) + if dialog is None: + raise Exception("[DialogBot]: Missing parameter. dialog is required") + + 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 ocurred 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): + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/samples/05.multi-turn-prompt/config.py b/samples/05.multi-turn-prompt/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/05.multi-turn-prompt/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/05.multi-turn-prompt/data_models/__init__.py b/samples/05.multi-turn-prompt/data_models/__init__.py new file mode 100644 index 000000000..35a5934d4 --- /dev/null +++ b/samples/05.multi-turn-prompt/data_models/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .user_profile import UserProfile + +__all__ = ["UserProfile"] diff --git a/samples/05.multi-turn-prompt/data_models/user_profile.py b/samples/05.multi-turn-prompt/data_models/user_profile.py new file mode 100644 index 000000000..efdc77eeb --- /dev/null +++ b/samples/05.multi-turn-prompt/data_models/user_profile.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" + This is our application state. Just a regular serializable Python class. +""" + + +class UserProfile: + def __init__(self, name: str = None, transport: str = None, age: int = 0): + self.name = name + self.transport = transport + self.age = age diff --git a/samples/05.multi-turn-prompt/dialogs/__init__.py b/samples/05.multi-turn-prompt/dialogs/__init__.py new file mode 100644 index 000000000..2de723d58 --- /dev/null +++ b/samples/05.multi-turn-prompt/dialogs/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .user_profile_dialog import UserProfileDialog + +__all__ = ["UserProfileDialog"] diff --git a/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py b/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py new file mode 100644 index 000000000..86eea641b --- /dev/null +++ b/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py @@ -0,0 +1,158 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult +) +from botbuilder.dialogs.prompts import ( + TextPrompt, + NumberPrompt, + ChoicePrompt, + ConfirmPrompt, + PromptOptions, + PromptValidatorContext +) +from botbuilder.dialogs.choices import Choice +from botbuilder.core import MessageFactory, UserState + +from data_models import UserProfile + + +class UserProfileDialog(ComponentDialog): + def __init__( + self, user_state: UserState + ): + super(UserProfileDialog, self).__init__(UserProfileDialog.__name__) + + self.user_profile_accessor = user_state.create_property("UserProfile") + + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, [ + self.transport_step, + self.name_step, + self.name_confirm_step, + self.age_step, + self.confirm_step, + self.summary_step + ] + ) + ) + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog( + NumberPrompt( + NumberPrompt.__name__, + UserProfileDialog.age_prompt_validator + ) + ) + self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def transport_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # WaterfallStep always finishes with the end of the Waterfall or with another dialog; + # here it is a Prompt Dialog. Running a prompt here means the next WaterfallStep will + # be run when the users response is received. + return await step_context.prompt( + ChoicePrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Please enter your mode of transport."), + choices=[Choice("Car"), Choice("Bus"), Choice("Bicycle")] + ) + ) + + async def name_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + step_context.values["transport"] = step_context.result.value + + return await step_context.prompt( + TextPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Please enter your name.") + ) + ) + + async def name_confirm_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + step_context.values["name"] = step_context.result + + # We can send messages to the user at any point in the WaterfallStep. + await step_context.context.send_activity( + MessageFactory.text(f"Thanks {step_context.result}") + ) + + # WaterfallStep always finishes with the end of the Waterfall or + # with another dialog; here it is a Prompt Dialog. + return await step_context.prompt( + ConfirmPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Would you like to give your age?") + ) + ) + + async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if step_context.result: + # User said "yes" so we will be prompting for the age. + # WaterfallStep always finishes with the end of the Waterfall or with another dialog, + # here it is a Prompt Dialog. + return await step_context.prompt( + NumberPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Please enter your age."), + retry_prompt=MessageFactory.text( + "The value entered must be greater than 0 and less than 150." + ) + ) + ) + else: + # User said "no" so we will skip the next step. Give -1 as the age. + return await step_context.next(-1) + + async def confirm_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + age = step_context.result + step_context.values["age"] = step_context.result + + msg = "No age given." if step_context.result == -1 else f"I have your age as {age}." + + # We can send messages to the user at any point in the WaterfallStep. + await step_context.context.send_activity(MessageFactory.text(msg)) + + # WaterfallStep always finishes with the end of the Waterfall or + # with another dialog; here it is a Prompt Dialog. + return await step_context.prompt( + ConfirmPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Is this ok?") + ) + ) + + async def summary_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if step_context.result: + # Get the current profile object from user state. Changes to it + # will saved during Bot.on_turn. + user_profile = await self.user_profile_accessor.get(step_context.context, UserProfile) + + user_profile.transport = step_context.values["transport"] + user_profile.name = step_context.values["name"] + user_profile.age = step_context.values["age"] + + msg = f"I have your mode of transport as {user_profile.transport} and your name as {user_profile.name}." + if user_profile.age != -1: + msg += f" And age as {user_profile.age}." + + await step_context.context.send_activity(MessageFactory.text(msg)) + else: + await step_context.context.send_activity( + MessageFactory.text("Thanks. Your profile will not be kept.") + ) + + # WaterfallStep always finishes with the end of the Waterfall or with another + # dialog, here it is the end. + return await step_context.end_dialog() + + @staticmethod + async def age_prompt_validator(prompt_context: PromptValidatorContext) -> bool: + # This condition is our validation rule. You can also change the value at this point. + return prompt_context.recognized.succeeded and 0 < prompt_context.recognized.value < 150 diff --git a/samples/05.multi-turn-prompt/helpers/__init__.py b/samples/05.multi-turn-prompt/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/05.multi-turn-prompt/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/05.multi-turn-prompt/helpers/dialog_helper.py b/samples/05.multi-turn-prompt/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/05.multi-turn-prompt/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/05.multi-turn-prompt/requirements.txt b/samples/05.multi-turn-prompt/requirements.txt new file mode 100644 index 000000000..676447d22 --- /dev/null +++ b/samples/05.multi-turn-prompt/requirements.txt @@ -0,0 +1,4 @@ +botbuilder-dialogs>=4.4.0.b1 +botbuilder-ai>=4.4.0.b1 +flask>=1.0.3 +