Search code examples
node.jsbotframework

How to find ActiveDialog (waterfall-step) in context after replacedialog in waterfall dialog


Contextual help in prompts

I need to implement contextual help for a chatbot. My strategy is to use the active prompt as an index for a table with help-textlines. I am struggling with finding the active prompt after a stepContext.replaceDialog() in a waterfall dialog.

I will use the Compex Dialog sample as example.

In reviewSelectionDialog below is a prompt called CHOICE_PROMPT. This is the prompt in which I would like to add contextual help. If the user enters help, the helptext should be shown that is about that prompt.

In the same dialog is a loopstep. Based on a user decision, the dialog is repeated (looped) by the replaceDialog() method.

ReviewSelectionDialog is extended with CancelAndHelpDialog. As a result I am able to check for and act on any user interrupts like 'help'.

In CancelAndHelpDialog I need the active prompt when help was entered by the user so I am able to show relevant help. (CHOICE_PROMPT in this example).

My question

In the first pass of ReviewSelectionDialog, after sending 'help', I am able to get the active prompt in the CancelAndHelpDialog via innerDc.activeDialog.id. But after the stepContext.replaceDialog() in loopStep and sending 'help' again in the CHOICE_PROMPT, innerDc.activeDialog.id shows REVIEW_SELECTION_DIALOG. Where do I find the active prompt after a replace_dialog()?

ReviewSelectionDialog

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

const { ChoicePrompt, WaterfallDialog } = require('botbuilder-dialogs');

const REVIEW_SELECTION_DIALOG = 'REVIEW_SELECTION_DIALOG';
const { CancelAndHelpDialog } = require('./cancelAndHelpDialog');

const CHOICE_PROMPT = 'CHOICE_PROMPT';
const WATERFALL_DIALOG = 'WATERFALL_DIALOG';

class ReviewSelectionDialog extends CancelAndHelpDialog {
    constructor() {
        super(REVIEW_SELECTION_DIALOG);

        // Define a "done" response for the company selection prompt.
        this.doneOption = 'done';

        // Define value names for values tracked inside the dialogs.
        this.companiesSelected = 'value-companiesSelected';

        // Define the company choices for the company selection prompt.
        this.companyOptions = ['Adatum Corporation', 'Contoso Suites', 'Graphic Design Institute', 'Wide World Importers'];

        this.addDialog(new ChoicePrompt(CHOICE_PROMPT));
        this.addDialog(new WaterfallDialog(WATERFALL_DIALOG, [
            this.selectionStep.bind(this),
            this.loopStep.bind(this)
        ]));

        this.initialDialogId = WATERFALL_DIALOG;
    }

    async selectionStep(stepContext) {
        // Continue using the same selection list, if any, from the previous iteration of this dialog.
        const list = Array.isArray(stepContext.options) ? stepContext.options : [];
        stepContext.values[this.companiesSelected] = list;

        // Create a prompt message.
        let message = '';
        if (list.length === 0) {
            message = `Please choose a company to review, or \`${ this.doneOption }\` to finish.`;
        } else {
            message = `You have selected **${ list[0] }**. You can review an additional company, or choose \`${ this.doneOption }\` to finish.`;
        }

        // Create the list of options to choose from.
        const options = list.length > 0
            ? this.companyOptions.filter(function(item) { return item !== list[0]; })
            : this.companyOptions.slice();
        options.push(this.doneOption);

        // Prompt the user for a choice.
        return await stepContext.prompt(CHOICE_PROMPT, {
            prompt: message,
            retryPrompt: 'Please choose an option from the list.',
            choices: options
        });
    }

    async loopStep(stepContext) {
        // Retrieve their selection list, the choice they made, and whether they chose to finish.
        const list = stepContext.values[this.companiesSelected];
        const choice = stepContext.result;
        const done = choice.value === this.doneOption;

        if (!done) {
            // If they chose a company, add it to the list.
            list.push(choice.value);
        }

        if (done || list.length > 1) {
            // If they're done, exit and return their list.
            return await stepContext.endDialog(list);
        } else {
            // Otherwise, repeat this dialog, passing in the list from this iteration.
            return await stepContext.replaceDialog(REVIEW_SELECTION_DIALOG, list);
        }
    }
}

module.exports.ReviewSelectionDialog = ReviewSelectionDialog;
module.exports.REVIEW_SELECTION_DIALOG = REVIEW_SELECTION_DIALOG;


CancelAndHelpDialog

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

const { InputHints } = require('botbuilder');
const { ComponentDialog, DialogTurnStatus } = require('botbuilder-dialogs');

/**
 * This base class watches for common phrases like "help" and "cancel" and takes action on them
 * BEFORE they reach the normal bot logic.
 */
