Telegram bot on Node.js

In this article, I continue to share the results of my study of creating telegram bots in nodejs, which I started in previous publications (one, two). This time I'll show you how to organize interactive dialogues with users using the module conversations libraries grammY. We'll look at how to configure the library to work with dialogs, manage their completion, and implement branches and loops. This approach will become the basis for more complex projects where user interaction is important.

Introduction

In the dialog being developed, the bot, having received the command /startfirst checks if the user is registered in the database. If the user is not registered, the bot prompts him to register. A list of available services to subscribe to is then displayed, and the bot prompts you to enter a service number, repeating the request until the correct number is received. After this, the bot displays the details of the selected service and asks you to confirm your subscription. If you agree, a new subscription is created and the dialogue ends; if the user refuses, the dialogue simply ends.

The source code for implementing the dialog is available in the repository flancer64/tg-demo-all (branch conversation), the bot itself – f64_demo_conversation_bot.

Conversations on grammY

Library grammY provides a plugin conversations to create dialogues between the bot and users. Unlike other frameworks that require the use of cumbersome configuration objects, this plugin allows you to define dialogs through regular JavaScript functions, making the code more understandable and flexible. Each conversation state is controlled by simple functions that are executed sequentially throughout the conversation.

Recommended to follow three basic rules when writing code inside dialog builder functions.

  1. All operations that depend on external systems must be wrapped in special calls to avoid errors and data loss.

  2. You should avoid using random values ​​directly; instead, you need to use the provided functions to work with randomness.

  3. You should use the helper functions offered by the library, which simplify working with states and variables, ensuring more reliable operation of dialogs (form, wait…, sleep, now, …).

It is also important to consider that the module conversations supports parallel dialogues, which allows you to interact with several users at the same time. This is especially useful in group chats, where the bot can conduct conversations with multiple participants. This approach makes developing interactive applications easier and more efficient.

Initializing the dialogue

Initializing the plugin conversation happens in the module Demo_Back_Bot_Setup and it comes down to this:

import {session} from 'grammy';
import {conversations} from '@grammyjs/conversations';

bot.use(session({initial: () => ({})}));
bot.use(conversations());

It is important to consider the order in which intermediaries are connected (middleware) – conversations must be connected after session. Because Intermediaries will be processed in order of connection, and dialogues without sessions will not work.

Connecting and starting a dialogue

A typical handler code receives two parameters:

const conv = async (conversation, ctx) => {
    // ...
}
  • conversation: An object that controls the state of the current dialog.

  • ctx: standard grammY context corresponding to the current user interaction with the bot (message).

Registration of processors is carried out in the same place as the registration of other intermediaries:

import {createConversation} from '@grammyjs/conversations';

bot.use(createConversation(conv, 'conversationStart'));

Calling a dialog handler from a command handler:

const cmd = async (ctx) => {
    await ctx.conversation.enter('conversationStart');
}

Ending the dialogue

The dialog ends when the handler finishes its work (reaches return ):

const conv = async (conversation, ctx) => {
    // ...
    return;
};

If for some reason the dialogue cannot end normally (for example, the user enters another command instead of following the dialogue script), then the dialogue can be forced to end via ctx.conversation.exit(). For example, like this:

// This middleware should be placed after `bot.use(conversations())`
bot.use(async (ctx, next) => {
    if (ctx?.chat && (typeof ctx?.conversation?.active === 'function')) {
        const {start} = await ctx.conversation.active();
        if (start >= 1) {
            logger.info(`An active conversation exists.`);
            const commandEntity = ctx.message?.entities?.find(entity => entity.type === 'bot_command');
            if (commandEntity) {
                await ctx.conversation.exit('conversationStart');
                await ctx.reply(`The previous conversation has been closed.`);
            }
        }
    }
    await next();
});

Scenario implementation

As mentioned above, grammY “glues“separate messages from the user into one continuous stream. Moreover, if the dialog is intended to process, say, three consecutive messages, then the dialog handler will be launched three times – once for each message:

const conv = async (conversation, ctx) => {
    const username = ctx.from.username;
    const sess = conversation.session;
    sess.count = sess.count ?? 0;
    sess.count++;
    logger.info(`username: ${username}, count: ${sess.count}`);
    //...
};

That is, if the bot starts executing the dialogue script, then conv-the handler will be launched for each new message. Moreover, with each new launch grammY will transmit all previous messages to it, moving the dialog handler to the appropriate state. That's why conversation provides its own generator random numbers (a random value is remembered and given each time for the current dialogue).

Hidden text

Here is the code to check the existence of the service selected by the user:

await ctx.reply(`Please select a service by number:\n${list}`);
let selected;
do {
    const response = await conversation.wait();
    const id = parseInt(response.message.text);
    selected = await modService.read({id});
    if (!selected) await ctx.reply(`Invalid selection. Please enter a valid service number.`);
} while (!selected);

The bot has only three services (id: 1,2,3), but the user incorrectly indicates the numbers 4,5 and only then 3. On each iterations The bot checks the existence of all previous entered identifiers:

