In the previous post, we tried the Bot in Microsoft Teams using ngrok, and we also added a @mention.
This post will be a bit code-heavy. A lot needed to be explained and a lot going on behind the scenes. Hopefully, this puts things a bit into context.
Dialogs are powerful in Bot Framework 4, and we could utilize them in very structured ways, and complicated branched conditional situations.
I will try to stick to the basics, but at the same time prepare the Bot for upcoming posts.
Here is the link to the Github repository for this post: https://github.com/simonagren/simon-blog-bot-v3
We have a few different dialogs: prompts
, waterfall dialogs
and component dialogs
Prompts are used to ask the user for input and wait until the user enters input. If the value is valid the prompt returns the value, otherwise it will re-prompt the user. We have to ability to define our custom validators for each prompt, and we could could also create our own custom prompts.
There are different prompts available, and among those:
A waterfall dialog is composed of a sequence of steps. Each step of the conversation is implemented as an asynchronous function that takes a waterfall step context parameter. Usually, in each step, we prompt the user for input(for instance a question), which then the user can respond to.
The input from the user will be passed to the next step.
Another option is to begin a child dialog from a step, and this could be as simple or complex as we want to build the branching.
You could read some more here regarding complex conversation flows.
The component dialog makes it easy to create reusable dialogs for specific scenarios, and sometimes it makes sense to break up the Bots’ logic into smaller pieces. A component could be added as a dialog to another ComponentDialog or DialogSet - we will use both of these scenarios in the Bot.
We can have Waterfall dialogs and prompts in each component and it will have its’ own “dialog flow”. The dialogs could be imported and run as the main dialog or started as child dialogs.
In our case we will have three components deriving from ComponentDialog
:
export class MainDialog extends ComponentDialog {
MainDialog
export class SiteDialog extends ComponentDialog {
export class OwnerResolverDialog extends ComponentDialog {
This is a high-level visualization of how the Bot is built:
In the src folder, we create an additional folder named dialogs. It will contain 4 new files:
We have also done a few changes in the bot.ts file
We added some more imports from botbuilder for handling state in the dialogs, and a few from botbuilder-dialogs. We have also imported the MainDialog that we will run.
import {Activity,ActivityHandler,ActivityTypes,BotState,ChannelAccount,ConversationState,Mention,StatePropertyAccessor,TurnContext,UserState} from 'botbuilder';import { Dialog, DialogState } from 'botbuilder-dialogs';import { MainDialog } from '../dialogs/mainDialog';
And handling state is not in the scope of this post, you can read more here
private conversationState: BotState;private userState: BotState;private dialog: Dialog;private dialogState: StatePropertyAccessor<DialogState>;/**** @param {ConversationState} conversationState* @param {UserState} userState* @param {Dialog} dialog*/constructor(conversationState: BotState,userState: BotState,dialog: Dialog) {super();if (!conversationState) {throw new Error('[SimonBot]: Missing parameter. conversationState is required');}if (!userState) {throw new Error('[SimonBot]: Missing parameter. userState is required');}if (!dialog) {throw new Error('[SimonBot]: Missing parameter. dialog is required');}this.conversationState = conversationState as ConversationState;this.userState = userState as UserState;this.dialog = dialog;this.dialogState = this.conversationState.createProperty<DialogState>('DialogState');...
The onMessage method will now run the MainDialog with the current context and dialogState. Before it sent back a message to the user @mention and echoing what was written to the Bot.
this.onMessage(async (context, next) => {// Run the Dialog with the new message Activity.await (this.dialog as MainDialog).run(context, this.dialogState);// By calling next() you ensure that the next BotHandler is run.await next();});
We have added the onDialog method to save the state changes.
this.onDialog(async (context, next) => {// Save any state changes. The load happened during the execution of the Dialog.await this.conversationState.saveChanges(context, false);await this.userState.saveChanges(context, false);// By calling next() you ensure that the next BotHandler is run.await next();});
The onMembersAdded have instead gotten the @mention part (slightly changed).
this.onMembersAdded(async (context, next) => {const membersAdded = context.activity.membersAdded;for (const member of membersAdded) {if (member.id !== context.activity.recipient.id) {// If we are in Microsoft Teamsif (context.activity.channelId === 'msteams') {// Send a message with an @Mentionawait this._messageWithMention(context, member);} else {// Otherwise we send a normal echoawait context.sendActivity(`Welcome to Simon Bot ${member.name}. This Bot is a work in progress. At this time we have some dialogs working. Type anything to get started.`);}}}
Here we also used to have a single import from botbuilder and now we have a couple more imports from. We have also imported the MainDialog that we will use and inject in the Bot class instance.
import { BotFrameworkAdapter, ConversationState, MemoryStorage, UserState } from 'botbuilder';import { MainDialog } from './dialogs/mainDialog';import { SimonBot } from './bots/bot';
in adapter.onTurnError at the end of the method, we have added some code to delete the conversationState on error.
await conversationState.delete(context);
let conversationState: ConversationState;let userState: UserState;const memoryStorage = new MemoryStorage();conversationState = new ConversationState(memoryStorage);userState = new UserState(memoryStorage);const dialog = new MainDialog('mainDialog');const myBot = new SimonBot(conversationState, userState, dialog);
The main dialog consists of two steps initialStep and finalStep. That will kick off the siteDialog and then collect the result.
At first, we add some constants. It’s the name of the dialogs we will add.
const SITE_DIALOG = 'siteDialog';const MAIN_WATERFALL_DIALOG = 'waterfallDialog';
Then later we use them in the constructor while adding the dialogs we want.
constructor(id: string) {super(id);this.addDialog(new SiteDialog(SITE_DIALOG)).addDialog(new WaterfallDialog(MAIN_WATERFALL_DIALOG, [this.initialStep.bind(this),this.finalStep.bind(this)]));this.initialDialogId = MAIN_WATERFALL_DIALOG;}}
First, we add the dialogs we will use, there’s the SiteDialog and a WaterFallDialog. The Waterfall Dialog contains steps, and these have been added in the sequence they are intended to be run.
This step creates a new instance of the SiteDetails class and injects it while starting the siteDialog child dialog. We are using the constant SITE_DIALOG
to make sure there are no spelling errors.
private async initialStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {const siteDetails = new SiteDetails();return await stepContext.beginDialog(SITE_DIALOG, siteDetails);}
In this step, we collect the result from the previous step. It’s the result of all the siteDialog steps. This is where we would typically save something to a “database”.
private async finalStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {if (stepContext.result) {const result = stepContext.result as SiteDetails;const msg = `I have created a ${ JSON.stringify(result) }`;await stepContext.context.sendActivity(msg);}return await stepContext.endDialog();}
The site dialog consists of multiple prompts, the OwnerResolverDialog and another WaterFallDialog.
This is what the constructor looks like:
constructor(id: string) {super(id || 'siteDialog');this.addDialog(new ChoicePrompt(CHOICE_PROMPT)).addDialog(new TextPrompt(TEXT_PROMPT)).addDialog(new OwnerResolverDialog(OWNER_RESOLVER_DIALOG)).addDialog(new ConfirmPrompt(CONFIRM_PROMPT)).addDialog(new WaterfallDialog(WATERFALL_DIALOG, [this.siteTypeStep.bind(this),this.titleStep.bind(this),this.descriptionStep.bind(this),this.ownerStep.bind(this),this.aliasStep.bind(this),this.confirmStep.bind(this),this.finalStep.bind(this)]));this.initialDialogId = WATERFALL_DIALOG;}
The first siteTypeStep will use a ChoicePrompt.
We will use the WaterFallStepContext to populate siteDetails with values in every step.
If we don’t have a siteType value in siteDetails, we will prompt the user. In this case, we use the dialog/prompt we added using the CHOICE_PROMPT.
And if we have a value, we will just run .next(siteDetails.siteType) and send the value to the next step in the WaterFall Dialog.
private async siteTypeStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {const siteDetails = stepContext.options as SiteDetails;if (!siteDetails.siteType) {return await stepContext.prompt(CHOICE_PROMPT, {choices: ChoiceFactory.toChoices(['Team Site', 'Communication Site']),prompt: 'Select site type.'});} else {return await stepContext.next(siteDetails.siteType);}}
The second step siteTitleStep
will use a TextPrompt
.
We will first get the siteType value from the previous step. And as in the previous step, if we don’t have the value from the user, we will prompt the user. And if we have a value, we will just run .next(siteDetails.title) and send the value to the next step in the WaterFall Dialog.
private async titleStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {const siteDetails = stepContext.options as SiteDetails;siteDetails.siteType = stepContext.result;if (!siteDetails.title) {const promptText = 'Provide a title for your site';return await stepContext.prompt(TEXT_PROMPT, { prompt: promptText });} else {return await stepContext.next(siteDetails.title);}}
The ownerStep will not use any of the prompts we added. It will instead kick off the OwnerResolverDialog.
private async ownerStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {const siteDetails = stepContext.options as SiteDetails;// Capture the results of the previous stepsiteDetails.description = stepContext.result;if (!siteDetails.owner) {return await stepContext.beginDialog(OWNER_RESOLVER_DIALOG, { siteDetails });} else {return await stepContext.next(siteDetails.owner);}}
I have created a dialog similar to the MainDialog only a bit smaller.
We also add a prompt here and a WaterFallDialog, but only two steps - an initial and final step.
this.addDialog(new TextPrompt(TEXT_PROMPT, OwnerResolverDialog.ownerPromptValidator.bind(this))).addDialog(new WaterfallDialog(WATERFALL_DIALOG, [this.initialStep.bind(this),this.finalStep.bind(this)]));this.initialDialogId = WATERFALL_DIALOG;}
Here we supply a validation method to the text prompt. Sometimes we need multiple validation methods, we will add some extra validation here in another post using Microsoft Graph to see that the user exists.
private static async ownerPromptValidator(promptContext: PromptValidatorContext<string>): Promise<boolean> {if (promptContext.recognized.succeeded) {const owner: string = promptContext.recognized.value;// Regex for emailif (!OwnerResolverDialog.validateEmail(owner)) {promptContext.context.sendActivity('Malformatted email adress.');return false;} else {return true;}} else {return false;}}
This is how the steps look like, they should be familiar. Note that we don’t use a repromtMessage since we handle the message to the user in the validator method.
private async initialStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {const siteDetails = (stepContext.options as any).siteDetails;const promptMsg = 'Provide an owner email';if (!siteDetails.owner) {return await stepContext.prompt(TEXT_PROMPT, {prompt: promptMsg});} else {return await stepContext.next(siteDetails.owner);}}private async finalStep(stepContext: WaterfallStepContext): Promise<DialogTurnResult> {const owner = stepContext.result;return await stepContext.endDialog(owner);}
The next post will be a short one focusing on how to handle user interruptions. If the user asks for help or restarts the Bot.