Migrate a JavaScript v3 bot to a v4 bot

APPLIES TO: SDK v4

In this article you will learn how to port the v3 SDK JavaScript core-MultiDialogs-v3 bot to a new v4 JavaScript bot. This conversion is broken down into these stages:

  1. Create the new project and add dependencies.
  2. Update the entry point and define constants.
  3. Create the dialogs and re-implement them using the SDK v4.
  4. Update the bot code to run the dialogs.
  5. Port the store.js utility file.

At the end of this process you will have a working v4 bot. A copy of the converted bot is also in the samples repo, core-MultiDialogs-v4.

The Bot Framework SDK v4 is based on the same underlying REST API as SDK v3. However, SDK v4 is a refactoring of the previous version of the SDK to allow developers more flexibility and control over their bots. Major changes in the SDK include:

  • State is managed via state management objects and property accessors.
  • How you handle turns has changed, that is, how the bot receives and responds to an incoming activity from the user's channel.
  • v4 does not use a session object, instead, it has a turn context object that contains information about the incoming activity and can be used to send back a response activity.
  • A new dialogs library that is very different from the one in v3. You'll need to convert old dialogs to the new dialog system, using component and waterfall dialogs.

Note

As part of the migration, you also need to clean up some of the code. This article highlights the changes to make to the v3 logic as part of the migration process.

Prerequisites

About this bot

The bot you're migrating demonstrates the use of multiple dialogs to manage conversation flow. The bot can look up flight or hotel information.

  • The main dialog asks the user what type of information they're looking for.
  • The hotel dialog prompts the user for search parameters, and then performs a mock search.
  • The flight dialog generates an error that the bot catches and deals with gracefully.

Create and open a new v4 bot project

  1. You will need a v4 project into which to port the bot code. To create a project locally, see Create a bot with the Bot Framework SDK for JavaScript.

    Tip

    You can also create a project on Azure, see Create a bot with Azure Bot Service. However, these two methods result in a slight difference in supporting files. The v4 project for this article was created as a local project.

  2. Then open the project in Visual Studio Code.

Update the package.json file

  1. Add a dependency on the botbuilder-dialogs package by entering npm i botbuilder-dialogs in Visual Studio Code's terminal window.

  2. Edit ./package.json and update the name, version, description, and other properties as desired.

Update the v4 app entry point

The v4 template creates an index.js file for the app entry point and a bot.js file for the bot-specific logic. In later steps, you will rename the bot.js file to bots/reservationBot.js in a later step and add a class for each dialog.

Edit ./index.js, which is the entry point for our bot app. This will contain the portions of the v3 app.js file that set up the HTTP server.

  1. In addition to BotFrameworkAdapter, import MemoryStorage and ConversationState from the botbuilder package. Also import the bot and main dialog modules. (You'll create these soon, but you need to reference them here.)

    // Import required bot services.
    // See https://docs.microsoft.com/azure/bot-service/bot-builder-basics to learn more about the different parts of a bot.
    const { BotFrameworkAdapter, MemoryStorage, ConversationState } = require('botbuilder');
    
    // This bot's main dialog.
    const { MainDialog } = require('./dialogs/main')
    const { ReservationBot } = require('./bots/reservationBot');
    
  2. Define an onTurnError handler for the adapter.

    // Catch-all for errors.
    adapter.onTurnError = async (context, error) => {
        const errorMsg = error.message ? error.message : `Oops. Something went wrong!`;
        // This check writes out errors to console log .vs. app insights.
        console.error(`\n [onTurnError]: ${ error }`);
        // Clear out state
        await conversationState.delete(context);
        // Send a message to the user
        await context.sendActivity(errorMsg);
    };
    

    In v4, you use a bot adapter to route incoming activities to the bot. The adapter allows us to catch and react to errors before a turn finishes. Here, you clear conversation state if an application error occurs, which will reset all dialogs and keep the bot from staying in a corrupted conversation state.

  3. Replace the template code for creating the bot with this.

    // Define state store for your bot.
    const memoryStorage = new MemoryStorage();
    
    // Create conversation state with in-memory storage provider.
    const conversationState = new ConversationState(memoryStorage);
    
    // Create the base dialog and bot
    const dialog = new MainDialog();
    const reservationBot = new ReservationBot(conversationState, dialog);
    

    The in-memory storage layer is now provided by the MemoryStorage class, and you need to explicitly create a conversation state management object.

    The dialog definition code has been moved to a MainDialog class that you'll define shortly. you'll also migrate the bot definition code into a ReservationBot class.

  4. Finally, you update the server's request handler to use the adapter to route activities to the bot.

    // Listen for incoming requests.
    server.post('/api/messages', (req, res) => {
        adapter.processActivity(req, res, async (context) => {
            // Route incoming activities to the bot.
            await reservationBot.run(context);
        });
    });
    

    In v4, the bot derives from ActivityHandler, which defines the run method to receive an activity for a turn.

Add a constants file

Create a ./const.js file to hold identifiers for your bot.

module.exports = {
    MAIN_DIALOG: 'mainDialog',
    INITIAL_PROMPT: 'initialPrompt',
    HOTELS_DIALOG: 'hotelsDialog',
    INITIAL_HOTEL_PROMPT: 'initialHotelPrompt',
    CHECKIN_DATETIME_PROMPT: 'checkinTimePrompt',
    HOW_MANY_NIGHTS_PROMPT: 'howManyNightsPrompt',
    FLIGHTS_DIALOG: 'flightsDialog',
};

In v4, IDs are assigned to dialog and prompt objects, and the dialogs and prompts are invoked by ID.

Create new dialog files

Create these files:

File name Description
./dialogs/flights.js This will contain the migrated logic for the hotels dialog.
./dialogs/hotels.js This will contain the migrated logic for the flights dialog.
./dialogs/main.js This will contain the migrated logic for our bot, and will stand in for the root dialog.

We have not migrated the support dialog. For an example of how to implement a help dialog in v4, see Handle user interruptions.

Implement the main dialog

In v3, all bots were built on top of a dialog system. In v4, bot logic and dialog logic is now separate. You've taken what was the root dialog in the v3 bot and made a MainDialog class to take its place.

Edit ./dialogs/main.js.

  1. Import the classes and constants you need for the dialog.

    const { DialogSet, DialogTurnStatus, ComponentDialog, WaterfallDialog,
        ChoicePrompt } = require('botbuilder-dialogs');
    const { FlightDialog } = require('./flights');
    const { HotelsDialog } = require('./hotels');
    const { MAIN_DIALOG,
        INITIAL_PROMPT,
        HOTELS_DIALOG,
        FLIGHTS_DIALOG
    } = require('../const');
    
  2. Define and export the MainDialog class.

    const initialId = 'mainWaterfallDialog';
    
    class MainDialog extends ComponentDialog {
        constructor() {
            super(MAIN_DIALOG);
    
            // Create a dialog set for the bot. It requires a DialogState accessor, with which
            // to retrieve the dialog state from the turn context.
            this.addDialog(new ChoicePrompt(INITIAL_PROMPT, this.validateNumberOfAttempts.bind(this)));
            this.addDialog(new FlightDialog(FLIGHTS_DIALOG));
    
            // Define the steps of the base waterfall dialog and add it to the set.
            this.addDialog(new WaterfallDialog(initialId, [
                this.promptForBaseChoice.bind(this),
                this.respondToBaseChoice.bind(this)
            ]));
    
            // Define the steps of the hotels waterfall dialog and add it to the set.
            this.addDialog(new HotelsDialog(HOTELS_DIALOG));
    
            this.initialDialogId = initialId;
        }
    }
    
    module.exports.MainDialog = MainDialog;
    

    This declares the other dialogs and prompts that the main dialog references directly.

    • The main waterfall dialog that contains the steps for this dialog. When the component dialog starts, it starts its initial dialog.
    • The choice prompt that you'll use to ask the user which task they'd like to perform. You've created the choice prompt with a validator.
    • The two child dialogs, flights and hotels.
  3. Add a run helper method to the class.

    /**
     * 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);
        }
    }
    

    In v4, a bot interacts with the dialog system by creating a dialog context first, and then calling continueDialog. If there is an active dialog, control is passed to it; otherwise, this call simply returns. A result of empty indicates that no dialog was active, and so here, you start the main dialog again.

    The accessor parameter passes in the accessor for the dialog state property. State for the dialog stack is stored in this property. For more information about how state and dialogs work in v4, see Managing state and Dialogs library, respectively.

  4. To the class, add the waterfall steps of the main dialog and the validator for the choice prompt.

    async promptForBaseChoice(stepContext) {
        return await stepContext.prompt(
            INITIAL_PROMPT, {
                prompt: 'Are you looking for a flight or a hotel?',
                choices: ['Hotel', 'Flight'],
                retryPrompt: 'Not a valid option'
            }
        );
    }
    
    async respondToBaseChoice(stepContext) {
        // Retrieve the user input.
        const answer = stepContext.result.value;
        if (!answer) {
            // exhausted attempts and no selection, start over
            await stepContext.context.sendActivity('Not a valid option. We\'ll restart the dialog ' +
                'so you can try again!');
            return await stepContext.endDialog();
        }
        if (answer === 'Hotel') {
            return await stepContext.beginDialog(HOTELS_DIALOG);
        }
        if (answer === 'Flight') {
            return await stepContext.beginDialog(FLIGHTS_DIALOG);
        }
        return await stepContext.endDialog();
    }
    
    async validateNumberOfAttempts(promptContext) {
        if (promptContext.attemptCount > 3) {
            // cancel everything
            await promptContext.context.sendActivity('Oops! Too many attempts :( But don\'t worry, I\'m ' +
                'handling that exception and you can try again!');
            return await promptContext.context.endDialog();
        }
    
        if (!promptContext.recognized.succeeded) {
            await promptContext.context.sendActivity(promptContext.options.retryPrompt);
            return false;
        }
        return true;
    }
    

    The first step of the waterfall asks the user to make a choice, by starting the choice prompt, which is itself a dialog. The second step of the waterfall consumes the result of the choice prompt. It either starts a child dialog (if a choice was made) or ends the main dialog (if the user failed to make a choice).

    The choice prompt will either return the user's choice, if they made a valid choice, or reprompt the user to make the choice again. The validator checks how many times in a row the prompt has been made to the user and allows the prompt to fail out after 3 failed attempts, returning control to the main waterfall dialog.

Implement the flights dialog

In the v3 bot, the flights dialog was a stub that demonstrated how the bot handles a conversation error. Here, you do the same.

Edit ./dialogs/flights.js.

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

const initialId = 'flightsWaterfallDialog';

class FlightDialog extends ComponentDialog {
    constructor(id) {
        super(id);

        // ID of the child dialog that should be started anytime the component is started.
        this.initialDialogId = initialId;

        // Define the conversation flow using a waterfall model.
        this.addDialog(new WaterfallDialog(initialId, [
            async () => {
                throw new Error('Flights Dialog is not implemented and is instead ' +
                    'being used to show Bot error handling');
            }
        ]));
    }
}

exports.FlightDialog = FlightDialog;

Implement the hotels dialog

You keep the same overall flow of the hotel dialog: ask for a destination, ask for a date, ask for the number of nights to stay, and then show the user a list of options that matched their search.

Edit ./dialogs/hotels.js.

  1. Import the classes and constants you'll need for the dialog.

    const { ComponentDialog, WaterfallDialog, TextPrompt, DateTimePrompt } = require('botbuilder-dialogs');
    const { AttachmentLayoutTypes, CardFactory } = require('botbuilder');
    const store = require('../store');
    const {
        INITIAL_HOTEL_PROMPT,
        CHECKIN_DATETIME_PROMPT,
        HOW_MANY_NIGHTS_PROMPT
    } = require('../const');
    
  2. Define and export the HotelsDialog class.

    const initialId = 'hotelsWaterfallDialog';
    
    class HotelsDialog extends ComponentDialog {
        constructor(id) {
            super(id);
    
            // ID of the child dialog that should be started anytime the component is started.
            this.initialDialogId = initialId;
    
            // Register dialogs
            this.addDialog(new TextPrompt(INITIAL_HOTEL_PROMPT));
            this.addDialog(new DateTimePrompt(CHECKIN_DATETIME_PROMPT));
            this.addDialog(new TextPrompt(HOW_MANY_NIGHTS_PROMPT));
    
            // Define the conversation flow using a waterfall model.
            this.addDialog(new WaterfallDialog(initialId, [
                this.destinationPromptStep.bind(this),
                this.destinationSearchStep.bind(this),
                this.checkinPromptStep.bind(this),
                this.checkinTimeSetStep.bind(this),
                this.stayDurationPromptStep.bind(this),
                this.stayDurationSetStep.bind(this),
                this.hotelSearchStep.bind(this)
            ]));
        }
    }
    
    exports.HotelsDialog = HotelsDialog;
    
  3. To the class, add a couple of helper functions that you'll use in the dialog steps.

    addDays(startDate, days) {
        const date = new Date(startDate);
        date.setDate(date.getDate() + days);
        return date;
    };
    
    createHotelHeroCard(hotel) {
        return CardFactory.heroCard(
            hotel.name,
            `${hotel.rating} stars. ${hotel.numberOfReviews} reviews. From ${hotel.priceStarting} per night.`,
            CardFactory.images([hotel.image]),
            CardFactory.actions([
                {
                    type: 'openUrl',
                    title: 'More details',
                    value: `https://www.bing.com/search?q=hotels+in+${encodeURIComponent(hotel.location)}`
                }
            ])
        );
    }
    

    createHotelHeroCard creates a hero card containing information about a hotel.

  4. To the class, add the waterfall steps used in the dialog.

    async destinationPromptStep(stepContext) {
        await stepContext.context.sendActivity('Welcome to the Hotels finder!');
        return await stepContext.prompt(
            INITIAL_HOTEL_PROMPT, {
                prompt: 'Please enter your destination'
            }
        );
    }
    
    async destinationSearchStep(stepContext) {
        const destination = stepContext.result;
        stepContext.values.destination = destination;
        await stepContext.context.sendActivity(`Looking for hotels in ${destination}`);
        return stepContext.next();
    }
    
    async checkinPromptStep(stepContext) {
        return await stepContext.prompt(
            CHECKIN_DATETIME_PROMPT, {
                prompt: 'When do you want to check in?'
            }
        );
    }
    
    async checkinTimeSetStep(stepContext) {
        const checkinTime = stepContext.result[0].value;
        stepContext.values.checkinTime = checkinTime;
        return stepContext.next();
    }
    
    async stayDurationPromptStep(stepContext) {
        return await stepContext.prompt(
            HOW_MANY_NIGHTS_PROMPT, {
                prompt: 'How many nights do you want to stay?'
            }
        );
    }
    
    async stayDurationSetStep(stepContext) {
        const numberOfNights = stepContext.result;
        stepContext.values.numberOfNights = parseInt(numberOfNights);
        return stepContext.next();
    }
    
    async hotelSearchStep(stepContext) {
        const destination = stepContext.values.destination;
        const checkIn = new Date(stepContext.values.checkinTime);
        const checkOut = this.addDays(checkIn, stepContext.values.numberOfNights);
    
        await stepContext.context.sendActivity(`Ok. Searching for Hotels in ${destination} from 
            ${checkIn.toDateString()} to ${checkOut.toDateString()}...`);
        const hotels = await store.searchHotels(destination, checkIn, checkOut);
        await stepContext.context.sendActivity(`I found in total ${hotels.length} hotels for your dates:`);
    
        const hotelHeroCards = hotels.map(this.createHotelHeroCard);
    
        await stepContext.context.sendActivity({
            attachments: hotelHeroCards,
            attachmentLayout: AttachmentLayoutTypes.Carousel
        });
    
        return await stepContext.endDialog();
    }
    

    You've migrated the steps from the v3 hotels dialog into the waterfall steps of the v4 hotels dialog.

Update the bot

In v4, bots can react to activities outside of the dialog system. The ActivityHandler class defines handlers for common types of activities, to make it easier to manage your code.

Rename ./bot.js to ./bots/reservationBot.js, and edit it.

  1. The file already imports the ActivityHandler, which provides a base implementation of a bot.

    const { ActivityHandler } = require('botbuilder');
    
  2. Rename the class to ReservationBot.

    class ReservationBot extends ActivityHandler {
        // ...
    }
    
    module.exports.ReservationBot = ReservationBot;
    
  3. Update the signature of the constructor, to accept the objects you're receiving.

    /**
     *
     * @param {ConversationState} conversationState
     * @param {Dialog} dialog
     * @param {any} logger object for logging events, defaults to console if none is provided
    */
    constructor(conversationState, dialog, logger) {
        super();
        // ...
    }
    
  4. In the constructor, add null parameter checks and define class constructor properties.

    if (!conversationState) throw new Error('[DialogBot]: Missing parameter. conversationState is required');
    if (!dialog) throw new Error('[DialogBot]: Missing parameter. dialog is required');
    if (!logger) {
        logger = console;
        logger.log('[DialogBot]: logger not passed in, defaulting to console');
    }
    
    this.conversationState = conversationState;
    this.dialog = dialog;
    this.logger = logger;
    this.dialogState = this.conversationState.createProperty('DialogState');
    

    This is where you create the dialog state property accessor that will store state for the dialog stack.

  5. In the constructor, update the onMessage handler and add an onDialog handler.

    this.onMessage(async (context, next) => {
        this.logger.log('Running dialog with Message Activity.');
    
        // Run the Dialog with the new message Activity.
        await this.dialog.run(context, this.dialogState);
    
        // By calling next() you ensure that the next BotHandler is run.
        await next();
    });
    
    this.onDialog(async (context, next) => {
        // Save any state changes. The load happened during the execution of the Dialog.
        await this.conversationState.saveChanges(context, false);
    
        // By calling next() you ensure that the next BotHandler is run.
        await next();
    });
    

    The ActivityHandler routes message activities to onMessage. This bot handles all user input via dialogs.

    The ActivityHandler calls onDialog at the end of the turn, before returning control to the adapter. You need to explicitly save state before exiting the turn. Otherwise, the state changes will not get saved and the dialog will not run properly.

  6. Finally, update the onMembersAdded handler in the constructor.

    this.onMembersAdded(async (context, next) => {
        const membersAdded = context.activity.membersAdded;
        for (let cnt = 0; cnt < membersAdded.length; ++cnt) {
            if (membersAdded[cnt].id !== context.activity.recipient.id) {
                await context.sendActivity('Hello and welcome to Contoso help desk bot.');
            }
        }
        // By calling next() you ensure that the next BotHandler is run.
        await next();
    });
    

    The ActivityHandler calls onMembersAdded when it receives a conversation update activity that indicates participants other than the bot were added to the conversation. You update this method to send a greeting message when a user joins the conversation.

Create the store file

Create the ./store.js file, used by the hotels dialog. searchHotels is a mock hotel search function, same as in the v3 bot.

module.exports = {
    searchHotels: destination => {
        return new Promise(resolve => {

            // Filling the hotels results manually just for demo purposes
            const hotels = [];
            for (let i = 1; i <= 5; i++) {
                hotels.push({
                    name: `${destination} Hotel ${i}`,
                    location: destination,
                    rating: Math.ceil(Math.random() * 5),
                    numberOfReviews: Math.floor(Math.random() * 5000) + 1,
                    priceStarting: Math.floor(Math.random() * 450) + 80,
                    image: `https://placeholdit.imgix.net/~text?txtsize=35&txt=Hotel${i}&w=500&h=260`
                });
            }

            hotels.sort((a, b) => a.priceStarting - b.priceStarting);

            // complete promise with a timer to simulate async response
            setTimeout(() => { resolve(hotels); }, 1000);
        });
    }
};

Test the bot in the Emulator

At this point, you should be able to run the bot locally and attach to it with the Emulator.

  1. Run the sample locally on your machine. If you start a debugging session in Visual Studio Code, logging information is sent to the debug console as you test the bot.
  2. Start the Emulator and connect to the bot.
  3. Send messages to test the main, flight, and hotel dialogs.

Additional resources

v4 conceptual topics:

v4 how-to topics: