botbuilder-dialogs
Version:
A dialog stack based conversation manager for Microsoft BotBuilder.
238 lines (217 loc) • 8.46 kB
text/typescript
/**
* @module botbuilder-dialogs
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import {
BotTelemetryClient,
StatePropertyAccessor,
TurnContext,
StringUtils,
NullTelemetryClient,
} from 'botbuilder-core';
import { Dialog } from './dialog';
import { DialogContext, DialogState } from './dialogContext';
export interface DialogDependencies {
/**
* Returns a dialogs child dialog dependencies so they can be added to a containers dialog set.
*/
getDependencies(): Dialog[];
}
/**
* A related set of dialogs that can all call each other.
*
* @remarks
* The constructor for the dialog set should be passed a state property that will be used to
* persist the dialog stack for the set:
*
* ```JavaScript
* const { ConversationState, MemoryStorage, ActivityTypes } = require('botbuilder');
* const { DialogSet, Dialog, DialogTurnStatus } = require('botbuilder-dialogs');
*
* const convoState = new ConversationState(new MemoryStorage());
* const dialogState = convoState.createProperty('dialogState');
* const dialogs = new DialogSet(dialogState);
* ```
*
* The bot can add dialogs or prompts to the set using the [add()](#add) method:
*
* ```JavaScript
* class GreetingDialog extends Dialog {
* async beginDialog(dc, options) {
* await dc.context.sendActivity(`Hi! I'm a bot.`);
* return await dc.endDialog();
* }
* }
*
* dialogs.add(new GreetingDialog('greeting'));
* ```
*
* To interact with the sets dialogs you can call [createContext()](#createcontext) with the
* current `TurnContext`. That will create a `DialogContext` that can be used to start or continue
* execution of the sets dialogs:
*
* ```JavaScript
* // Create DialogContext for the current turn
* const dc = await dialogs.createContext(turnContext);
*
* // Try to continue executing an active multi-turn dialog
* const result = await dc.continueDialog();
*
* // Send greeting if no other dialogs active
* if (result.status == DialogTurnStatus.empty && dc.context.activity.type == ActivityTypes.Message) {
* await dc.beginDialog('greeting');
* }
* ```
*/
export class DialogSet {
private readonly dialogs: { [id: string]: Dialog } = {};
private readonly dialogState: StatePropertyAccessor<DialogState>;
private _telemetryClient: BotTelemetryClient;
private _version: string;
/**
* Creates a new DialogSet instance.
*
* @remarks
* If the `dialogState` property is not passed in, calls to [createContext()](#createcontext)
* will return an error. You will need to create a `DialogContext` for the set manually and
* pass in your own state object for persisting the sets dialog stack:
*
* ```JavaScript
* const dc = new DialogContext(dialogs, turnContext, state);
* ```
* @param dialogState (Optional) state property used to persist the sets dialog stack.
*/
constructor(dialogState?: StatePropertyAccessor<DialogState>) {
this.dialogState = dialogState;
}
/**
* Returns a 32-bit hash of the all the `Dialog.version` values in the set.
*
* @returns A version that will change when any of the child dialogs version changes.
* @remarks
* This hash is persisted to state storage and used to detect changes to a dialog set.
*/
getVersion(): string {
if (!this._version) {
let versions = '';
for (const id in this.dialogs) {
const v = this.dialogs[id].getVersion();
if (v) {
versions += `|${v}`;
}
}
this._version = StringUtils.hash(versions);
}
return this._version;
}
/**
* Adds a new dialog or prompt to the set.
*
* @remarks
* If the `Dialog.id` being added already exists in the set, the dialogs id will be updated to
* include a suffix which makes it unique. So adding 2 dialogs named "duplicate" to the set
* would result in the first one having an id of "duplicate" and the second one having an id
* of "duplicate2".
* @param dialog The dialog or prompt to add.
* If a telemetryClient is present on the dialog set, it will be added to each dialog.
* @returns The dialog set after the operation is complete.
*/
add<T extends Dialog>(dialog: T): this {
if (!(dialog instanceof Dialog)) {
throw new Error('DialogSet.add(): Invalid dialog being added.');
}
// Ensure new version hash is computed
this._version = undefined;
// Ensure dialogs ID is unique.
if (Object.prototype.hasOwnProperty.call(this.dialogs, dialog.id)) {
// If we are trying to add the same exact instance, it's not a name collision.
// No operation required since the instance is already in the dialog set.
if (this.dialogs[dialog.id] === dialog) {
return this;
}
// If we are adding a new dialog with a conflicting name, add a suffix to avoid
// dialog name collisions.
let nextSuffix = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const suffixId = dialog.id + nextSuffix.toString();
if (!Object.prototype.hasOwnProperty.call(this.dialogs, suffixId)) {
dialog.id = suffixId;
break;
} else {
nextSuffix++;
}
}
}
// If a telemetry client has already been set on this dialogSet, also set it on new dialogs as they are added.
if (this._telemetryClient) {
dialog.telemetryClient = this._telemetryClient;
}
// Save dialog reference
this.dialogs[dialog.id] = dialog;
// Automatically add any child dependencies the dialog might have
if (typeof ((dialog as any) as DialogDependencies).getDependencies == 'function') {
((dialog as any) as DialogDependencies).getDependencies().forEach((child: Dialog): void => {
this.add(child);
});
}
return this;
}
/**
* Creates a dialog context which can be used to work with the dialogs in the set.
*
* @param context Context for the current turn of conversation with the user.
* @returns A promise representing the asynchronous operation.
*/
async createContext(context: TurnContext): Promise<DialogContext> {
if (!this.dialogState) {
throw new Error(
'DialogSet.createContext(): the dialog set was not bound to a stateProperty when constructed.'
);
}
const state: DialogState = await this.dialogState.get(context, { dialogStack: [] } as DialogState);
return new DialogContext(this, context, state);
}
/**
* Finds a dialog that was previously added to the set using [add()](#add).
*
* @remarks
* This example finds a dialog named "greeting":
*
* ```JavaScript
* const dialog = dialogs.find('greeting');
* ```
* @param dialogId ID of the dialog or prompt to lookup.
* @returns The dialog if found; otherwise undefined.
*/
find(dialogId: string): Dialog | undefined {
return Object.prototype.hasOwnProperty.call(this.dialogs, dialogId) ? this.dialogs[dialogId] : undefined;
}
/**
* Set the telemetry client for this dialog set and apply it to all current dialogs.
*
* @returns The [BotTelemetryClient](xref:botbuilder.BotTelemetryClient) to use for logging.
*/
get telemetryClient(): BotTelemetryClient {
return this._telemetryClient;
}
/**
* Set the telemetry client for this dialog set and apply it to all current dialogs.
* Future dialogs added to the set will also inherit this client.
*/
set telemetryClient(client: BotTelemetryClient) {
this._telemetryClient = client ?? new NullTelemetryClient();
Object.values(this.dialogs).forEach((dialog) => (dialog.telemetryClient = this._telemetryClient));
}
/**
* Gets the Dialogs of the set.
*
* @returns {Dialog} An array of [Dialog](xref:botbuilder-dialogs.Dialog).
*/
getDialogs(): Dialog[] {
return Object.values(this.dialogs);
}
}