Search code examples
botframework

Bot Framework: How do I stop a waterfall dialog which is waiting for a prompt response from re-prompting after an interruption?


Intended Functionality

I have created a skill which is meant to allow a user to start a conversation with a live person. In order to accomplish this they are shown a prompt with 1 choice: "Connect". When they click "Connect" the skill moves to the next waterfall step which executes code to initiate the live person conversation. The problem I am running into is that if they do not hit "Connect" & instead type something else then whatever response they get is followed by the same "Connect" prompt. This is a normal situation which could occur. They may not want to talk to a live person, they may want to continue talking to the bot.

The "Connect" button is really just meant to execute the code to start the conversation. I don't know of any other way to do this other than putting it in a waterfall dialog & having the next step be the code I want execute.

Reproducing

I have created a similar example in this repo: LoopingPrompt Repo

In my repo there is a bot called "ComposerMultiSkillDialog"

It contains a few skills & a few intents.

enter image description here

Running the project

In order to run this project you need Visual Studio & the Bot Composer

Open the solution file: MultiSkillComposerBot.sln

Ensure that Bot.Skills is the startup project.

Hit F5 to run the project. It should start on port 36352.

Then open the Bot Composer to the folder: ComposerMultiSkillDialog

Start the Bot then use "Open Web Chat"

Type "loop" to see the prompt with the choice "Handle Prompt"

Then type "call skill 1" to cause an interruption.

Notice that after "call skill 1" completes the prompt with "Handle Prompt" appears again.

This will continue to happen as the user says more things until they click the "Handle Prompt" button.

Goal

The goal is to prevent this behavior & only have the "Handle Prompt" appear the first time. If there is an interruption then it would not appear again.

enter image description here enter image description here

Attempts To Resolve

What I have tried so far is:

  • Find a way to add a "Max Turn Count" which is an available option within the Bot Composer. This is not an available option within stepContext.PromptAsync though as far as I can tell enter image description here

  • I debugged through the code when the 2nd "Handle Prompt" appears. The code goes through the controller, into the constructor of LoopingPromptDialog & into the AddAdditionalDialogs method. I was hoping that it would get into PromptStepAsync where I could put a counter of some kind to detect if this had already been reached & stop it but PromptStepAsync is never called. I'm not exactly sure how the "Handle Prompt" is actually being sent back to the chat again.

  • I would not be able to have the code which is called after "Handle Prompt" be its own intent. I would not want to start a chat with a live person right away if the user typed something which triggered that intent. So cannot have a "Connect" intent which starts the chat with a live person.

  • I tried to check if an action can be linked to a hero card response somehow which would execute the code but have not been able to find anything like that.

Any help is appreciated! Thank you for your time.


