UNPKG

botbuilder-dialogs-adaptive

Version:

Rule system for the Microsoft BotBuilder dialog system.

545 lines (483 loc) • 20.8 kB
/** * @module botbuilder-dialogs-adaptive */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { ActivityTemplate, StaticActivityTemplate } from '../templates'; import { ActivityTemplateConverter } from '../converters'; import { ActivityTypes, Activity, InputHints, MessageFactory } from 'botbuilder'; import { AdaptiveEvents } from '../adaptiveEvents'; import { AttachmentInput } from './attachmentInput'; import { BoolProperty, IntProperty, StringProperty, TemplateInterfaceProperty, UnknownProperty } from '../properties'; import { TelemetryLoggerConstants } from '../telemetryLoggerConstants'; import { BoolExpression, BoolExpressionConverter, ExpressionParser, IntExpression, IntExpressionConverter, StringExpression, StringExpressionConverter, ValueExpression, ValueExpressionConverter, } from 'adaptive-expressions'; import { Choice, ChoiceFactory, ChoiceFactoryOptions, Converter, ConverterFactory, Dialog, DialogConfiguration, DialogContext, DialogEvent, DialogEvents, DialogReason, DialogStateManager, DialogTurnResult, ListStyle, TemplateInterface, TurnPath, } from 'botbuilder-dialogs'; export enum InputState { missing = 'missing', unrecognized = 'unrecognized', invalid = 'invalid', valid = 'valid', } export interface InputDialogConfiguration extends DialogConfiguration { alwaysPrompt?: BoolProperty; allowInterruptions?: BoolProperty; property?: StringProperty; value?: UnknownProperty; prompt?: TemplateInterfaceProperty<Partial<Activity>, DialogStateManager>; unrecognizedPrompt?: TemplateInterfaceProperty<Partial<Activity>, DialogStateManager>; invalidPrompt?: TemplateInterfaceProperty<Partial<Activity>, DialogStateManager>; defaultValueResponse?: TemplateInterfaceProperty<Partial<Activity>, DialogStateManager>; validations?: string[]; maxTurnCount?: IntProperty; defaultValue?: UnknownProperty; disabled?: BoolProperty; } /** * Defines input dialogs. */ export abstract class InputDialog extends Dialog implements InputDialogConfiguration { static OPTIONS_PROPERTY = 'this.options'; static VALUE_PROPERTY = 'this.value'; static TURN_COUNT_PROPERTY = 'this.turnCount'; /** * A value indicating whether the input should always prompt the user regardless of there being a value or not. */ alwaysPrompt: BoolExpression; /** * Interruption policy. */ allowInterruptions: BoolExpression; /** * The value expression which the input will be bound to. */ property: StringExpression; /** * A value expression which can be used to initialize the input prompt. */ value: ValueExpression; /** * The activity to send to the user. */ prompt: TemplateInterface<Partial<Activity>, DialogStateManager>; /** * The activity template for retrying prompt. */ unrecognizedPrompt: TemplateInterface<Partial<Activity>, DialogStateManager>; /** * The activity template to send to the user whenever the value provided is invalid or not. */ invalidPrompt: TemplateInterface<Partial<Activity>, DialogStateManager>; /** * The activity template to send when maxTurnCount has be reached and the default value is used. */ defaultValueResponse: TemplateInterface<Partial<Activity>, DialogStateManager>; /** * The expressions to run to validate the input. */ validations: string[] = []; /** * Maximum number of times to ask the user for this value before the dialog gives up. */ maxTurnCount?: IntExpression; /** * The default value for the input dialog when maxTurnCount is exceeded. */ defaultValue?: ValueExpression; /** * An optional expression which if is true will disable this action. */ disabled?: BoolExpression; /** * @param property The key of the conditional selector configuration. * @returns The converter for the selector configuration. */ getConverter(property: keyof InputDialogConfiguration): Converter | ConverterFactory { switch (property) { case 'alwaysPrompt': return new BoolExpressionConverter(); case 'allowInterruptions': return new BoolExpressionConverter(); case 'property': return new StringExpressionConverter(); case 'value': return new ValueExpressionConverter(); case 'prompt': return new ActivityTemplateConverter(); case 'unrecognizedPrompt': return new ActivityTemplateConverter(); case 'invalidPrompt': return new ActivityTemplateConverter(); case 'defaultValueResponse': return new ActivityTemplateConverter(); case 'maxTurnCount': return new IntExpressionConverter(); case 'defaultValue': return new ValueExpressionConverter(); case 'disabled': return new BoolExpressionConverter(); default: return super.getConverter(property); } } /** * Initializes a new instance of the [InputDialog](xref:botbuilder-dialogs-adaptive.InputDialog) class * * @param property Optional. The value expression which the input will be bound to. * @param prompt Optional. The [Activity](xref:botframework-schema.Activity) to send to the user, * if a string is specified it will instantiates an [ActivityTemplate](xref:botbuilder-dialogs-adaptive.ActivityTemplate). */ constructor(property?: string, prompt?: Partial<Activity> | string) { super(); if (property) { this.property = new StringExpression(property); } if (prompt) { if (typeof prompt === 'string') { this.prompt = new ActivityTemplate(prompt); } else { this.prompt = new StaticActivityTemplate(prompt); } } } /** * Called when the [Dialog](xref:botbuilder-dialogs.Dialog) 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 [DialogTurnResult](xref:botbuilder-dialogs.DialogTurnResult) `Promise` representing the asynchronous operation. */ async beginDialog(dc: DialogContext, options?: any): Promise<DialogTurnResult> { if (this.disabled && this.disabled.getValue(dc.state)) { return await dc.endDialog(); } // Initialize and persist options const opts = await this.onInitializeOptions(dc, options || {}); dc.state.setValue(InputDialog.OPTIONS_PROPERTY, opts); // Initialize turn count & input dc.state.setValue(InputDialog.TURN_COUNT_PROPERTY, 0); if (this.property && this.alwaysPrompt && this.alwaysPrompt.getValue(dc.state)) { dc.state.deleteValue(this.property.getValue(dc.state)); } // Recognize input const state = this.alwaysPrompt && this.alwaysPrompt.getValue(dc.state) ? InputState.missing : await this.recognizeInput(dc, 0); if (state == InputState.valid) { // Return input const property = this.property.getValue(dc.state); const value = dc.state.getValue(InputDialog.VALUE_PROPERTY); dc.state.setValue(property, value); return await dc.endDialog(value); } else { // Prompt user dc.state.setValue(InputDialog.TURN_COUNT_PROPERTY, 1); return await this.promptUser(dc, state); } } /** * Called when the [Dialog](xref:botbuilder-dialogs.Dialog) is _continued_, where it is the active dialog and the user replies with a new activity. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @returns A [DialogTurnResult](xref:botbuilder-dialogs.DialogTurnResult) `Promise` representing the asynchronous operation. */ async continueDialog(dc: DialogContext): Promise<DialogTurnResult> { const activity = dc.context.activity; // Interrupted dialogs reprompt so we can ignore the incoming activity. const interrupted = dc.state.getValue<boolean>(TurnPath.interrupted, false); if (!interrupted && activity.type !== ActivityTypes.Message) { return Dialog.EndOfTurn; } // Are we continuing after an interruption? const turnCount = dc.state.getValue(InputDialog.TURN_COUNT_PROPERTY, 0); const state = await this.recognizeInput(dc, interrupted ? 0 : turnCount); if (state === InputState.valid) { const input = dc.state.getValue(InputDialog.VALUE_PROPERTY); if (this.property) { dc.state.setValue(this.property.getValue(dc.state), input); } return await dc.endDialog(input); } else if (!this.maxTurnCount || turnCount < this.maxTurnCount.getValue(dc.state)) { if (!interrupted) { dc.state.setValue(InputDialog.TURN_COUNT_PROPERTY, turnCount + 1); } return await this.promptUser(dc, state); } else { if (this.defaultValue) { if (this.defaultValueResponse) { const response = await this.defaultValueResponse.bind(dc, dc.state); this.telemetryClient.trackEvent({ name: TelemetryLoggerConstants.GeneratorResultEvent, properties: { template: this.defaultValueResponse, result: response || '', context: TelemetryLoggerConstants.InputDialogResultEvent, }, }); if (response != null) { await dc.context.sendActivity(response); } } const property = this.property.getValue(dc.state); const value = this.defaultValue.getValue(dc.state); dc.state.setValue(property, value); return await dc.endDialog(value); } } return await dc.endDialog(); } /** * Called when a child [Dialog](xref:botbuilder-dialogs.Dialog) completes its turn, returning control to this dialog. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param _reason [DialogReason](xref:botbuilder-dialogs.DialogReason), reason why the dialog resumed. * @param _result Optional. Value returned from the [Dialog](xref:botbuilder-dialogs.Dialog) that was called. * The type of the value returned is dependent on the child dialog. * @returns A [DialogTurnResult](xref:botbuilder-dialogs.DialogTurnResult) `Promise` representing the asynchronous operation. */ async resumeDialog(dc: DialogContext, _reason: DialogReason, _result?: any): Promise<DialogTurnResult> { // Re-send initial prompt return await this.promptUser(dc, InputState.missing); } /** * @protected * Called before an event is bubbled to its parent. * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param event [DialogEvent](xref:botbuilder-dialogs.DialogEvent), the event being raised. * @returns Whether the event is handled by the current [Dialog](xref:botbuilder-dialogs.Dialog) and further processing should stop. */ protected async onPreBubbleEvent(dc: DialogContext, event: DialogEvent): Promise<boolean> { if (event.name === DialogEvents.activityReceived && dc.context.activity.type === ActivityTypes.Message) { if (dc.parent) { await dc.parent.emitEvent(AdaptiveEvents.recognizeUtterance, dc.context.activity, false); } // should we allow interruptions let canInterrupt = true; if (this.allowInterruptions) { const allowInterruptions = this.allowInterruptions.getValue(dc.state); canInterrupt = !!allowInterruptions; } // stop bubbling if interruptions are NOT allowed return !canInterrupt; } return false; } protected abstract onRecognizeInput(dc: DialogContext): Promise<InputState>; /** * @protected * Method which processes options. * @param _dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param options Initial information to pass to the dialog. * @returns A promise representing the asynchronous operation. */ protected onInitializeOptions(_dc: DialogContext, options: any): Promise<any> { return Promise.resolve(Object.assign({}, options)); } /** * @protected * Method which renders the prompt to the user given the current input state. * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param state Dialog [InputState](xref:botbuilder-dialogs-adaptive.InputState). * @returns An [Activity](xref:botframework-schema.Activity) `Promise` representing the asynchronous operation. */ protected async onRenderPrompt(dc: DialogContext, state: InputState): Promise<Partial<Activity>> { let msg: Partial<Activity>; let template: TemplateInterface<Partial<Activity>, DialogStateManager>; switch (state) { case InputState.unrecognized: if (this.unrecognizedPrompt) { template = this.unrecognizedPrompt; msg = await this.unrecognizedPrompt.bind(dc, dc.state); } else if (this.invalidPrompt) { template = this.invalidPrompt; msg = await this.invalidPrompt.bind(dc, dc.state); } break; case InputState.invalid: if (this.invalidPrompt) { template = this.invalidPrompt; msg = await this.invalidPrompt.bind(dc, dc.state); } else if (this.unrecognizedPrompt) { template = this.unrecognizedPrompt; msg = await this.unrecognizedPrompt.bind(dc, dc.state); } break; } if (!msg) { template = this.prompt; if (!template) throw new Error('InputDialog is missing Prompt.'); msg = await this.prompt.bind(dc, dc.state); } if (msg != null && (typeof msg?.inputHint !== 'string' || !msg.inputHint)) { msg.inputHint = InputHints.ExpectingInput; } this.trackGeneratorResultEvent(dc, template, msg); return msg; } /** * @protected * Track GeneratorResultEvent telemetry event with InputDialogResultEvent context. * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param activityTemplate used to create the Activity. * @param msg The Partial [Activity](xref:botframework-schema.Activity) which will be sent. */ protected trackGeneratorResultEvent( dc: DialogContext, activityTemplate: TemplateInterface<Partial<Activity>, DialogStateManager>, msg: Partial<Activity> ): void { this.telemetryClient.trackEvent({ name: TelemetryLoggerConstants.GeneratorResultEvent, properties: { template: activityTemplate, result: msg, context: TelemetryLoggerConstants.InputDialogResultEvent, }, }); } /** * Helper function to compose an output activity containing a set of choices. * * @param prompt The prompt to append the users choices to. * @param channelId ID of the channel the prompt is being sent to. * @param choices List of choices to append. * @param style Configured style for the list of choices. * @param options (Optional) options to configure the underlying ChoiceFactory call. * @returns A bound activity ready to send to the user. */ protected appendChoices( prompt: Partial<Activity>, channelId: string, choices: Choice[], style: ListStyle, options?: ChoiceFactoryOptions ): Partial<Activity> { // Create temporary msg let msg: Partial<Activity>; const text = prompt.text || ''; switch (style) { case ListStyle.inline: msg = ChoiceFactory.inline(choices, text, null, options); break; case ListStyle.list: msg = ChoiceFactory.list(choices, text, null, options); break; case ListStyle.suggestedAction: msg = ChoiceFactory.suggestedAction(choices, text); break; case ListStyle.heroCard: msg = ChoiceFactory.heroCard(choices as Choice[], text); break; case ListStyle.none: msg = MessageFactory.text(text); break; default: msg = ChoiceFactory.forChannel(channelId, choices, text, null, options); break; } // Update clone of prompt with text, actions and attachments const clone = JSON.parse(JSON.stringify(prompt)) as Activity; clone.text = msg.text; if ( msg.suggestedActions && Array.isArray(msg.suggestedActions.actions) && msg.suggestedActions.actions.length > 0 ) { clone.suggestedActions = msg.suggestedActions; } if (msg.attachments) { clone.attachments = msg.attachments; } if (!clone.inputHint) { clone.inputHint = InputHints.ExpectingInput; } return clone; } /** * @private */ private async recognizeInput(dc: DialogContext, turnCount: number): Promise<InputState> { let input: any; if (this.property) { const property = this.property.getValue(dc.state); input = dc.state.getValue(property); dc.state.deleteValue(property); } if (!input && this.value) { const value = this.value.getValue(dc.state); input = value; } const activityProcessed = dc.state.getValue(TurnPath.activityProcessed); if (!activityProcessed && !input && turnCount > 0) { if (this instanceof AttachmentInput) { input = dc.context.activity.attachments || []; } else { input = dc.context.activity.text; // if there is no visible text AND we have a value object, then fallback to that. if (!input && dc.context.activity.value != undefined) { input = dc.context.activity.value; } } } dc.state.setValue(InputDialog.VALUE_PROPERTY, input); if (input) { const state = await this.onRecognizeInput(dc); if (state == InputState.valid) { for (let i = 0; i < this.validations.length; i++) { const validation = this.validations[i]; const exp = new ExpressionParser().parse(validation); const { value } = exp.tryEvaluate(dc.state); if (!value) { return InputState.invalid; } } dc.state.setValue(TurnPath.activityProcessed, true); return InputState.valid; } else { return state; } } else { return InputState.missing; } } /** * @private */ private async promptUser(dc: DialogContext, state: InputState): Promise<DialogTurnResult> { const prompt = await this.onRenderPrompt(dc, state); if (prompt == null) { throw new Error(`Call to onRenderPrompt() returned a null activity for state ${state}.`); } await dc.context.sendActivity(prompt); return Dialog.EndOfTurn; } }