botbuilder-dialogs
Version:
A dialog stack based conversation manager for Microsoft BotBuilder.
360 lines (336 loc) • 13.9 kB
text/typescript
/**
* @module botbuilder-dialogs
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { v4 as uuidv4 } from 'uuid';
import { ActivityTypes } from 'botbuilder-core';
import { TurnContext, telemetryTrackDialogView } from 'botbuilder-core';
import { DialogInstance } from './dialog';
import { Dialog, DialogReason, DialogTurnResult } from './dialog';
import { DialogContext } from './dialogContext';
import { WaterfallStepContext } from './waterfallStepContext';
/**
* Function signature of an individual waterfall step.
*
* ```TypeScript
* type WaterfallStep<O extends object = {}> = (step: WaterfallStepContext<O>) => Promise<DialogTurnResult>;
* ```
*
* @param O (Optional) type of dialog options passed into the step.
* @param WaterfallStep.step Contextual information for the current step being executed.
*/
export type WaterfallStep<O extends object = {}> = (step: WaterfallStepContext<O>) => Promise<DialogTurnResult>;
/**
* A waterfall is a dialog that's optimized for prompting a user with a series of questions.
*
* @remarks
* Waterfalls accept a stack of functions which will be executed in sequence. Each waterfall step
* can ask a question of the user and the user's response will be passed to the next step in the
* waterfall via `step.result`. A special `step.value` object can be used to persist values between
* steps:
*
* ```JavaScript
* const { ComponentDialog, WaterfallDialog, TextPrompt, NumberPrompt } = require('botbuilder-dialogs);
*
* class FillProfileDialog extends ComponentDialog {
* constructor(dialogId) {
* super(dialogId);
*
* // Add control flow dialogs
* this.addDialog(new WaterfallDialog('start', [
* async (step) => {
* // Ask user their name
* return await step.prompt('namePrompt', `What's your name?`);
* },
* async (step) => {
* // Remember the users answer
* step.values['name'] = step.result;
*
* // Ask user their age.
* return await step.prompt('agePrompt', `Hi ${step.values['name']}. How old are you?`);
* },
* async (step) => {
* // Remember the users answer
* step.values['age'] = step.result;
*
* // End the component and return the completed profile.
* return await step.endDialog(step.values);
* }
* ]));
*
* // Add prompts
* this.addDialog(new TextPrompt('namePrompt'));
* this.addDialog(new NumberPrompt('agePrompt'))
* }
* }
* module.exports.FillProfileDialog = FillProfileDialog;
* ```
*/
export class WaterfallDialog<O extends object = {}> extends Dialog<O> {
private readonly steps: WaterfallStep<O>[];
/**
* Creates a new waterfall dialog containing the given array of steps.
*
* @remarks
* See the [addStep()](#addstep) function for details on creating a valid step function.
* @param dialogId Unique ID of the dialog within the component or set its being added to.
* @param steps (Optional) array of asynchronous waterfall step functions.
*/
constructor(dialogId: string, steps?: WaterfallStep<O>[]) {
super(dialogId);
this.steps = [];
if (steps) {
this.steps = steps.slice(0);
}
}
/**
* Gets the dialog version, composed of the ID and number of steps.
*
* @returns Dialog version, composed of the ID and number of steps.
*/
getVersion(): string {
// Simply return the id + number of steps to help detect when new steps have
// been added to a given waterfall.
return `${this.id}:${this.steps.length}`;
}
/**
* Adds a new step to the waterfall.
*
* @remarks
* All step functions should be asynchronous and return a `DialogTurnResult`. The
* `WaterfallStepContext` passed into your function derives from `DialogContext` and contains
* numerous stack manipulation methods which return a `DialogTurnResult` so you can typically
* just return the result from the DialogContext method you call.
*
* The step function itself can be either an asynchronous closure:
*
* ```JavaScript
* const helloDialog = new WaterfallDialog('hello');
*
* helloDialog.addStep(async (step) => {
* await step.context.sendActivity(`Hello World!`);
* return await step.endDialog();
* });
* ```
*
* A named async function:
*
* ```JavaScript
* async function helloWorldStep(step) {
* await step.context.sendActivity(`Hello World!`);
* return await step.endDialog();
* }
*
* helloDialog.addStep(helloWorldStep);
* ```
*
* Or a class method that's been bound to its `this` pointer:
*
* ```JavaScript
* helloDialog.addStep(this.helloWorldStep.bind(this));
* ```
* @param step Asynchronous step function to call.
* @returns Waterfall dialog for fluent calls to `addStep()`.
*/
addStep(step: WaterfallStep<O>): this {
this.steps.push(step);
return this;
}
/**
* Called when the [WaterfallDialog](xref:botbuilder-dialogs.WaterfallDialog) is started and pushed onto the dialog stack.
*
* @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation.
* @param options Optional, initial information to pass to the [Dialog](xref:botbuilder-dialogs.Dialog).
* @returns A Promise representing the asynchronous operation.
* @remarks
* If the task is successful, the result indicates whether the [Dialog](xref:botbuilder-dialogs.Dialog) is still
* active after the turn has been processed by the dialog.
*/
async beginDialog(dc: DialogContext, options?: O): Promise<DialogTurnResult> {
// Initialize waterfall state
const state: WaterfallDialogState = dc.activeDialog.state as WaterfallDialogState;
state.options = options || {};
state.values = {
instanceId: uuidv4(),
};
this.telemetryClient.trackEvent({
name: 'WaterfallStart',
properties: {
DialogId: this.id,
InstanceId: state.values['instanceId'],
},
});
telemetryTrackDialogView(this.telemetryClient, this.id);
// Run the first step
return await this.runStep(dc, 0, DialogReason.beginCalled);
}
/**
* Called when the [WaterfallDialog](xref:botbuilder-dialogs.WaterfallDialog) is _continued_, where it is the active dialog and the
* user replies with a new [Activity](xref:botframework-schema.Activity).
*
* @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation.
* @returns A Promise representing the asynchronous operation.
* @remarks
* If the task is successful, the result indicates whether the dialog is still
* active after the turn has been processed by the dialog. The result may also contain a
* return value.
*/
async continueDialog(dc: DialogContext): Promise<DialogTurnResult> {
// Don't do anything for non-message activities
if (dc.context.activity.type !== ActivityTypes.Message) {
return Dialog.EndOfTurn;
}
// Run next step with the message text as the result.
return await this.resumeDialog(dc, DialogReason.continueCalled, dc.context.activity.text);
}
/**
* Called when a child [WaterfallDialog](xref:botbuilder-dialogs.WaterfallDialog) completed its turn, returning control to this dialog.
*
* @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of the conversation.
* @param reason [Reason](xref:botbuilder-dialogs.DialogReason) why the dialog resumed.
* @param result Optional, value returned from the dialog that was called. The type
* of the value returned is dependent on the child dialog.
* @returns A Promise representing the asynchronous operation.
*/
async resumeDialog(dc: DialogContext, reason: DialogReason, result?: any): Promise<DialogTurnResult> {
// Increment step index and run step
const state: WaterfallDialogState = dc.activeDialog.state as WaterfallDialogState;
return await this.runStep(dc, state.stepIndex + 1, reason, result);
}
/**
* Called when an individual waterfall step is being executed.
*
* @remarks
* SHOULD be overridden by derived class that want to add custom logging semantics.
*
* ```JavaScript
* class LoggedWaterfallDialog extends WaterfallDialog {
* async onStep(step) {
* console.log(`Executing step ${step.index} of the "${this.id}" waterfall.`);
* return await super.onStep(step);
* }
* }
* ```
* @param step Context object for the waterfall step to execute.
* @returns A promise with the DialogTurnResult.
*/
protected async onStep(step: WaterfallStepContext<O>): Promise<DialogTurnResult> {
// Log Waterfall Step event.
const stepName = this.waterfallStepName(step.index);
const state: WaterfallDialogState = step.activeDialog.state as WaterfallDialogState;
const properties = {
DialogId: this.id,
InstanceId: state.values['instanceId'],
StepName: stepName,
};
this.telemetryClient.trackEvent({ name: 'WaterfallStep', properties: properties });
return await this.steps[step.index](step);
}
/**
* Executes a step of the [WaterfallDialog](xref:botbuilder-dialogs.WaterfallDialog).
*
* @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation.
* @param index The index of the current waterfall step to execute.
* @param reason The [Reason](xref:botbuilder-dialogs.DialogReason) the waterfall step is being executed.
* @param result Optional, result returned by a dialog called in the previous waterfall step.
* @returns A Promise that represents the work queued to execute.
*/
protected async runStep(
dc: DialogContext,
index: number,
reason: DialogReason,
result?: any
): Promise<DialogTurnResult> {
if (index < this.steps.length) {
// Update persisted step index
const state: WaterfallDialogState = dc.activeDialog.state as WaterfallDialogState;
state.stepIndex = index;
// Create step context
let nextCalled = false;
const step: WaterfallStepContext<O> = new WaterfallStepContext<O>(dc, {
index: index,
options: <O>state.options,
reason: reason,
result: result,
values: state.values,
onNext: async (stepResult?: any): Promise<DialogTurnResult<any>> => {
if (nextCalled) {
throw new Error(
`WaterfallStepContext.next(): method already called for dialog and step '${this.id}[${index}]'.`
);
}
nextCalled = true;
return await this.resumeDialog(dc, DialogReason.nextCalled, stepResult);
},
});
// Execute step
return await this.onStep(step);
} else {
// End of waterfall so just return to parent
return await dc.endDialog(result);
}
}
/**
* Called when the dialog is ending.
*
* @param context Context for the current turn of conversation.
* @param instance The instance of the current dialog.
* @param reason The reason the dialog is ending.
*/
async endDialog(context: TurnContext, instance: DialogInstance, reason: DialogReason): Promise<void> {
const state: WaterfallDialogState = instance.state as WaterfallDialogState;
const instanceId = state.values['instanceId'];
if (reason === DialogReason.endCalled) {
this.telemetryClient.trackEvent({
name: 'WaterfallComplete',
properties: {
DialogId: this.id,
InstanceId: instanceId,
},
});
} else if (reason === DialogReason.cancelCalled) {
const index = state.stepIndex;
const stepName = this.waterfallStepName(index);
this.telemetryClient.trackEvent({
name: 'WaterfallCancel',
properties: {
DialogId: this.id,
StepName: stepName,
InstanceId: instanceId,
},
});
}
}
/**
* Identifies the step name by its position index.
*
* @param index Step position
* @returns A string that identifies the step name.
*/
private waterfallStepName(index: number): string {
// Log Waterfall Step event. Each event has a distinct name to hook up
// to the Application Insights funnel.
let stepName = '';
if (this.steps[index]) {
try {
stepName = this.steps[index].name;
} finally {
if (stepName === undefined || stepName === '') {
stepName = 'Step' + (index + 1) + 'of' + this.steps.length;
}
}
}
return stepName;
}
}
/**
* @private
*/
interface WaterfallDialogState {
options: object;
stepIndex: number;
values: object;
}