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