botbuilder-dialogs-adaptive
Version:
Rule system for the Microsoft BotBuilder dialog system.
545 lines (483 loc) • 20.8 kB
text/typescript
/**
* @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;
}
}