diff --git a/libraries/botbuilder-dialogs/src/prompts/prompt.ts b/libraries/botbuilder-dialogs/src/prompts/prompt.ts index 1c982a91e8..915b4c86c1 100644 --- a/libraries/botbuilder-dialogs/src/prompts/prompt.ts +++ b/libraries/botbuilder-dialogs/src/prompts/prompt.ts @@ -172,6 +172,30 @@ export abstract class Prompt extends Dialog { */ protected constructor(dialogId: string, private validator?: PromptValidator) { super(dialogId); + + if (!validator) validator = async (prompt) => prompt.recognized.succeeded; + + this.validator = async (prompt) => { + const context = prompt.context; + + const hasRespondedBeforeValidator = context.responded; + // Making the validator *only* consider as "responded" if responded has been set during a turn + // @ts-expect-error + context._respondedRef.responded = false; + try { + const result = await validator(prompt); + const shouldReprompt = context.turnState.get(Prompt.repromptOnRetry); + + // Set reprompt retry status based on whether the bot responded or not while on a validator + Prompt.setRepromptRetry(context, this.shouldRepromptOnRetry(context)); + return result; + } finally { + if (hasRespondedBeforeValidator) prompt.context.responded = true; + } + } + + + } /** @@ -206,6 +230,38 @@ export abstract class Prompt extends Dialog { return Dialog.EndOfTurn; } + /** + * By default the bot re-prompts on retries if there has not been any message sent to this user in this turn + * + * However, that might be undesirable. You can set here whether you want it to reprompt or not. + * + * To reset to the default status, you can set it back to undefined or null. + * @param context + * @param shouldReprompt Defaults to {true}. Set to undefined/null to allow the default behaviour. + */ + static setRepromptRetry(context:TurnContext, shouldReprompt: boolean | undefined | null = true) { + context.turnState.set(this.repromptOnRetry, shouldReprompt); + } + + /** + * Determined whether to re-prompt on retry, by default it checks whether the context.responded is false or not + * + * You can change that behaviour by either (a) extending this function on your base class or (b) using Prompt.setRepromptRetry + * @param context + * @returns + */ + protected shouldRepromptOnRetry(context: TurnContext): boolean { + const shouldReprompt: null | undefined | boolean = context.turnState.get(Prompt.repromptOnRetry); + if (shouldReprompt == null) return !context.responded; + return shouldReprompt; + } + + + /** + * Optional symbol to be used to *force* the context whether to reprompt the retry or not based on this flag + */ + private static repromptOnRetry = Symbol('repromptOnRetry'); + /** * Called when a prompt dialog is the active dialog and the user replied with a new activity. * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. @@ -238,28 +294,26 @@ export abstract class Prompt extends Dialog { // Validate the return value let isValid = false; - if (this.validator) { - if (state.state['attemptCount'] === undefined) { - state.state['attemptCount'] = 0; - } - isValid = await this.validator({ - context: dc.context, - recognized: recognized, - state: state.state, - options: state.options, - attemptCount: ++state.state['attemptCount'], - }); - } else if (recognized.succeeded) { - isValid = true; + + if (state.state['attemptCount'] === undefined) { + state.state['attemptCount'] = 0; } + isValid = await this.validator({ + context: dc.context, + recognized: recognized, + state: state.state, + options: state.options, + attemptCount: ++state.state['attemptCount'], + }); + // Return recognized value or re-prompt if (isValid) { return await dc.endDialog(recognized.value); } else { - if (!dc.context.responded) { + if (this.shouldRepromptOnRetry(dc.context)) { await this.onPrompt(dc.context, state.state, state.options, true); - } + } return Dialog.EndOfTurn; } diff --git a/libraries/botbuilder-dialogs/tests/textPrompt.test.js b/libraries/botbuilder-dialogs/tests/textPrompt.test.js index 8ee7008327..a8b3cab6a5 100644 --- a/libraries/botbuilder-dialogs/tests/textPrompt.test.js +++ b/libraries/botbuilder-dialogs/tests/textPrompt.test.js @@ -1,5 +1,5 @@ const { ActivityTypes, ConversationState, MemoryStorage, TestAdapter } = require('botbuilder-core'); -const { DialogSet, TextPrompt, DialogTurnStatus } = require('../'); +const { DialogSet, TextPrompt, DialogTurnStatus, Prompt } = require('../'); const assert = require('assert'); const lineReader = require('line-reader'); const path = require('path'); @@ -191,5 +191,156 @@ describe('TextPrompt', function() { .send('test') .assertReply('test') .startTest(); + }); + + it('should send retryPrompt when a message is sent before validation.', async function() { + + const alwaysSentMessage = 'Working on your answer, hold on a second.' + const adapter = new TestAdapter(async (turnContext) => { + await turnContext.sendActivity(alwaysSentMessage); + const dc = await dialogs.createContext(turnContext); + + const results = await dc.continueDialog(); + if (results.status === DialogTurnStatus.empty) { + await dc.prompt('prompt', { prompt: 'Please say something.', retryPrompt: 'Text is required.' }); + } else if (results.status === DialogTurnStatus.complete) { + const reply = results.result; + await turnContext.sendActivity(reply); + } + await convoState.saveChanges(turnContext); + }); + + const convoState = new ConversationState(new MemoryStorage()); + + const dialogState = convoState.createProperty('dialogState'); + const dialogs = new DialogSet(dialogState); + dialogs.add(new TextPrompt('prompt')); + + await adapter.send('Hello') + .assertReply(alwaysSentMessage) + .assertReply('Please say something.') + .send(invalidMessage) + .assertReply(alwaysSentMessage) + .assertReply('Text is required.') + .send('test') + .assertReply(alwaysSentMessage) + .assertReply('test') + .startTest(); + + }); + + it('should not send retryPrompt if the validator replies, even when a message is sent before.', async function() { + + const alwaysSentMessage = 'Working on your answer, hold on a second.' + const adapter = new TestAdapter(async (turnContext) => { + await turnContext.sendActivity(alwaysSentMessage); + const dc = await dialogs.createContext(turnContext); + + const results = await dc.continueDialog(); + if (results.status === DialogTurnStatus.empty) { + await dc.prompt('prompt', { prompt: 'Please say something.', retryPrompt: 'Text is required.' }); + } else if (results.status === DialogTurnStatus.complete) { + const reply = results.result; + await turnContext.sendActivity(reply); + } + await convoState.saveChanges(turnContext); + }); + + const convoState = new ConversationState(new MemoryStorage()); + + const dialogState = convoState.createProperty('dialogState'); + const dialogs = new DialogSet(dialogState); + dialogs.add(new TextPrompt('prompt', async (prompt) => { + if (!prompt.recognized.succeeded) { + await prompt.context.sendActivity('dont send an empty text.') + } + return prompt.recognized.succeeded; + })); + + await adapter.send('Hello') + .assertReply(alwaysSentMessage) + .assertReply('Please say something.') + .send(invalidMessage) + .assertReply(alwaysSentMessage) + .assertReply('dont send an empty text.') + .send('test') + .assertReply(alwaysSentMessage) + .assertReply('test') + .startTest(); + + }); + + it('should not send retryPrompt if repromptRetry status is false.', async function() { + + const alwaysSentMessage = 'Working on your answer, hold on a second.' + const adapter = new TestAdapter(async (turnContext) => { + await turnContext.sendActivity(alwaysSentMessage); + Prompt.setRepromptRetry(turnContext, false); + const dc = await dialogs.createContext(turnContext); + + const results = await dc.continueDialog(); + if (results.status === DialogTurnStatus.empty) { + await dc.prompt('prompt', { prompt: 'Please say something.', retryPrompt: 'Text is required.' }); + } else if (results.status === DialogTurnStatus.complete) { + const reply = results.result; + await turnContext.sendActivity(reply); + } + await convoState.saveChanges(turnContext); + }); + + const convoState = new ConversationState(new MemoryStorage()); + + const dialogState = convoState.createProperty('dialogState'); + const dialogs = new DialogSet(dialogState); + dialogs.add(new TextPrompt('prompt')); + + await adapter.send('Hello') + .assertReply(alwaysSentMessage) + .assertReply('Please say something.') + .send(invalidMessage) + .assertReply(alwaysSentMessage) + .send('test') + .assertReply(alwaysSentMessage) + .assertReply('test') + .startTest(); + + }); + + it('should send retryPrompt even if validator replies when repromptRetry status is true', async function() { + const adapter = new TestAdapter(async (turnContext) => { + const dc = await dialogs.createContext(turnContext); + + const results = await dc.continueDialog(); + if (results.status === DialogTurnStatus.empty) { + await dc.prompt('prompt', { prompt: 'Please say something.', retryPrompt: 'Text is required.' }); + } else if (results.status === DialogTurnStatus.complete) { + const reply = results.result; + await turnContext.sendActivity(reply); + } + await convoState.saveChanges(turnContext); + }); + + const convoState = new ConversationState(new MemoryStorage()); + + const dialogState = convoState.createProperty('dialogState'); + const dialogs = new DialogSet(dialogState); + dialogs.add(new TextPrompt('prompt', async (prompt) => { + assert(prompt); + Prompt.setRepromptRetry(prompt.context, true) + const valid = prompt.recognized.value.length >= 3; + if (!valid) { + await prompt.context.sendActivity('too short'); + } + return valid; + })); + + await adapter.send('Hello') + .assertReply('Please say something.') + .send('i') + .assertReply('too short') + .assertReply('Text is required.') + .send('test') + .assertReply('test') + .startTest(); }); });