4
votes

I am using the bot framework of V4. using an API call, I'm trying to get data and display it to the user and I defined a custom method to capture data from API and preprocess it, before sending it to the user through waterfall dialog and I made this method async and also using await where it is being called.

There are 2 scenarios where I'm facing the issue -

  • When two users send questions at an instance one of the responses captured as null instead of value acquired from API.

  • We are using the suggestive card to display result and when the user clicks on button cross-session was observed sporadically. Help in this area is much appreciated.

The custom method defined to make API call and get data:


    public static async Task<string> 
    GetEPcallsDoneAsync(ConversationData conversationData)
        {
            LogWriter.LogWrite("Info: Acessing end-points");
            string responseMessage = null;
            try
            {
                conversationData.fulFillmentMap = await 
                AnchorUtil.GetFulfillmentAsync(xxxxx);//response from API call to get data

                if (conversationData.fulFillmentMap == null || (conversationData.fulFillmentMap.ContainsKey("status") && conversationData.fulFillmentMap["status"].ToString() != "200"))
                {
                    responseMessage = "Sorry, something went wrong. Please try again later!";
                }
                else
                {
                    conversationData.NLGresultMap = await 
            AnchorUtil.GetNLGAsync(conversationData.fulFillmentMap ,xxxx);//API call to get response to be displayed


                    if (conversationData.errorCaptureDict.ContainsKey("fulfillmentError") || conversationData.NLGresultMap.ContainsKey("NLGError"))
                    {
                        responseMessage = "Sorry, something went wrong:( Please try again later!!!";
                    }
                    else
                    {
                        responseMessage = FormatDataResponse(conversationData.NLGresultMap["REPLY"].ToString()); //response message
                    }
                }
                return responseMessage;
            }
            catch (HttpRequestException e)
            {
                LogWriter.LogWrite("Error: " + e.Message);
                System.Console.WriteLine("Error: " + e.Message);
                return null;
            }
        }

And the waterfall step of dialog class where the above function is being called:


     private async Task<DialogTurnResult> DoProcessInvocationStep(WaterfallStepContext stepContext, CancellationToken cancellationToken)
        {
                conversationData.index = 0; //var for some other purpose
                conversationData.result = await 
    AnchorUtil.GetEPcallsDoneAsync(conversationData);
                await _conversationStateAccessor.SetAsync(stepContext.Context, conversationData, cancellationToken);

                return await stepContext.NextAsync(cancellationToken);
     }

ConversationData contains variables that are required to process data through waterfall dialogs, values to the object has been set and accessed through accessor as below in each step:

In a dialog class,

   public class TopLevelDialog : ComponentDialog
    {

private readonly IStatePropertyAccessor<ConversationData> _conversationStateAccessor;
        ConversationData conversationData;

        public TopLevelDialog(ConversationState conversationState)
            : base(nameof(TopLevelDialog))
        {
            _conversationStateAccessor = conversationState.CreateProperty<ConversationData>(nameof(ConversationData));

            AddDialog(new TextPrompt(nameof(TextPrompt)));
            AddDialog(new ChoicePrompt(nameof(ChoicePrompt)));
            AddDialog(new ReviewSelectionDialog(conversationState));
            AddDialog(new ESSelectionDialog());

            AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[]
            {
                StartSelectionStepAsync,
                GetESResultStep,
                DoProcessInvocationStep,
                ResultStepAsync,
                IterationStepAsync
            }));

            InitialDialogId = nameof(WaterfallDialog);
        }

        private async Task<DialogTurnResult> StartSelectionStepAsync (WaterfallStepContext stepContext, CancellationToken cancellationToken)
        {
              conversationData = await _conversationStateAccessor.GetAsync(stepContext.Context, () => new ConversationData());
              //code for functionality
              await _conversationStateAccessor.SetAsync(stepContext.Context, conversationData, cancellationToken);
              return await stepContext.NextAsync(null, cancellationToken);
       }
       //other dialog steps
      }