Solution

  • I was able to find a workable solution to this problem. The details of the changes can be found in this Commit

    I had to override the default functionality of the Declarative type "Microsoft.BeginSkill". Specifically, I overrode the "RepromptDialogAsync" method.

    Steps I took:

    Add BeginSkillNonRePrompting

    This class overrides the default behavior of the declarative type "BeginSkill" which is the $kind = Microsoft.BeginSkill within the .dialog files. It checks configuration of the skill to determine if a reprompt should be allowed & prevents it if not.

    using System;
    using System.Runtime.CompilerServices;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Bot.Builder;
    using Microsoft.Bot.Builder.Dialogs;
    using Microsoft.Bot.Builder.Dialogs.Adaptive.Actions;
    using Microsoft.Bot.Builder.Skills;
    using Microsoft.Extensions.Configuration;
    
    namespace Microsoft.BotFramework.Composer.WebApp.Dialogs
    {
        public class BeginSkillNonRePrompting : BeginSkill
        {
            private readonly string _dialogOptionsStateKey = $"{typeof(BeginSkill).FullName}.DialogOptionsData";
    
            public BeginSkillNonRePrompting([CallerFilePath] string callerPath = "", [CallerLineNumber] int callerLine = 0)
                : base(callerPath, callerLine)
            {
            }
    
            public override Task RepromptDialogAsync(ITurnContext turnContext, DialogInstance instance, CancellationToken cancellationToken = default)
            {
                //get the skill endpoint - this contains the skill name from configuration - we need this to get the related skill:{SkillName}:disableReprompt value
                var skillEndpoint = ((SkillDialogOptions)instance.State["Microsoft.Bot.Builder.Dialogs.Adaptive.Actions.BeginSkill.DialogOptionsData"]).Skill.SkillEndpoint;
    
                //get IConfiguration so that we can use it to determine if this skill should be reprompting or not
                var config = turnContext.TurnState.Get<IConfiguration>() ?? throw new NullReferenceException("Unable to locate IConfiguration in HostContext");
    
                //the id looks like this:
                //BeginSkillNonRePrompting['=settings.skill['testLoopingPrompt'].msAppId','']
                //parse out the skill name
                var startingSearchValue = "=settings.skill['";
                var startOfSkillName = instance.Id.IndexOf(startingSearchValue) + startingSearchValue.Length;
                var endingOfSkillName = instance.Id.Substring(startOfSkillName).IndexOf("']");
                var skillName = instance.Id.Substring(startOfSkillName, endingOfSkillName);
    
                //if we do not want to reprompt call EndDialogAsync instead of RepromptDialogAsync
                if (Convert.ToBoolean(config[$"skill:{skillName}:disableReprompt"]))
                {
                    //this does not actually appear to remove the dialog from the stack
                    //if I call "Call Skill 1" again then this line is still hit
                    //so it seems like the dialog hangs around but shouldn't actually show to the user
                    //not sure how to resolve this but it's not really an issue as far as I can tell
                    return EndDialogAsync(turnContext, instance, DialogReason.EndCalled, cancellationToken);
                }
                else
                {
                    LoadDialogOptions(turnContext, instance);
                    return base.RepromptDialogAsync(turnContext, instance, cancellationToken);
                }
            }
    
            private void LoadDialogOptions(ITurnContext context, DialogInstance instance)
            {
                var dialogOptions = (SkillDialogOptions)instance.State[_dialogOptionsStateKey];
    
                DialogOptions.BotId = dialogOptions.BotId;
                DialogOptions.SkillHostEndpoint = dialogOptions.SkillHostEndpoint;
                DialogOptions.ConversationIdFactory = context.TurnState.Get<SkillConversationIdFactoryBase>() ?? throw new NullReferenceException("Unable to locate SkillConversationIdFactoryBase in HostContext");
                DialogOptions.SkillClient = context.TurnState.Get<BotFrameworkClient>() ?? throw new NullReferenceException("Unable to locate BotFrameworkClient in HostContext");
                DialogOptions.ConversationState = context.TurnState.Get<ConversationState>() ?? throw new NullReferenceException($"Unable to get an instance of {nameof(ConversationState)} from TurnState.");
                DialogOptions.ConnectionName = dialogOptions.ConnectionName;
    
                // Set the skill to call
                DialogOptions.Skill = dialogOptions.Skill;
            }
        }
    }
    

    Add AdaptiveComponentRegistrationCustom

    This class allows AdaptiveBotComponentCustom to be added to ComponentRegistration in Startup.cs

    using System;
    using System.Linq;
    using Microsoft.Bot.Builder.Dialogs.Adaptive;
    using Microsoft.Bot.Builder.Dialogs.Adaptive.Actions;
    using Microsoft.Bot.Builder.Dialogs.Declarative;
    using Microsoft.Bot.Builder.Dialogs.Declarative.Obsolete;
    using Microsoft.BotFramework.Composer.WebApp.Dialogs;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    
    namespace Microsoft.BotFramework.Composer.WebApp.Components
    {
        /// <summary>
        /// <see cref="ComponentRegistration"/> implementation for adaptive components.
        /// </summary>
        [Obsolete("Use `AdaptiveBotComponent` instead.")]
        public class AdaptiveComponentRegistrationCustom : DeclarativeComponentRegistrationBridge<AdaptiveBotComponentCustom>
        {
        }
    }
    

    Add AdaptiveBotComponentCustom

    This class overrides the default AdaptiveBotComponent behavior & replaces the dependency injection in DeclarativeType with DeclarativeType

    using System;
    using System.Linq;
    using Microsoft.Bot.Builder.Dialogs.Adaptive;
    using Microsoft.Bot.Builder.Dialogs.Adaptive.Actions;
    using Microsoft.Bot.Builder.Dialogs.Declarative;
    using Microsoft.Bot.Builder.Dialogs.Declarative.Obsolete;
    using Microsoft.BotFramework.Composer.WebApp.Dialogs;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    
    namespace Microsoft.BotFramework.Composer.WebApp.Components
    {
        public class AdaptiveBotComponentCustom : AdaptiveBotComponent
        {
            public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
            {
                base.ConfigureServices(services, configuration);
    
                //this is the replace the DeclarativeType BeginSkill so that we can handle the reprompting situation differently
                //in some cases we don't want to reprompt when there has been an interruption
                //this will be configured in appSettings.json under skill:{SkillName}:disableReprompt
                var beginSkillDI = services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DeclarativeType<BeginSkill>));
                if (beginSkillDI != null)
                {
                    services.Remove(beginSkillDI);
                }
    
                //adding the cust class which will deal with use the configuration setting skill:{SkillName}:disableReprompt
                services.AddSingleton<DeclarativeType>(sp => new DeclarativeType<BeginSkillNonRePrompting>(BeginSkill.Kind));
            }
        }
    }
    

    Add appSettings.json

    Adding the setting which will trigger functionality to prevent a re prompt

    {
      "skill": {
        "testLoopingPrompt": {
          "disableReprompt": "true"
        }
      }
    }
    

    Modify Program.cs

    Added a line to load the appSettings.json file which contains the setting which will prevent the reprompt for a specific skill.

    builder.AddJsonFile("appSettings.json");
    

    Modify Startup.cs

    Change section where ComponentRegistration is configured. This will use the new BeginSkillNonRePrompting class instead of the normal BeginSkill class.

    ComponentRegistration.Add(new AdaptiveComponentRegistration());
    

    to

    ComponentRegistration.Add(new AdaptiveComponentRegistrationCustom());
    

    Remaining Issues

    Although this solves the issue on the user's side the "EndDialogAsync" within the "RepromptDialogAsync" doesn't actually get the dialog off of the stack. It still continues to get into the "RepromptDialogAsync" method though it is always going to do nothing from the user's perspective. I just have short dialogs right now. I'll be getting into longer dialogs with if / switch / multiple skills / multiple user prompts shortly so will need to watch out for any issues related to this change.