UNPKG

botbuilder-dialogs

Version:

A dialog stack based conversation manager for Microsoft BotBuilder.

320 lines (299 loc) 13.1 kB
/** * @module botbuilder-dialogs */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { ActionTypes, Activity, CardAction, CardFactory, InputHints, MessageFactory, TurnContext, } from 'botbuilder-core'; import * as channel from './channel'; import { Choice } from './findChoices'; /** * Additional options used to tweak the formatting of choice lists. */ export interface ChoiceFactoryOptions { /** * (Optional) character used to separate individual choices when there are more than 2 choices. * The default value is `", "`. */ inlineSeparator?: string; /** * (Optional) separator inserted between the choices when their are only 2 choices. The default * value is `" or "`. */ inlineOr?: string; /** * (Optional) separator inserted between the last 2 choices when their are more than 2 choices. * The default value is `", or "`. */ inlineOrMore?: string; /** * (Optional) if `true`, inline and list style choices will be prefixed with the index of the * choice as in "1. choice". If `false`, the list style will use a bulleted list instead. The * default value is `true`. */ includeNumbers?: boolean; } /** * A set of utility functions to assist with the formatting a 'message' activity containing a list * of choices. * * @remarks * This example shows creating a message containing a list of choices that has been conditionally * formatted based on the capabilities of the underlying channel: * * ```JavaScript * const { ChoiceFactory } = require('botbuilder-choices'); * * const message = ChoiceFactory.forChannel(context, ['red', 'green', 'blue'], `Pick a color.`); * await context.sendActivity(message); * ``` */ export class ChoiceFactory { /** * Returns a 'message' activity containing a list of choices that has been automatically * formatted based on the capabilities of a given channel. * * @remarks * The algorithm prefers to format the supplied list of choices as suggested actions but can * decide to use a text based list if suggested actions aren't natively supported by the * channel, there are too many choices for the channel to display, or the title of any choice * is too long. * * If the algorithm decides to use a list it will use an inline list if there are 3 or less * choices and all have short titles. Otherwise, a numbered list is used. * * ```JavaScript * const message = ChoiceFactory.forChannel(context, [ * { value: 'red', action: { type: 'imBack', title: 'The Red Pill', value: 'red pill' } }, * { value: 'blue', action: { type: 'imBack', title: 'The Blue Pill', value: 'blue pill' } }, * ], `Which do you choose?`); * await context.sendActivity(message); * ``` * @param channelOrContext Channel ID or context object for the current turn of conversation. * @param choices List of choices to render. * @param text (Optional) text of the message. * @param speak (Optional) SSML to speak for the message. * @param options (Optional) formatting options to use when rendering as a list. * @returns The created message activity. */ static forChannel( channelOrContext: string | TurnContext, choices: (string | Choice)[], text?: string, speak?: string, options?: ChoiceFactoryOptions ): Partial<Activity> { const channelId: string = typeof channelOrContext === 'string' ? channelOrContext : channel.getChannelId(channelOrContext); // Normalize choices const list: Choice[] = ChoiceFactory.toChoices(choices); // Find maximum title length let maxTitleLength = 0; list.forEach((choice: Choice) => { const l: number = choice.action && choice.action.title ? choice.action.title.length : choice.value.length; if (l > maxTitleLength) { maxTitleLength = l; } }); // Determine list style const supportsSuggestedActions: boolean = channel.supportsSuggestedActions(channelId, choices.length); const maxActionTitleLength: number = channel.maxActionTitleLength(channelId); const supportsCardActions = channel.supportsCardActions(channelId, choices.length); const longTitles: boolean = maxTitleLength > maxActionTitleLength; if (!longTitles && !supportsSuggestedActions && supportsCardActions) { // SuggestedActions is the preferred approach, but for channels that don't // support them (e.g. Teams, Cortana) we should use a HeroCard with CardActions return ChoiceFactory.heroCard(list, text, speak); } else if (!longTitles && supportsSuggestedActions) { // We always prefer showing choices using suggested actions. If the titles are too long, however, // we'll have to show them as a text list. return ChoiceFactory.suggestedAction(list, text, speak); } else if (!longTitles && choices.length <= 3) { // If the titles are short and there are 3 or less choices we'll use an inline list. return ChoiceFactory.inline(list, text, speak, options); } else { // Show a numbered list. return ChoiceFactory.list(list, text, speak, options); } } /** * Creates a message [Activity](xref:botframework-schema.Activity) that includes a [Choice](xref:botbuilder-dialogs.Choice) list that have been added as `HeroCard`'s. * * @param choices Optional. The [Choice](xref:botbuilder-dialogs.Choice) list to add. * @param text Optional. Text of the message. * @param speak Optional. SSML text to be spoken by the bot on a speech-enabled channel. * @returns An [Activity](xref:botframework-schema.Activity) with choices as `HeroCard` with buttons. */ static heroCard(choices: (string | Choice)[] = [], text = '', speak = ''): Activity { const buttons: CardAction[] = ChoiceFactory.toChoices(choices).map( (choice) => ({ title: choice.value, type: ActionTypes.ImBack, value: choice.value, } as CardAction) ); const attachment = CardFactory.heroCard(undefined, text, undefined, buttons); return MessageFactory.attachment(attachment, undefined, speak, InputHints.ExpectingInput) as Activity; } /** * Returns a 'message' activity containing a list of choices that has been formatted as an * inline list. * * @remarks * This example generates a message text of "Pick a color: (1. red, 2. green, or 3. blue)": * * ```JavaScript * const message = ChoiceFactory.inline(['red', 'green', 'blue'], `Pick a color:`); * await context.sendActivity(message); * ``` * @param choices List of choices to render. * @param text (Optional) text of the message. * @param speak (Optional) SSML to speak for the message. * @param options (Optional) formatting options to tweak rendering of list. * @returns The created message activity. */ static inline( choices: (string | Choice)[], text?: string, speak?: string, options?: ChoiceFactoryOptions ): Partial<Activity> { const opt: ChoiceFactoryOptions = { inlineSeparator: ', ', inlineOr: ' or ', inlineOrMore: ', or ', includeNumbers: true, ...options, } as ChoiceFactoryOptions; // Format list of choices let connector = ''; let txt: string = text || ''; txt += ' '; ChoiceFactory.toChoices(choices).forEach((choice: any, index: number) => { const title: string = choice.action && choice.action.title ? choice.action.title : choice.value; // tslint:disable-next-line:prefer-template txt += `${connector}${opt.includeNumbers ? '(' + (index + 1).toString() + ') ' : ''}${title}`; if (index === choices.length - 2) { connector = (index === 0 ? opt.inlineOr : opt.inlineOrMore) || ''; } else { connector = opt.inlineSeparator || ''; } }); txt += ''; // Return activity with choices as an inline list. return MessageFactory.text(txt, speak, InputHints.ExpectingInput); } /** * Returns a 'message' activity containing a list of choices that has been formatted as an * numbered or bulleted list. * * @remarks * This example generates a message with the choices presented as a numbered list: * * ```JavaScript * const message = ChoiceFactory.list(['red', 'green', 'blue'], `Pick a color:`); * await context.sendActivity(message); * ``` * @param choices List of choices to render. * @param text (Optional) text of the message. * @param speak (Optional) SSML to speak for the message. * @param options (Optional) formatting options to tweak rendering of list. * @returns The created message activity. */ static list( choices: (string | Choice)[], text?: string, speak?: string, options?: ChoiceFactoryOptions ): Partial<Activity> { const opt: ChoiceFactoryOptions = { includeNumbers: true, ...options, } as ChoiceFactoryOptions; // Format list of choices let connector = ''; let txt: string = text || ''; txt += '\n\n '; ChoiceFactory.toChoices(choices).forEach((choice: any, index: number) => { const title: string = choice.action && choice.action.title ? choice.action.title : choice.value; // tslint:disable-next-line:prefer-template txt += `${connector}${opt.includeNumbers ? (index + 1).toString() + '. ' : '- '}${title}`; connector = '\n '; }); // Return activity with choices as a numbered list. return MessageFactory.text(txt, speak, InputHints.ExpectingInput); } /** * Returns a 'message' activity containing a list of choices that have been added as suggested * actions. * * @remarks * This example generates a message with the choices presented as suggested action buttons: * * ```JavaScript * const message = ChoiceFactory.suggestedAction(['red', 'green', 'blue'], `Pick a color:`); * await context.sendActivity(message); * ``` * @param choices List of choices to add. * @param text (Optional) text of the message. * @param speak (Optional) SSML to speak for the message. * @returns An activity with choices as suggested actions. */ static suggestedAction(choices: (string | Choice)[], text?: string, speak?: string): Partial<Activity> { // Map choices to actions const actions: CardAction[] = ChoiceFactory.toChoices(choices).map<CardAction>((choice: Choice) => { if (choice.action) { return choice.action; } else { return { type: ActionTypes.ImBack, value: choice.value, title: choice.value, channelData: undefined }; } }); // Return activity with choices as suggested actions return MessageFactory.suggestedActions(actions, text, speak, InputHints.ExpectingInput); } /** * Takes a mixed list of `string` and `Choice` based choices and returns them as a `Choice[]`. * * @remarks * This example converts a simple array of string based choices to a properly formated `Choice[]`. * * If the `Choice` has a `Partial<CardAction>` for `Choice.action`, `.toChoices()` will attempt to * fill the `Choice.action`. * * ```JavaScript * const choices = ChoiceFactory.toChoices(['red', 'green', 'blue']); * ``` * @param choices List of choices to add. * @returns A list of choices. */ static toChoices(choices: (string | Choice)[] | undefined): Choice[] { return (choices || []) .map((choice: Choice) => (typeof choice === 'string' ? { value: choice } : choice)) .map((choice: Choice) => { const action: CardAction = choice.action; // If the choice.action is incomplete, populate the missing fields. if (action) { action.type = action.type ? action.type : ActionTypes.ImBack; if (!action.value && action.title) { action.value = action.title; } else if (!action.title && action.value) { action.title = action.value; } else if (!action.title && !action.value) { action.title = action.value = choice.value; } } return choice; }) .filter((choice: Choice) => choice); } }