Routing in complex chatbots with the Hobot framework



Having started developing bots for Telegram a few years ago, I discovered the performance, simplicity and flexibility of working with them as a special case of the command line interface. These features, available to many today, are largely due to the popular framework telegraf.js and the like, which provide simplified methods for working with the Telegram API.

At the same time, the architecture of the project lies entirely on the shoulders of the developer, and judging by the modest number of complex and multifunctional bots, we still have room to grow in this regard.

In this article I want to talk about a small chatbot routing framework, without which the development of our project would be impossible.

Some basic information


In chatbots and CLIs, the execution of a single logical action often consists of several refinement steps or branching steps. This requires the program to store a certain coordinate in order to remember where in the flow the user is located and execute his command in accordance with this coordinate.

The simplest illustration is the execution of the npm init command , during which the program asks you to specify one or another data for package.json in turn.

In the first step, she tells the user that she is waiting for text input of the package name - and what the user sends her with the next command will be saved as the package name thanks to the variable in which this wait is written.

We call this variable path - the path to which this or that code is logically attached. It is always needed if the bot has navigation or commands issued in a few steps.

Today's practice in bot architecture


The approach that I initially looked at from other developers looked like this: for any update coming from the user, a list of checks for a particular value of the path variable is written and business logic and further navigation are placed inside these checks in the most elementary form:

onUserInput(ctx, input) {
    switch(ctx.session.path) {
        case 'firstPath':
	    if (input === '!') {
               // -  
	        ctx.reply('!');
	        ctx.session.path = 'secondPath';
	    } else {
	        ctx.reply(' "!"');
	    }
	    break;
        case '...':
       	    //   
    }
}

If you have only a couple of teams and a couple of steps for each team, this solution is optimal. Getting to the third team and the seventh if you start to think that something is going wrong.

In one of the bots that we had a chance to work with at a late stage, the core of the functional was a sheet of 4000 lines and ~ 70 conditions of only the top level that grew out of two ifs, with a check of what went to heart - sometimes paths, sometimes commands, sometimes paths and commands. All these conditions were checked for each user action and accessed auxiliary functions from a neighboring sheet object, which also grew from several lines. Needless to say, how slowly and waddily was this project going?

Hobot framework


Starting ActualizeBot, we already imagined how big it would be, and our first task was to preserve the extensibility and speed of development.

To do this, we broke the client logic into controllers assigned to the paths and wrote a small abstraction to navigate between these controllers and process the messages received from the user in them.

All this, based on large projects, was written in TypeScript and was given the elegant name of Hobot, alluding, of course, to navigation pipelines.

A controller is a simple object of three properties:

  • path - string identifier of the path used to initialize and navigate the initialized paths
  • get — , , hobot.gotoPath(ctx, path, data?). — data ,
  • post — . , , . updateType — , : text, callback_query . updateType ctx

Controller example:

const anotherController = {
    path: 'firstPath',
    get: async (ctx, data) => 
        await ctx.reply('Welcome to this path! Say "Hi"'),
    post: async (ctx, updateType) => {
        //     : text / callback_query / etc...
        if (updateType === updateTypes.text && ctx.update.message.text === 'Hi') {
            await ctx.reply("Thank you!");
            // hobot       this:
            this.hobot.gotoPath(ctx, 'secondPath', { userJustSaid: "Hi" });
        } else {
            //     ,       
            await ctx.reply('We expect "Hi" text message here');
        }
    }
}

It looks a bit more complicated than at the beginning, but the difficulty will remain the same when you have 100 or 200 paths.

The internal logic is trivial and it is surprising that no one has done this yet :
These controllers are added to an object whose keys are the values ​​of the path property and are called from it by these keys during user actions or when navigating using hobot.gotoPath (ctx, path, data? ) .

Navigation is allocated in a separate method in order not to touch the variable path and navigation logic, but to think only about business logic, although you can always change ctx.session.path with your hands, which, of course, is not recommended.

All you need to do to get your new boat with an indestructible structure working is to launch a regular telegraf bot and pass it and the config object to the Hobot constructor. The config object consists of the controllers you want to initialize, the default path, and the command / controller pairs.

//   telegraf-
const bot = new Telegraf('_');

//  
export const hobot = new Hobot(bot, {
    defaultPath: 'firstPath',
    commands: [
        //  ,     
        // get    :
        { command: 'start', path: 'firstPath' }
    ],
    controllers: [
        // -,    
        startController,
        nextController
    ]
});

// C telegraf-,       
bot.launch();

In conclusion


Implicit advantages of dividing a sheet into controllers:

  • The ability to put isolated functions, methods and interfaces that are locked to the logic of this controller in separate files next to the controllers
  • Significantly reduced risk of accidentally breaking everything
  • Modularity: turning on / off / giving a certain segment of the audience this or that logic can be done simply by adding and removing controllers from the array, including by updating the config without programming - for this, of course, we need to write a couple of letters, since we have not yet reached
  • The ability to clearly tell the user what exactly is expected of him when he (which happens often) does something wrong - and do it where this is the place - at the end of the post method processing

Our plans :

This article describes the basic script for working with text messaging Hobot.
If the topic turns out to be relevant, we will share other technical subtleties of the framework and our observations from the practice of developing and using telegram bots in future articles.

Links :

Installing the bot looks like this: npm i -s hobot
Repository with walkthrough in README.MD and the sandbox
Ready bot working in production based on Hobot.

Thank you for your attention, I will be glad to hear your questions, suggestions or ideas for new bots!

All Articles