1
Please include code related to this issue. From what you've included, anybody would need to see at least 1) your custom method, 2) the waterfall dialog, 3) your "suggestive card" (is this an Adaptive Card or Suggested Action. That being said, "cross session" usually occur when the User ID is the same for both users. If you're using Web Chat, please 4) include your index.html file as wellmdrichardson
Added issue related code in the question.user12923898
There isn't enough code here to really debug, but one definite problem is that in DoProcessInvocationStep, conversationData seems to refer to a global variable. This is likely the cause of "cross session". Almost definitely. Are you getting any errors from your Logger? It looks like the API returns null on an error, which would explain that point.mdrichardson
No error found while logging and please find added code which detaails how conversationData declared and useduser12923898

1 Answers

1
votes

Both of your issues likely stem from the same thing. You can't declare conversationData as a class-level property. You'll run into concurrency issues like this as every user will overwrite the conversationData for every other user. You must re-declare conversationData in each step function.

For example,

User A starts the waterfall dialog and gets half of the way through. conversationData is correct at this point and represents exactly what it should.

Now User B starts a dialog. At the StartSelectionStepAsync, they just reset conversationData for everybody because of conversationData = await _conversationStateAccessor.GetAsync(stepContext.Context, () => new ConversationData()); and all users share the same conversationData because of ConversationData conversationData;.

So now, when User A continues their conversation, conversationData will be null/empty.


How State Should Be Saved

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

const { Channels, MessageFactory } = require('botbuilder');
const {
    AttachmentPrompt,
    ChoiceFactory,
    ChoicePrompt,
    ComponentDialog,
    ConfirmPrompt,
    DialogSet,
    DialogTurnStatus,
    NumberPrompt,
    TextPrompt,
    WaterfallDialog
} = require('botbuilder-dialogs');
const { UserProfile } = require('../userProfile');

const ATTACHMENT_PROMPT = 'ATTACHMENT_PROMPT';
const CHOICE_PROMPT = 'CHOICE_PROMPT';
const CONFIRM_PROMPT = 'CONFIRM_PROMPT';
const NAME_PROMPT = 'NAME_PROMPT';
const NUMBER_PROMPT = 'NUMBER_PROMPT';
const USER_PROFILE = 'USER_PROFILE';
const WATERFALL_DIALOG = 'WATERFALL_DIALOG';

/**
 * This is a "normal" dialog, where userState is stored properly using the accessor, this.userProfile.
 * In this dialog example, we create the userProfile using the accessor in the first step, transportStep.
 * We then pass prompt results through the remaining steps using step.values.
 * In the final step, summaryStep, we save the userProfile using the accessor.
 */
class UserProfileDialogNormal extends ComponentDialog {
    constructor(userState) {
        super('userProfileDialogNormal');

        this.userProfileAccessor = userState.createProperty(USER_PROFILE);

        this.addDialog(new TextPrompt(NAME_PROMPT));
        this.addDialog(new ChoicePrompt(CHOICE_PROMPT));
        this.addDialog(new ConfirmPrompt(CONFIRM_PROMPT));
        this.addDialog(new NumberPrompt(NUMBER_PROMPT, this.agePromptValidator));
        this.addDialog(new AttachmentPrompt(ATTACHMENT_PROMPT, this.picturePromptValidator));

        this.addDialog(new WaterfallDialog(WATERFALL_DIALOG, [
            this.transportStep.bind(this),
            this.nameStep.bind(this),
            this.nameConfirmStep.bind(this),
            this.ageStep.bind(this),
            this.pictureStep.bind(this),
            this.confirmStep.bind(this),
            this.saveStep.bind(this)
        ]));

        this.initialDialogId = WATERFALL_DIALOG;
    }

    /**
     * The run method handles the incoming activity (in the form of a TurnContext) and passes it through the dialog system.
     * If no dialog is active, it will start the default dialog.
     * @param {*} turnContext
     * @param {*} accessor
     */
    async run(turnContext, accessor) {
        const dialogSet = new DialogSet(accessor);
        dialogSet.add(this);

        const dialogContext = await dialogSet.createContext(turnContext);
        const results = await dialogContext.continueDialog();
        if (results.status === DialogTurnStatus.empty) {
            await dialogContext.beginDialog(this.id);
        }
    }

    async transportStep(step) {
        // Get the userProfile if it exists, or create a new one if it doesn't.
        const userProfile = await this.userProfileAccessor.get(step.context, new UserProfile());

        // Pass the userProfile through step.values.
        // This makes it so we don't have to call this.userProfileAccessor.get() in every step.
        step.values.userProfile = userProfile;

        // Skip this step if we already have the user's transport.
        if (userProfile.transport) {
            // ChoicePrompt results will show in the next step with step.result.value.
            // Since we don't need to prompt, we can pass the ChoicePrompt result manually.
            return await step.next({ value: userProfile.transport });
        }

        // WaterfallStep always finishes with the end of the Waterfall or with another dialog; here it is a Prompt Dialog.
        // Running a prompt here means the next WaterfallStep will be run when the user's response is received.
        return await step.prompt(CHOICE_PROMPT, {
            prompt: 'Please enter your mode of transport.',
            choices: ChoiceFactory.toChoices(['Car', 'Bus', 'Bicycle'])
        });
    }

    async nameStep(step) {
        // Retrieve the userProfile from step.values.
        const userProfile = step.values.userProfile;
        // Set the transport property of the userProfile.
        userProfile.transport = step.result.value;

        // Pass the userProfile through step.values.
        // This makes it so we don't have to call this.userProfileAccessor.get() in every step.
        step.values.userProfile = userProfile;

        // Skip the prompt if we already have the user's name.
        if (userProfile.name) {
            // We pass in a skipped bool so we know whether or not to send messages in the next step.
            return await step.next({ value: userProfile.name, skipped: true });
        }

        return await step.prompt(NAME_PROMPT, 'Please enter your name.');
    }

    async nameConfirmStep(step) {
        // Retrieve the userProfile from step.values and set the name property
        const userProfile = step.values.userProfile;

        // If userState is working correctly, we'll have userProfile.transport from the previous step.
        if (!userProfile || !userProfile.transport) {
            throw new Error(`transport property does not exist in userProfile.\nuserProfile:\n ${ JSON.stringify(userProfile) }`);
        }
        // Text prompt results normally end up in step.result, but if we skipped the prompt, it will be in step.result.value.
        userProfile.name = step.result.value || step.result;
        // step.values.userProfile.name is already set by reference, so there's no need to set it again to pass it to the next step.

        // We can send messages to the user at any point in the WaterfallStep. Only do this if we didn't skip the prompt.
        if (!step.result.skipped) {
            await step.context.sendActivity(`Thanks ${ step.result }.`);
        }

        // WaterfallStep always finishes with the end of the Waterfall or with another dialog; here it is a Prompt Dialog.
        // Skip the prompt if we already have the user's age.
        if (userProfile.age) {
            return await step.next('yes');
        }
        return await step.prompt(CONFIRM_PROMPT, 'Do you want to give your age?', ['yes', 'no']);
    }

    async ageStep(step) {
        // Retrieve the userProfile from step.values
        const userProfile = step.values.userProfile;

        // If userState is working correctly, we'll have userProfile.name from the previous step.
        if (!userProfile || !userProfile.name) {
            throw new Error(`name property does not exist in userProfile.\nuserProfile:\n ${ JSON.stringify(userProfile) }`);
        }

        // Skip the prompt if we already have the user's age.
        if (userProfile.age) {
            // We pass in a skipped bool so we know whether or not to send messages in the next step.
            return await step.next({ value: userProfile.age, skipped: true });
        }

        if (step.result) {
            // User said "yes" so we will be prompting for the age.
            // WaterfallStep always finishes with the end of the Waterfall or with another dialog; here it is a Prompt Dialog.
            const promptOptions = { prompt: 'Please enter your age.', retryPrompt: 'The value entered must be greater than 0 and less than 150.' };

            return await step.prompt(NUMBER_PROMPT, promptOptions);
        } else {
            // User said "no" so we will skip the next step. Give -1 as the age.
            return await step.next(-1);
        }
    }

    async pictureStep(step) {
        // Retrieve the userProfile from step.values and set the age property
        const userProfile = step.values.userProfile;
        // We didn't set any additional properties on userProfile in the previous step, so no need to check for them here.

        // Confirm prompt results normally end up in step.result, but if we skipped the prompt, it will be in step.result.value.
        userProfile.age = step.result.value || step.result;
        // step.values.userProfile.age is already set by reference, so there's no need to set it again to pass it to the next step.

        if (!step.result.skipped) {
            const msg = userProfile.age === -1 ? 'No age given.' : `I have your age as ${ userProfile.age }.`;

            // We can send messages to the user at any point in the WaterfallStep. Only send it if we didn't skip the prompt.
            await step.context.sendActivity(msg);
        }

        // Skip the prompt if we already have the user's picture.
        if (userProfile.picture) {
            return await step.next(userProfile.picture);
        }

        if (step.context.activity.channelId === Channels.msteams) {
            // This attachment prompt example is not designed to work for Teams attachments, so skip it in this case
            await step.context.sendActivity('Skipping attachment prompt in Teams channel...');
            return await step.next(undefined);
        } else {
            // WaterfallStep always finishes with the end of the Waterfall or with another dialog; here it is a Prompt Dialog.
            var promptOptions = {
                prompt: 'Please attach a profile picture (or type any message to skip).',
                retryPrompt: 'The attachment must be a jpeg/png image file.'
            };

            return await step.prompt(ATTACHMENT_PROMPT, promptOptions);
        }
    }

    async confirmStep(step) {
        // Retrieve the userProfile from step.values and set the picture property
        const userProfile = step.values.userProfile;
        // If userState is working correctly, we'll have userProfile.age from the previous step.
        if (!userProfile || !userProfile.age) {
            throw new Error(`age property does not exist in userProfile.\nuserProfile:\n ${ JSON.stringify(userProfile) }`);
        }
        userProfile.picture = (step.result && typeof step.result === 'object' && step.result[0]) || 'no picture provided';
        // step.values.userProfile.picture is already set by reference, so there's no need to set it again to pass it to the next step.

        let msg = `I have your mode of transport as ${ userProfile.transport } and your name as ${ userProfile.name }`;
        if (userProfile.age !== -1) {
            msg += ` and your age as ${ userProfile.age }`;
        }

        msg += '.';
        await step.context.sendActivity(msg);
        if (userProfile.picture && userProfile.picture !== 'no picture provided') {
            try {
                await step.context.sendActivity(MessageFactory.attachment(userProfile.picture, 'This is your profile picture.'));
            } catch (err) {
                await step.context.sendActivity('A profile picture was saved but could not be displayed here.');
            }
        }

        // WaterfallStep always finishes with the end of the Waterfall or with another dialog; here it is a Prompt Dialog.
        return await step.prompt(CONFIRM_PROMPT, { prompt: 'Would you like me to save this information?' });
    }

    async saveStep(step) {
        if (step.result) {
            // Get the current profile object from user state.
            const userProfile = step.values.userProfile;

            // Save the userProfile to userState.
            await this.userProfileAccessor.set(step.context, userProfile);

            await step.context.sendActivity('User Profile Saved.');
        } else {
            // Ensure the userProfile is cleared
            await this.userProfileAccessor.set(step.context, {});
            await step.context.sendActivity('Thanks. Your profile will not be kept.');
        }

        // WaterfallStep always finishes with the end of the Waterfall or with another dialog; here it is the end.
        return await step.endDialog();
    }

    async agePromptValidator(promptContext) {
        // This condition is our validation rule. You can also change the value at this point.
        return promptContext.recognized.succeeded && promptContext.recognized.value > 0 && promptContext.recognized.value < 150;
    }

    async picturePromptValidator(promptContext) {
        if (promptContext.recognized.succeeded) {
            var attachments = promptContext.recognized.value;
            var validImages = [];

            attachments.forEach(attachment => {
                if (attachment.contentType === 'image/jpeg' || attachment.contentType === 'image/png') {
                    validImages.push(attachment);
                }
            });

            promptContext.recognized.value = validImages;

            // If none of the attachments are valid images, the retry prompt should be sent.
            return !!validImages.length;
        } else {
            await promptContext.context.sendActivity('No attachments received. Proceeding without a profile picture...');

            // We can return true from a validator function even if Recognized.Succeeded is false.
            return true;
        }
    }
}

module.exports.UserProfileDialogNormal = UserProfileDialogNormal;