Handle user interruptions

APPLIES TO: yesSDK v4 no SDK v3

Handling interruptions is an important aspect of a robust bot. Users will not always follow your defined conversation flow, step by step. They may try to ask a question in the middle of the process, or simply want to cancel it instead of completing it. In this topic, we will explore some common ways to handle user interruptions in your bot.

Prerequisites

About this sample

The sample used in this article models a flight booking bot that uses dialogs to get flight information from the user. At any time during the conversation with the bot, the user can issue help or cancel commands to cause an interruption. There are two types of interruptions we handle here:

  • Turn level: Bypass processing at the turn level but leave the dialog on the stack with the information that was provided. In the next turn, continue from where we left off.
  • Dialog level: Cancel the processing completely, so the bot can start all over again.

Define and implement the interruption logic

First, we define and implement the help and cancel interruptions.

To use dialogs, install the Microsoft.Bot.Builder.Dialogs NuGet package.

Dialogs\CancelAndHelpDialog.cs

We begin by implementing the CancelAndHelpDialog class to handle user interruptions.

public class CancelAndHelpDialog : ComponentDialog

In the CancelAndHelpDialog class the OnBeginDialogAsync and OnContinueDialogAsync methods call the InerruptAsync method to check if the user has interrupted the normal flow or not. If the flow is interrupted, base class methods are called; otherwise, the return value from the InterruptAsync is returned.

protected override async Task<DialogTurnResult> OnBeginDialogAsync(DialogContext innerDc, object options, CancellationToken cancellationToken = default(CancellationToken))
{
    var result = await InterruptAsync(innerDc, cancellationToken);
    if (result != null)
    {
        return result;
    }

    return await base.OnBeginDialogAsync(innerDc, options, cancellationToken);
}

If the user types "help", the InterrupAsync method sends a message and then calls DialogTurnResult (DialogTurnStatus.Waiting) to indicate that the dialog on top is waiting for a response from the user. In this way, the conversation flow is interrupted for a turn only, and in the next turn we continue from where we left off.

If the user types "cancel", it calls CancelAllDialogsAsync on its inner dialog context, which clears its dialog stack and causes it to exit with a cancelled status and no result value. To the MainDialog (shown later on), it will appear that the booking dialog ended and returned null, similar to when the user chooses not to confirm their booking.

private async Task<DialogTurnResult> InterruptAsync(DialogContext innerDc, CancellationToken cancellationToken)
{
    if (innerDc.Context.Activity.Type == ActivityTypes.Message)
    {
        var text = innerDc.Context.Activity.Text.ToLowerInvariant();

        switch (text)
        {
            case "help":
            case "?":
                await innerDc.Context.SendActivityAsync($"Show Help...", cancellationToken: cancellationToken);
                return new DialogTurnResult(DialogTurnStatus.Waiting);

            case "cancel":
            case "quit":
                await innerDc.Context.SendActivityAsync($"Cancelling", cancellationToken: cancellationToken);
                return await innerDc.CancelAllDialogsAsync();
        }
    }

    return null;
}

Check for interruptions each turn

Now that we've covered how the interrupt handling class works, let's step back and look at what happens when the bot receives a new message from the user.

Dialogs\MainDialog.cs

As the new message activity arrives, the bot runs the MainDialog. The MainDialog prompts the user for what it can help with. And then it starts the BookingDialog in the MainDialog.ActStepAsync method, with a call to BeginDialogAsync as shown below.

private async Task<DialogTurnResult> ActStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.)
    var bookingDetails = stepContext.Result != null
            ?
        await LuisHelper.ExecuteLuisQuery(Configuration, Logger, stepContext.Context, cancellationToken)
            :
        new BookingDetails();

    // In this sample we only have a single Intent we are concerned with. However, typically a scenario
    // will have multiple different Intents each corresponding to starting a different child Dialog.

    // Run the BookingDialog giving it whatever details we have from the LUIS call, it will fill out the remainder.
    return await stepContext.BeginDialogAsync(nameof(BookingDialog), bookingDetails, cancellationToken);
}

Next, in the FinalStepAsync method of the MainDialog class, the booking dialog ended and the booking is considered to be complete or cancelled.

private async Task<DialogTurnResult> FinalStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // If the child dialog ("BookingDialog") was cancelled or the user failed to confirm, the Result here will be null.
    if (stepContext.Result != null)
    {
        var result = (BookingDetails)stepContext.Result;

        // Now we have all the booking details call the booking service.

        // If the call to the booking service was successful tell the user.

        var timeProperty = new TimexProperty(result.TravelDate);
        var travelDateMsg = timeProperty.ToNaturalLanguage(DateTime.Now);
        var msg = $"I have you booked to {result.Destination} from {result.Origin} on {travelDateMsg}";
        await stepContext.Context.SendActivityAsync(MessageFactory.Text(msg), cancellationToken);
    }
    else
    {
        await stepContext.Context.SendActivityAsync(MessageFactory.Text("Thank you."), cancellationToken);
    }
    return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
}

The code in BookingDialog is not shown here as it is not directly related to interruption handling. It is used to prompt users for booking details. You can find that code in Dialogs\BookingDialogs.cs.

Handle unexpected errors

Next, we deal with any unhandled exceptions that might occur.

AdapterWithErrorHandler.cs

In our sample, the adapter's OnTurnError handler receives any exceptions thrown by your bot's turn logic. If there is an exception thrown, the handler deletes the conversation state for the current conversation to prevent the bot from getting stuck in a error-loop caused by being in a bad state.

public class AdapterWithErrorHandler : BotFrameworkHttpAdapter
{
    public AdapterWithErrorHandler(ICredentialProvider credentialProvider, ILogger<BotFrameworkHttpAdapter> logger, ConversationState conversationState = null)
        : base(credentialProvider)
    {
        OnTurnError = async (turnContext, exception) =>
        {
            // Log any leaked exception from the application.
            logger.LogError($"Exception caught : {exception.Message}");

            // Send a catch-all apology to the user.
            await turnContext.SendActivityAsync("Sorry, it looks like something went wrong.");

            if (conversationState != null)
            {
                try
                {
                    // Delete the conversationState for the current conversation to prevent the
                    // bot from getting stuck in a error-loop caused by being in a bad state.
                    // ConversationState should be thought of as similar to "cookie-state" in a Web pages.
                    await conversationState.DeleteAsync(turnContext);
                }
                catch (Exception e)
                {
                    logger.LogError($"Exception caught on attempting to Delete ConversationState : {e.Message}");
                }
            }
        };
    }
}

Register services

Startup.cs

Finally, in Startup.cs, the bot is created as a transient, and on every turn, a new instance of the bot is created.


// Create the bot as a transient. In this case the ASP Controller is expecting an IBot.

For reference, here are the class definitions that are used in the call to create the bot above.

public class MainDialog : ComponentDialog
public class DialogAndWelcomeBot<T> : DialogBot<T> where T : Dialog
public class DialogBot<T> : ActivityHandler where T : Dialog

To test the bot

  1. If you have not done so already, install the Bot Framework Emulator.
  2. Run the sample locally on your machine.
  3. Start the emulator, connect to your bot, and send messages as shown below.

Additional information

  • The authentication sample shows how to handle logout which uses similar pattern shown here for handling interruptions.

  • You should send a default response instead of doing nothing and leaving the user wondering what is going on. The default response should tell the user what commands the bot understands so the user can get back on track.

  • At any point in the turn, the turn context's responded property indicates whether the bot has sent a message to the user this turn. Before the turn ends, your bot should send some message to the user, even if it is a simple acknowledgement of their input.