javascript

Телеграм-бот на Node.js/grammY: Диалоги

  • среда, 23 октября 2024 г. в 00:00:07
https://habr.com/ru/articles/852330/

В этой статье я продолжаю делиться результатами изучения создания телеграм-ботов в nodejs, начатой в предыдущих публикациях (раз, два). На этот раз я покажу, как организовать интерактивные диалоги с пользователями, используя модуль conversations библиотеки grammY. Мы рассмотрим, как настроить библиотеку для работы с диалогами, управлять их завершением, а также реализовать ветвления и циклы. Этот подход станет основой для более сложных проектов, где важно взаимодействие с пользователем.

Введение

В разрабатываемом диалоге бот, получив команду /start, сначала проверяет, зарегистрирован ли пользователь в базе данных. Если пользователь не зарегистрирован, бот предлагает ему пройти регистрацию. Затем отображается список доступных услуг для подписки, и бот запрашивает ввод номера услуги, повторяя запрос, пока не будет получен корректный номер. После этого бот выводит детали выбранного сервиса и просит подтвердить подписку. В случае согласия создаётся новая подписка, и диалог завершается; если же пользователь отказывается, диалог просто заканчивается.

Исходный код для реализации диалога доступен в репозитории flancer64/tg-demo-all (ветка conversation), сам бот - f64_demo_conversation_bot.

Conversations в grammY

Библиотека grammY предоставляет плагин conversations для создания диалогов между ботом и пользователями. В отличие от других фреймворков, которые требуют использования громоздких конфигурационных объектов, этот плагин позволяет определять диалоги через обычные функции JavaScript, что делает код более понятным и гибким. Каждое состояние диалога управляется с помощью простых функций, которые выполняются последовательно в ходе общения.

Рекомендуется следовать трем основным правилам при написании кода внутри функций-строителей диалогов.

  1. Все операции, зависящие от внешних систем, должны быть обернуты в специальные вызовы, чтобы избежать ошибок и потери данных.

  2. Следует избегать использования случайных значений напрямую; вместо этого необходимо использовать предоставленные функции для работы с рандомом.

  3. Следует использовать вспомогательные функции, предлагаемые библиотекой, которые упрощают работу с состояниями и переменными, обеспечивая более надежную работу диалогов (form, wait..., sleep, now, ...).

Важно также учитывать, что модуль conversations поддерживает параллельные диалоги, что позволяет взаимодействовать с несколькими пользователями одновременно. Это особенно полезно в групповых чатах, где бот может проводить диалоги с несколькими участниками. Такой подход делает разработку интерактивных приложений проще и эффективнее.

Инициализация диалога

Инициализация плагина conversation происходит в модуле Demo_Back_Bot_Setup и сводится к следующему:

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

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

Важно учитывать порядок подключения посредников (middleware) - conversations должны подключаться после session. Т.к. обрабатываться посредники будут в порядке подключения, а диалоги без сессий не работают.

Подключение и запуск диалога

Типовой код обработчика получает два параметра:

const conv = async (conversation, ctx) => {
    // ...
}
  • conversation: объект, управляющий состоянием текущего диалога.

  • ctx: стандартный контекст grammY, соответствующий текущему взаимодействию пользователя с ботом (сообщению).

Регистрация обработчиков производится там же, где и регистрация остальных просредников:

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

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

Вызов обработчика диалога из обработчика команды:

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

Завершение диалога

Диалог завершается, когда обработчик заканчивает свою работу (доходит до return ):

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

Если по каким-то причинам диалог не может завершиться штатно (например, пользователь вводит другую команду вместо того, чтобы следовать сценарию диалога), то можно диалог завершить принудительно через ctx.conversation.exit(). Например, так:

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

Имплементация сценария

Как уже было сказано выше, grammY "склеивает" отдельные сообщения от пользователя в один непрерывный поток. При этом, если в рамках диалога предусмотрена обработка, допустим, трёх последовательных сообщений, то обработчик диалога будет запущен три раза - по разу на каждое сообщение:

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

Т.е., если бот начал выполнять сценарий диалога, то conv-обработчик будет запускаться на каждое новое сообщение. Более того, при каждом новом запуске grammY будет передавать ему все предыдущие сообщения, переводя обработчик диалога в соответствующее состояние. Именно поэтому conversation предоставляет свой собственный генератор случайных чисел (случайное значение запоминается и выдаётся каждый раз для текущего диалога).

Скрытый текст

Вот код проверки существования сервиса, выбираемого пользователем:

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);

У бота есть только три сервиса (id:1,2,3), но пользователь неправильно указывает номера 4,5 и лишь затем 3. На каждой итерации бот проверяет существование всех предыдущих введённых идентификаторов:

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).

Побочные эффекты

Допустим, у нас в коде диалога есть вызов внешнего сервиса, который создаёт запись в БД.

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

Если запись в БД создаётся на первом шаге, а в диалоге всего три шага, то первый шаг будет повторён трижды, причём с одними и теми же параметрами. Т.е., три раза будет вызван сервис по созданию одной и той же записи.

Для отработки подобных "побочных эффектов" плагин conversation предоставлет метод external:

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

В таком виде создание пользователя будет выполнено только один раз, при самом первом вызове метода external. В последующие разы для этого диалога будет возвращаться результат самого первого выполнения, а внешний сервис "дёргаться" не будет.

Если же выполнение внешнего сервиса зависит от введённых пользователем данных (например, поиск услуги по идентификатору), то метод external можно вызывать в таком виде:

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

});

В этом случае сохраняются и переиспользуются пары "аргументы - результат".

Скрытый текст

Предыдущий пример с поиском сервиса можно переписать в таком виде:

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);

Видно, что внешний сервис (Demo_Back_Mod_Service) более повторно не вызвается для неправильных значений (4 и 5), хотя сервис Demo_Back_Mod_User вызывается каждый раз (как необёрнутый в 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).

Ветвление

С ветвлением всё просто, а если код диалога не создаёт побочных эффектов, то даже очень просто:

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

Без побочных эффектов (в том числе и сохранения состояния) код может выполняться сколько угодно раз и при одних и тех же входных данных будет давать один и тот же результат (преимущества функциональщины!).

Если же в коде есть побочные эффекты, то их нужно оборачивать в external.

Циклы

В принципе, практически то же самое ветвление:

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".`);
    }
}

но с учётом, что если цикл стоял, допустим, на втором шаге и пользователь три раза ввёл что-то неожиданное (например: "ok", "sure", "of cause") и только потом "yes", то при переходе на следующие шаги (третий, четвёртый, ...), когда весь диалог будет выполняться с первого шага и до текущего, на втором шаге код цикла будет проигрываться все разы - для "ok", "sure", "of cause" и "yes".

Ну, вот так работает conversation в grammY. Небольшая в общем-то плата, за удобство использования.

Заключение

В данной статье мы рассмотрели, как организовать интерактивные диалоги с пользователями в телеграм-ботах на базе Node.js с использованием библиотеки grammY и её модуля conversations. Мы изучили основные принципы работы с диалогами, включая инициализацию, завершение, ветвление и циклы, а также особенности обработки побочных эффектов. Благодаря простоте и гибкости этого подхода, вы можете создавать более сложные и отзывчивые приложения, которые эффективно взаимодействуют с пользователями. Надеюсь, что полученные знания помогут вам в разработке собственных телеграм-ботов и расширении их функциональности.