Skip to content

Commit ec71c69

Browse files
author
mdrichardson
committed
added AdaptiveCardPrompt
1 parent 0800976 commit ec71c69

4 files changed

Lines changed: 1090 additions & 0 deletions

File tree

libraries/botbuilder-dialog-prompts/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,68 @@ The `SocialMediaPrompt` will extract one of the following types based on which `
6969
## GUID
7070
7171
The `GUIDPrompt` will extract a GUID from a message from the user.
72+
73+
## Adaptive Card
74+
75+
* Includes validation for specified required input fields
76+
* Displays custom message if user replies via text and not card input
77+
* Ensures input is only valid if it comes from the appropriate card (not one shown previous to prompt)
78+
79+
### Usage
80+
81+
```js
82+
// Load an adaptive card
83+
const cardJson = require('./adaptiveCard.json');
84+
const card = CardFactory.adaptiveCard(cardJson);
85+
86+
// Configure settings - All optional
87+
const promptSettings = {
88+
card: card,
89+
inputFailMessage: 'Please fill out the adaptive card',
90+
requiredInputIds: [
91+
'inputA',
92+
'inputB',
93+
],
94+
missingRequiredInputsMessage: 'The following inputs are required',
95+
attemptsBeforeCardRedsiplayed: 5,
96+
promptId: 'myCustomId'
97+
}
98+
99+
// Initialize the prompt
100+
const adaptiveCardPrompt = new AdaptiveCardPrompt('adaptiveCardPrompt', null, promptSettings);
101+
102+
// Add the prompt to your dialogs
103+
dialogSet.add(adaptiveCardPrompt);
104+
105+
// Call the prompt
106+
return await stepContext.prompt('adaptiveCardPrompt');
107+
108+
// Use the result
109+
const result = stepContext.result;
110+
```
111+
112+
### Adaptive Cards
113+
114+
Card authors describe their content as a simple JSON object. That content can then be rendered natively inside a host application, automatically adapting to the look and feel of the host. For example, Contoso Bot can author an Adaptive Card through the Bot Framework, and when delivered to Cortana, it will look and feel like a Cortana card. When that same payload is sent to Microsoft Teams, it will look and feel like Microsoft Teams. As more host apps start to support Adaptive Cards, that same payload will automatically light up inside these applications, yet still feel entirely native to the app. Users win because everything feels familiar. Host apps win because they control the user experience. Card authors win because their content gets broader reach without any additional work.
115+
116+
The Bot Framework provides support for Adaptive Cards. See the following to learn more about Adaptive Cards.
117+
118+
- [Adaptive card](http://adaptivecards.io)
119+
- [Send an Adaptive card](https://docs.microsoft.com/en-us/azure/bot-service/nodejs/bot-builder-nodejs-send-rich-cards?view=azure-bot-service-3.0&viewFallbackFrom=azure-bot-service-4.0#send-an-adaptive-card)
120+
121+
### Getting Input Data From Adaptive Cards
122+
123+
In a `TextPrompt`, the user response is returned in the `Activity.Text` property, which only accepts strings. Because Adaptive Cards can contain multiple inputs, the user response is sent as a JSON object in `Activity.Value`, like so:
124+
125+
```json
126+
const activity = {
127+
[...]
128+
"value": {
129+
"inputA": "response A",
130+
"inputB": "response B",
131+
[...etc]
132+
}
133+
}
134+
```
135+
136+
Because of this, it can be a little difficult to gather user input using an Adaptive Card within a dialog. The `AdaptiveCardPrompt` allows you to do so easily and returns the JSON object user response in `stepContext.result`.
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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

Comments
 (0)