10/21 17:34:49.537 (info Demo_Back_Mod_User): User wiredgeese read successfully (id:1383581234).
10/21 17:34:49.540 (info Demo_Back_Mod_Service): Service with ID 4 not found. 
10/21 17:34:50.794 (info Demo_Back_Mod_User): User wiredgeese read successfully (id:1383581234).
10/21 17:34:50.797 (info Demo_Back_Mod_Service): Service with ID 4 not found.
10/21 17:34:50.798 (info Demo_Back_Mod_Service): Service with ID 5 not found.
10/21 17:34:53.290 (info Demo_Back_Mod_User): User wiredgeese read successfully (id:1383581234).
10/21 17:34:53.292 (info Demo_Back_Mod_Service): Service with ID 4 not found.
10/21 17:34:53.293 (info Demo_Back_Mod_Service): Service with ID 5 not found.
10/21 17:34:53.294 (info Demo_Back_Mod_Service): Service 'Service 3' read successfully (id:3).

Side effects

Let's say that in our dialog code there is a call to an external service that creates an entry in the database.

user = await modUser.create({dto});

If a record in the database is created in the first step, and there are only three steps in the dialog, then the first step will be repeated three times, and with the same parameters. That is, the service will be called three times to create the same record.

To work out such “side effects” plugin conversation provides a method external:

const user = await conversation.external(
    () => {
        const dto = modUser.composeEntity();
        ...
        dto.telegramId = telegramId;
        modUser.create({dto});
    }
);

In this form, user creation will be performed only once, at the very first call of the method external. Subsequent times for this dialog, the result of the very first execution will be returned, and the external service “twitch“It won't.

If the execution of an external service depends on data entered by the user (for example, searching for a service by identifier), then the method external can be called like this:

let service = await conversation.external({
    task: (id) => modService.read({id}),
    args: [id]

});

In this case, the “pairs” are saved and reusedarguments – result“.

Hidden text

The previous example with searching for a service can be rewritten as follows:

let selected;
do {
    const response = await conversation.wait();
    const id = parseInt(response.message.text);
    selected = await conversation.external({
        task: (id) => modService.read({id}),
        args: [id]

    });
    if (!selected) await ctx.reply(`Invalid selection. Please enter a valid service number.`);
} while (!selected);

It can be seen that the external service (Demo_Back_Mod_Service) is no longer called repeatedly for incorrect values ​​(4 and 5), although the service Demo_Back_Mod_User called every time (as unwrapped in external):

10/21 17:46:58.668 (info Demo_Back_Mod_User): User wiredgeese read successfully (id:1383581234).
10/21 17:46:58.672 (info Demo_Back_Mod_Service): Service with ID 4 not found.
10/21 17:46:59.817 (info Demo_Back_Mod_User): User wiredgeese read successfully (id:1383581234).
10/21 17:46:59.822 (info Demo_Back_Mod_Service): Service with ID 5 not found.
10/21 17:47:01.758 (info Demo_Back_Mod_User): User wiredgeese read successfully (id:1383581234).
10/21 17:47:01.764 (info Demo_Back_Mod_Service): Service 'Service 3' read successfully (id:3).

Branching

With branching, everything is simple, and if the dialog code does not create side effects, then it’s even very simple:

const confirmation = await conversation.wait();
const confirmationText = confirmation.message.text.toLowerCase();
if (confirmationText === 'yes') {
    // ...
} else if (confirmationText === 'no') {
    // ...
} else {
    // ...
}

Without side effects (including saving state), the code can be executed as many times as you like and, given the same input data, will produce the same result (benefits of functionality!).

If there are side effects in the code, then they need to be wrapped in external.

Cycles

In principle, almost the same branching:

let confirmed = false;
while (!confirmed) {
    const confirmation = await conversation.wait();
    const confirmationText = confirmation.message.text.toLowerCase();
    if (confirmationText === 'yes') {
        // ...
        confirmed = true;
    } else if (confirmationText === 'no') {
        // ...
        confirmed = true;
    } else {
        await ctx.reply(`Please respond with "yes" or "no".`);
    }
}

but taking into account that if the loop was, say, at the second step and the user entered something unexpected three times (for example: “ok“, “sure“, “of cause“) and only then “yes“, then when moving to the next steps (third, fourth, …), when the entire dialogue is executed from the first step to the current one, in the second step the loop code will be played all times – for “ok“, “sure“, “of cause” And “yes“.

Well, that's how it works conversation V grammY. In general, a small fee for ease of use.

Conclusion

In this article we looked at how to organize interactive dialogues with users in telegram bots based on Node.js using the library grammY and its module conversations. We learned the basic principles of working with dialogs, including initialization, termination, branching, and looping, as well as how to handle side effects. Thanks to the simplicity and flexibility of this approach, you can create more complex and responsive applications that interact effectively with users. I hope that the knowledge gained will help you develop your own telegram bots and expand their functionality.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *