В этой статье я продолжаю делиться результатами изучения создания телеграм-ботов в nodejs, начатой в предыдущих публикациях (раз, два). На этот раз я покажу, как организовать интерактивные диалоги с пользователями, используя модуль conversations
библиотеки grammY
. Мы рассмотрим, как настроить библиотеку для работы с диалогами, управлять их завершением, а также реализовать ветвления и циклы. Этот подход станет основой для более сложных проектов, где важно взаимодействие с пользователем.
Введение
В разрабатываемом диалоге бот, получив команду /start
, сначала проверяет, зарегистрирован ли пользователь в базе данных. Если пользователь не зарегистрирован, бот предлагает ему пройти регистрацию. Затем отображается список доступных услуг для подписки, и бот запрашивает ввод номера услуги, повторяя запрос, пока не будет получен корректный номер. После этого бот выводит детали выбранного сервиса и просит подтвердить подписку. В случае согласия создаётся новая подписка, и диалог завершается; если же пользователь отказывается, диалог просто заканчивается.
Исходный код для реализации диалога доступен в репозитории flancer64/tg-demo-all (ветка conversation
), сам бот — f64_demo_conversation_bot.
Conversations в grammY
Библиотека grammY предоставляет плагин conversations для создания диалогов между ботом и пользователями. В отличие от других фреймворков, которые требуют использования громоздких конфигурационных объектов, этот плагин позволяет определять диалоги через обычные функции JavaScript, что делает код более понятным и гибким. Каждое состояние диалога управляется с помощью простых функций, которые выполняются последовательно в ходе общения.
Рекомендуется следовать трем основным правилам при написании кода внутри функций-строителей диалогов.
-
Все операции, зависящие от внешних систем, должны быть обернуты в специальные вызовы, чтобы избежать ошибок и потери данных.
-
Следует избегать использования случайных значений напрямую; вместо этого необходимо использовать предоставленные функции для работы с рандомом.
-
Следует использовать вспомогательные функции, предлагаемые библиотекой, которые упрощают работу с состояниями и переменными, обеспечивая более надежную работу диалогов (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/
Добавить комментарий