Search code examples
botframework

TextPrompt reprompts after interruption


TL;DR

When my bot is waiting in a Prompt (e.g. TextPrompt) and another dialog ends (because user input triggered an interrupt action such as 'help', which started an help dialog that just outputs help text), the OnPromptAsync method of that Prompt is called and prompts the Prompts text again. I don't want this. I want the Prompt dialog to wait for user input after the help dialog has ended.

Detailed

I have a bot that prompts something using TextPrompt and then waits for the user to reply. I've implemented user interruptions as described here to catch requests for help. If the user typed in 'help' the bot should output some help text (in the ExampleDialog, see below) and then again wait for user inputs.

The MainDialog

    public class MainDialog : ComponentDialog
    {
        public MainDialog() : base("Main")
        {
            AddDialog(new TextPrompt(nameof(TextPrompt)));
            AddDialog(new ExampleDialog(nameof(ExampleDialog)));
            AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[]
            {
                PromptStep,
                EvaluationStep
            }));

            InitialDialogId = nameof(WaterfallDialog);
        }

        protected override async Task<DialogTurnResult> OnContinueDialogAsync(DialogContext innerDc, CancellationToken cancellationToken = default)
        {
            if (!string.IsNullOrEmpty(innerDc.Context.Activity.Text))
            {
                // Check for interruptions
                var result = await InterruptAsync(innerDc, cancellationToken);
                if (result != null)
                {
                    return result;
                }
            }

            return await base.OnContinueDialogAsync(innerDc, cancellationToken);
        }

        private async Task<DialogTurnResult> InterruptAsync(DialogContext innerDc, CancellationToken cancellationToken)
        {
            if (innerDc.Context.Activity.Type == ActivityTypes.Message)
            {
                string text = innerDc.Context.Activity.Text;
                
                // Catch request for help
                if (text == "help")
                {
                    await innerDc.BeginDialogAsync(nameof(ExampleDialog), null, cancellationToken);
                    return Dialog.EndOfTurn;
                }
            }

            return null;
        }

        private async Task<DialogTurnResult> PromptStep(WaterfallStepContext stepContext, CancellationToken cancellationToken)
        {
            return await stepContext.PromptAsync(nameof(TextPrompt), new PromptOptions()
            {
                Prompt = MessageFactory.Text("Please enter some text. Type 'help' if you need some examples."),
            });
        }


        private async Task<DialogTurnResult> EvaluationStep(WaterfallStepContext stepContext, CancellationToken cancellationToken)
        {
            await stepContext.Context.SendActivityAsync(MessageFactory.Text("You typed: " + stepContext.Result as string));
            return await stepContext.EndDialogAsync(null, cancellationToken);
        }

    }

The ExampleDialog


    public class ExampleDialog : ComponentDialog
    {
        public ExampleDialog(string dialogId) : base(dialogId)
        {
            AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[]
            {
                ExampleStep
            }));

            InitialDialogId = nameof(WaterfallDialog);
        }

        private async Task<DialogTurnResult> ExampleStep(WaterfallStepContext stepContext, CancellationToken cancellationToken)
        {
            await stepContext.Context.SendActivityAsync(MessageFactory.Text("Example: bla bla"));
            return await stepContext.NextAsync(null, cancellationToken);
            // ExampleDialog ends here
        }
    }

The problem is, that when the ExampleDialog ends after outputting the help text, the TextPrompt resumes and again prompts its message. This results in this conversation:

Bot:  Hello world!
Bot:  Please enter some text. Type ‘help’ if you need some examples.
User: help
Bot:  Example: bla bla
Bot:  Please enter some text. Type ‘help’ if you need some examples.

I don't want this last line to be reprompted by the bot. How can I fix this?

Thanks in advance

EDIT 1: A (not really satisfying) workaround

I've found a solution which does not really satisfy me. I've created my own TextPrompt class called MyTextPrompt and overwritten ResumeDialogAsync:

    public class MyTextPrompt : TextPrompt
    {
        public MyTextPrompt(string id) : base(id)
        {

        }

        public override async Task<DialogTurnResult> ResumeDialogAsync(DialogContext dc, DialogReason reason, object result = null, CancellationToken cancellationToken = default)
        {
            return Dialog.EndOfTurn;
        }
    }

In MainDialog I simply replaced TextPrompt with MyTextPrompt in the constructor

    AddDialog(new MyTextPrompt(nameof(MyTextPrompt)));

and use the correct dialog id in the PromptStep

    private async Task<DialogTurnResult> PromptStep(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        return await stepContext.PromptAsync(nameof(MyTextPrompt), new PromptOptions()
        {
            Prompt = MessageFactory.Text("Please enter some text. Type 'help' if you need some examples."),
        });
    }

The result is this conversation:

Bot:  Hello world!
Bot:  Please enter some text. Type ‘help’ if you need some examples.
User: help
Bot:  Example: bla bla
/* bot now waits at this point of the conversation */
User: bla bla
Bot:  You typed: bla bla

Ok, great. This is what I wanted, isn't it?

Yes it is, but there are some drawbacks:

  1. This has to be done for every single type of prompt dialog.
  2. If you've already overwritten the ResumeDialogAsync method in a custom prompt class, in some way you have to keep track what causes the call of ResumeDailogAsync.

How can this be solved in an elegant way?


Solution

  • I can think of three major possibilities for getting a prompt to not reprompt when it's resumed.

    1. Nullify the prompt

    The only way to actually stop the built-in prompts from reprompting when they're resumed is to set the Prompt property to null. I mentioned in the comments that there has been some question about whether resuming a prompt should count as retrying a prompt, and you happen to be in luck because resuming a prompt does not count as a retry. This means you can leave the RetryPrompt property intact so that it still automatically reprompts on invalid input like normal. All you have to take care of is making the initial prompt show up without a prompt property, and that's easy enough because you can just send a message that's worded like a prompt before you call the actual prompt.

    2. Use middleware

    Even if the prompt tries to reprompt, it does so through the turn context and so the activity it tries to send to the user will pass through the middleware pipeline. This means you can catch the activity in custom middleware and prevent it from actually getting sent to the user.

    In order for your custom middleware to recognize the prompt activity as something it should catch, the activity will need some kind of tag in one of its properties that the middleware is programmed to look for. Activities have a lot of hidden metadata that you could use for something like this, like ChannelData or Entities or Value or Properties. You won't want the middleware to block the activity all the time because most of the time you'll actually want the prompt to be displayed, so there also needs to be a switch in the turn state that you can activate in the help dialog and that the middleware can check for.

    In order for your custom middleware to actually stop the activity from going through, you can either short-circuit the pipeline or you can replace the activity with something that won't show up in the conversation.

    3. Create a wrapper class

    This possibility is based on the idea you already came up with, but instead of deriving from every existing prompt you can just make one class that can wrap any prompt. I won't make the whole implementation for you but the class signature might look something like this:

    class NonResumingPrompt<TPrompt, TValue> : Prompt<TValue> where TPrompt : Prompt<TValue>
    

    You could instantiate NonResumingPrompt like this for example:

    new NonResumingPrompt<TextPrompt, string>()
    

    Then the code could internally create an instance of TPrompt and define NonResumingPrompt.OnPromptAsync, NonResumingPrompt.OnRecognizeAsync, and NonResumingPrompt.OnPreBubbleEventAsync to call the corresponding methods in the wrapped TPrompt instance.

    This would solve the problem of having to make your own version of every prompt, but it would require you to override a few more methods than in your solution. I should mention that making your own version of each prompt is not so different from what happened when the adaptive dialogs library was made. New "adaptive" versions (called inputs) of each prompt were made, and a lot of code ended up getting duplicated unfortunately. You can feel free to try out adaptive dialogs if you like.