class CancelAndHelpDialog extends ComponentDialog {
    async onContinueDialog(innerDc) {
        const result = await this.interrupt(innerDc);
        if (result) {
            return result;
        }
        return await super.onContinueDialog(innerDc);
    }

    async interrupt(innerDc) {
        if (innerDc.context.activity.text) {
            const text = innerDc.context.activity.text.toLowerCase();

            switch (text) {
            case 'help':
            case '?': {
                const helpMessageText = 'Show help about prompt: ' + innerDc.activeDialog.id;
                await innerDc.context.sendActivity(helpMessageText, helpMessageText, InputHints.ExpectingInput);
                return { status: DialogTurnStatus.waiting };
            }
            case 'cancel':
            case 'quit': {
                const cancelMessageText = 'Cancelling...';
                await innerDc.context.sendActivity(cancelMessageText, cancelMessageText, InputHints.IgnoringInput);
                return await innerDc.cancelAllDialogs();
            }
            }
        }
    }
}

module.exports.CancelAndHelpDialog = CancelAndHelpDialog;



Solution

  • I want to thank you for using the sample code because you've actually revealed a bug that I've reported here: https://github.com/microsoft/BotBuilder-Samples/issues/2457

    The underlying problem here is that the dialogs library has two ways of stacking dialogs. Ordinarily, one dialog gets stacked on top of another dialog like this:

    [  CHOICE_PROMPT   ]
    [ WATERFALL_DIALOG ]
    

    However, component dialogs form a nested dialog stack that stacks inward rather than further upward:

            [ REVIEW_SELECTION_DIALOG  ]
        [        TOP_LEVEL_DIALOG          ]
    [               MAIN_DIALOG                ]
    

    Since not all dialogs are component dialogs, the two ways combine to look like this:

                [  CHOICE_PROMPT   ]
                [ WATERFALL_DIALOG ]
            [ REVIEW_SELECTION_DIALOG  ]
        [        TOP_LEVEL_DIALOG          ]
    [               MAIN_DIALOG                ]
    

    I want to note that the order of this stack is not necessarily what you'd expect if you're used to writing hierarchical lists that look like this (with the most recently added item on the bottom):

    • MAIN_DIALOG
      • TOP_LEVEL_DIALOG
        • REVIEW_SELECTION_DIALOG
          • WATERFALL_DIALOG
          • CHOICE_PROMPT

    Some people might not consider the second way of stacking actual stacking, since it's a parent-child relationship and not a stack. The reason I'm calling it a second way of stacking here is because of the conceptual similarity to a dialog stack. When you design your bot's dialogs, you have a choice about whether you want each new dialog to be added on top of the existing dialog stack or be nested as a child in an inner dialog stack. The two ways behave similarly because a component dialog ends when its last child dialog ends, so when you pop a dialog off of a stack the stack unravels outwards in much the same way as it unravels downwards. (Remember that new dialogs get added to the top of the stack so "downwards" here means from newer dialogs back to older dialogs, like the stack diagrams I started with.)

    The "active dialog" is the dialog at the top of the stack. Since each component dialog has its own dialog set and dialog state and dialog stack and dialog context, each component dialog has a different idea of what the active dialog is. Because the active dialog is defined in terms of a specific dialog stack, when there are multiple dialog stacks the active dialog depends on who you ask.

    This didn't cause a problem for you when you were looking for the active dialog in the innermost component dialog. But then you replaced that component dialog's child with the component dialog itself. After that, your (full) stack looked like this:

                    [  CHOICE_PROMPT   ]
                    [ WATERFALL_DIALOG ]
                [ REVIEW_SELECTION_DIALOG  ]
            [     REVIEW_SELECTION_DIALOG      ]
        [            TOP_LEVEL_DIALOG              ]
    [                   MAIN_DIALOG                    ]
    

    When your CancelAndHelpDialog tried to access the active dialog of its inner dialog context, it correctly returned a ReviewSelectionDialog because that was the only dialog on its stack. You wanted to return the choice prompt but that choice prompt was in the dialog stack of the child ReviewSelectionDialog and not the parent ReviewSelectionDialog.

    The bug is that you should be replacing the waterfall dialog with itself rather than with the parent component dialog. So it could look like this:

    return await stepContext.replaceDialog(WATERFALL_DIALOG, list);
    

    Or like this:

    return await stepContext.replaceDialog(this.initialDialogId, list);
    

    Ultimately, this still hasn't answered a question that you may have meant to ask. Since you've seen that problems can arise when you get the active dialog in an intermediate dialog context, you may want a way to get the "real" innermost active dialog. This can be accomplished with some simple recursion:

    function getInnermostActiveDialog(dc) {
        const child = dc.child;
        return child ? getInnermostActiveDialog(child) : dc.activeDialog;
    }