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

от автора

В этой статье я продолжаю делиться результатами изучения создания телеграм-ботов в 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. Мы изучили основные принципы работы с диалогами, включая инициализацию, завершение, ветвление и циклы, а также особенности обработки побочных эффектов. Благодаря простоте и гибкости этого подхода, вы можете создавать более сложные и отзывчивые приложения, которые эффективно взаимодействуют с пользователями. Надеюсь, что полученные знания помогут вам в разработке собственных телеграм-ботов и расширении их функциональности.


ссылка на оригинал статьи https://habr.com/ru/articles/852330/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *