|
| 1 | +import { PromptValidator, PromptOptions, PromptRecognizerResult } from 'botbuilder-dialogs'; |
| 2 | +import { DialogTurnResult, Dialog, DialogContext } from 'botbuilder-dialogs'; |
| 3 | +import { InputHints, TurnContext, Activity, Attachment } from 'botbuilder-core'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Settings to control the behavior of AdaptiveCardPrompt |
| 7 | + */ |
| 8 | +export interface AdaptiveCardPromptSettings { |
| 9 | + /** |
| 10 | + * An Adaptive Card. Required. |
| 11 | + * @remarks |
| 12 | + * Add the card here. Do not pass it in to `Prompt.Attachments` or it will show twice. |
| 13 | + */ |
| 14 | + card: Attachment; |
| 15 | + |
| 16 | + /** |
| 17 | + * Array of strings matching IDs of required input fields |
| 18 | + * |
| 19 | + * @example |
| 20 | + * The ID strings must exactly match those used in the Adaptive Card JSON Input IDs |
| 21 | + * For JSON: |
| 22 | + * ```json |
| 23 | + * { |
| 24 | + * "type": "Input.Text", |
| 25 | + * "id": "myCustomId", |
| 26 | + * }, |
| 27 | + *``` |
| 28 | + * You would use `"myCustomId"` if you want that to be a required input. |
| 29 | + */ |
| 30 | + requiredInputIds?: string[]; |
| 31 | + |
| 32 | + /** |
| 33 | + * ID specific to this prompt. |
| 34 | + * @requires |
| 35 | + * If used, you MUST add this promptId to every <submit>.data.promptId in your Adaptive Card. |
| 36 | + * |
| 37 | + * @remarks |
| 38 | + * Card input is only accepted if SubmitAction.data.promptId matches the promptId. |
| 39 | + * Does not change between reprompts. |
| 40 | + */ |
| 41 | + promptId?: string; |
| 42 | +} |
| 43 | + |
| 44 | +/** |
| 45 | + * Additional items to include on PromptRecognizerResult, as necessary |
| 46 | + */ |
| 47 | +export interface AdaptiveCardPromptResult { |
| 48 | + /** |
| 49 | + * The value of the user's input from the Adaptive Card. |
| 50 | + */ |
| 51 | + data?: object; |
| 52 | + /** |
| 53 | + * If not recognized.succeeded, include reason why, if known |
| 54 | + */ |
| 55 | + error?: AdaptiveCardPromptErrors; |
| 56 | + /** |
| 57 | + * Include which requiredIds were not included with user input |
| 58 | + */ |
| 59 | + missingIds?: string[]; |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * AdaptiveCardPrompt catches certain common errors that are passed to validator, if present |
| 64 | + * This allows developers to handle these specific errors as they choose |
| 65 | + * These are given in validator context.recognized.value.error |
| 66 | + */ |
| 67 | +export enum AdaptiveCardPromptErrors { |
| 68 | + /** |
| 69 | + * No known user errors. |
| 70 | + */ |
| 71 | + none, |
| 72 | + /** |
| 73 | + * Error presented if developer specifies AdaptiveCardPromptSettings.promptId, |
| 74 | + * but user submits adaptive card input on a card where the ID does not match. |
| 75 | + * This error will also be present if developer AdaptiveCardPromptSettings.promptId, |
| 76 | + * but forgets to add the promptId to every <submit>.data.promptId in your Adaptive Card. |
| 77 | + */ |
| 78 | + userInputDoesNotMatchCardId, |
| 79 | + /** |
| 80 | + * Error presented if developer specifies AdaptiveCardPromptSettings.requiredIds, |
| 81 | + * but user does not submit input for all required input id's on the adaptive card |
| 82 | + */ |
| 83 | + missingRequiredIds, |
| 84 | + /** |
| 85 | + * Error presented if user enters plain text instead of using Adaptive Card's input fields |
| 86 | + */ |
| 87 | + userUsedTextInput |
| 88 | +} |
| 89 | + |
| 90 | +/** |
| 91 | + * Waits for Adaptive Card Input to be received. |
| 92 | + * |
| 93 | + * @remarks |
| 94 | + * This prompt is similar to ActivityPrompt but provides features specific to Adaptive Cards: |
| 95 | + * * Optionally allow specified input fields to be required |
| 96 | + * * Optionally ensures input is only valid if it comes from the appropriate card (not one shown previous to prompt) |
| 97 | + * * Provides ability to handle variety of common user errors related to Adaptive Cards |
| 98 | + * DO NOT USE WITH CHANNELS THAT DON'T SUPPORT ADAPTIVE CARDS |
| 99 | + */ |
| 100 | +export class AdaptiveCardPrompt extends Dialog { |
| 101 | + private validator: PromptValidator<object>; |
| 102 | + private requiredInputIds: string[]; |
| 103 | + private promptId: string; |
| 104 | + private card: Attachment; |
| 105 | + |
| 106 | + /** |
| 107 | + * Creates a new AdaptiveCardPrompt instance |
| 108 | + * @param dialogId Unique ID of the dialog within its parent `DialogSet` or `ComponentDialog`. |
| 109 | + * @param settings (optional) Additional options for AdaptiveCardPrompt behavior |
| 110 | + * @param validator (optional) Validator that will be called each time a new activity is received. Validator should handle error messages on failures. |
| 111 | + */ |
| 112 | + public constructor(dialogId: string, settings: AdaptiveCardPromptSettings, validator?: PromptValidator<object>) { |
| 113 | + super(dialogId); |
| 114 | + if (!settings || !settings.card) { |
| 115 | + throw new Error('AdaptiveCardPrompt requires a card in `AdaptiveCardPromptSettings.card`'); |
| 116 | + } |
| 117 | + |
| 118 | + this.validator = validator; |
| 119 | + |
| 120 | + this.requiredInputIds = settings.requiredInputIds || []; |
| 121 | + |
| 122 | + this.throwIfNotAdaptiveCard(settings.card); |
| 123 | + this.card = settings.card; |
| 124 | + |
| 125 | + // Don't allow promptId to be something falsy |
| 126 | + if (settings.promptId !== undefined && !settings.promptId) { |
| 127 | + throw new Error('AdaptiveCardPromptSettings.promptId cannot be a falsy string'); |
| 128 | + } |
| 129 | + this.promptId = settings.promptId; |
| 130 | + } |
| 131 | + |
| 132 | + public async beginDialog(dc: DialogContext, options: PromptOptions): Promise<DialogTurnResult> { |
| 133 | + // Initialize prompt state |
| 134 | + const state: AdaptiveCardPromptState = dc.activeDialog.state; |
| 135 | + state.options = options; |
| 136 | + state.state = {}; |
| 137 | + |
| 138 | + // Send initial prompt |
| 139 | + await this.onPrompt(dc.context, state.state, state.options, false); |
| 140 | + |
| 141 | + return Dialog.EndOfTurn; |
| 142 | + } |
| 143 | + |
| 144 | + protected async onPrompt(context: TurnContext, state: object, options: PromptOptions, isRetry: boolean): Promise<void> { |
| 145 | + // Since card is passed in via AdaptiveCardPromptSettings, PromptOptions may not be used. |
| 146 | + // Ensure we're working with RetryPrompt, as applicable |
| 147 | + let prompt: Partial<Activity> | string; |
| 148 | + if (options) { |
| 149 | + prompt = isRetry && options.retryPrompt ? options.retryPrompt || {} : options.prompt || {}; |
| 150 | + } else { |
| 151 | + prompt = {}; |
| 152 | + } |
| 153 | + |
| 154 | + // Clone the correct prompt so that we don't affect the one saved in state |
| 155 | + let clonedPrompt = JSON.parse(JSON.stringify(prompt)); |
| 156 | + |
| 157 | + // Create a prompt if user didn't pass it in through PromptOptions or if they passed in a string |
| 158 | + if (!clonedPrompt || typeof(prompt) != 'object' || Object.keys(prompt).length === 0) { |
| 159 | + clonedPrompt = { |
| 160 | + text: typeof(prompt) === 'string' ? prompt : undefined, |
| 161 | + }; |
| 162 | + } |
| 163 | + |
| 164 | + // Depending on how the prompt is called, when compiled to JS, activity attachments may be on prompt or options |
| 165 | + const existingAttachments = clonedPrompt.attachments || options ? options['attachments'] : []; |
| 166 | + // Add Adaptive Card as last attachment (user input should go last), keeping any others |
| 167 | + clonedPrompt.attachments = existingAttachments ? [...existingAttachments, this.card] : [this.card]; |
| 168 | + |
| 169 | + await context.sendActivity(clonedPrompt, undefined, InputHints.ExpectingInput); |
| 170 | + } |
| 171 | + |
| 172 | + // Override continueDialog so that we can catch activity.value (which is ignored, by default) |
| 173 | + public async continueDialog(dc: DialogContext): Promise<DialogTurnResult> { |
| 174 | + // Perform base recognition |
| 175 | + const state: AdaptiveCardPromptState = dc.activeDialog.state; |
| 176 | + const recognized: PromptRecognizerResult<AdaptiveCardPromptResult> = await this.onRecognize(dc.context); |
| 177 | + |
| 178 | + if (state.state['attemptCount'] === undefined) { |
| 179 | + state.state['attemptCount'] = 1; |
| 180 | + } else { |
| 181 | + state.state['attemptCount']++; |
| 182 | + } |
| 183 | + |
| 184 | + let isValid = false; |
| 185 | + if (this.validator) { |
| 186 | + isValid = await this.validator({ |
| 187 | + context: dc.context, |
| 188 | + recognized: recognized, |
| 189 | + state: state.state, |
| 190 | + options: state.options, |
| 191 | + attemptCount: state.state['attemptCount'] |
| 192 | + }); |
| 193 | + } else if (recognized.succeeded) { |
| 194 | + isValid = true; |
| 195 | + } |
| 196 | + |
| 197 | + // Return recognized value or re-prompt |
| 198 | + if (isValid) { |
| 199 | + return await dc.endDialog(recognized.value); |
| 200 | + } else { |
| 201 | + // Re-prompt |
| 202 | + if (!dc.context.responded) { |
| 203 | + await this.onPrompt(dc.context, state.state, state.options, true); |
| 204 | + } |
| 205 | + |
| 206 | + return Dialog.EndOfTurn; |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + protected async onRecognize(context: TurnContext): Promise<PromptRecognizerResult<AdaptiveCardPromptResult>> { |
| 211 | + // Ignore user input that doesn't come from adaptive card |
| 212 | + if (!context.activity.text && context.activity.value) { |
| 213 | + const data = context.activity.value; |
| 214 | + // Validate it comes from the correct card - This is only a worry while the prompt/dialog has not ended |
| 215 | + if (this.promptId && context.activity.value && context.activity.value['promptId'] != this.promptId) { |
| 216 | + return { succeeded: false, value: { data, error: AdaptiveCardPromptErrors.userInputDoesNotMatchCardId }}; |
| 217 | + } |
| 218 | + // Check for required input data, if specified in AdaptiveCardPromptSettings |
| 219 | + const missingIds = []; |
| 220 | + this.requiredInputIds.forEach((id): void => { |
| 221 | + if (!context.activity.value[id] || !context.activity.value[id].trim()) { |
| 222 | + missingIds.push(id); |
| 223 | + } |
| 224 | + }); |
| 225 | + // User did not submit inputs that were required |
| 226 | + if (missingIds.length > 0) { |
| 227 | + return { succeeded: false, value: { data, missingIds, error: AdaptiveCardPromptErrors.missingRequiredIds}}; |
| 228 | + } |
| 229 | + return { succeeded: true, value: { data } }; |
| 230 | + } else { |
| 231 | + // User used text input instead of card input |
| 232 | + return { succeeded: false, value: { error: AdaptiveCardPromptErrors.userUsedTextInput }}; |
| 233 | + } |
| 234 | + } |
| 235 | + |
| 236 | + private throwIfNotAdaptiveCard(cardAttachment: Attachment): void { |
| 237 | + const adaptiveCardType = 'application/vnd.microsoft.card.adaptive'; |
| 238 | + |
| 239 | + if (!cardAttachment || !cardAttachment.content) { |
| 240 | + throw new Error('No Adaptive Card provided. Include in the constructor or PromptOptions.prompt.attachments'); |
| 241 | + } else if (!cardAttachment.contentType || cardAttachment.contentType !== adaptiveCardType) { |
| 242 | + throw new Error(`Attachment is not a valid Adaptive Card.\n`+ |
| 243 | + `Ensure card.contentType is '${ adaptiveCardType }'\n`+ |
| 244 | + `and card.content contains the card json`); |
| 245 | + } |
| 246 | + } |
| 247 | +} |
| 248 | + |
| 249 | +/** |
| 250 | + * @private |
| 251 | + */ |
| 252 | +interface AdaptiveCardPromptState { |
| 253 | + state: object; |
| 254 | + options: PromptOptions; |
| 255 | +} |
0 commit comments