Начав разрабатывать боты для Telegram несколько лет назад, я открыл для себя производительность, простоту и гибкость работы с ними как с частным случаем интерфейса командной строки. Эти характеристики, доступные сегодня многим — во многом заслуга популярного фреймворка telegraf.js и ему подобных, которые предоставляют упрощенные методы для работы с API Telegram.
Архитектура проекта при этом целиком ложится на плечи разработчика и, судя по скромному числу комплексных и многофункциональных ботов, по этой части нам еще есть куда расти.
В этой статье я хочу рассказать о небольшом фреймворке для роутинга в чат-ботах, без которого разработка нашего проекта была бы невозможна.
Немного базовых сведений
В чат-ботах и CLI выполнение одного логического действия часто состоит из нескольких шагов-уточнений или шагов-разветвлений. Это требует от программы хранения некой координаты, чтобы помнить в каком месте в флоу находится пользователь и исполнения его команды в соответствии с этой координатой.
Самая простая иллюстрация — выполнение команды npm init, в ходе которой программа просит вас по очереди указать те или иные данные для package.json.
В первом шаге она сообщает пользователю, что ожидает текстовый ввод имени пакета — и то, что пользователь отправит ей следующей командой, будет сохранено как имя пакета благодаря переменной, в которую записано это ожидание.
Мы называем эту переменную path — путь, к которому логически привязывается тот или иной код. Она нужна всегда, если в боте есть навигация или команды, отдаваемые в несколько шагов.
Сегодняшняя практика в архитектуре ботов
Подход, подсмотренный мной вначале у других разработчиков, выглядел так: для любого обновления, приходящего от пользователя пишется список проверок на то или иное значение переменной path и внутрь этих проверок помещается бизнес-логика и дальнейшая навигация в самом элементарном виде:
onUserInput(ctx, input) {
switch(ctx.session.path) {
case 'firstPath':
if (input === 'Привет!') {
// Бизнес-логика и ответ
ctx.reply('Привет!');
ctx.session.path = 'secondPath';
} else {
ctx.reply('Скажи "Привет!"');
}
break;
case '...':
// И так далее
}
}
Если у вас всего пара команд и пара шагов на каждую команду, это решение оптимально. Приступая к третьей команде и седьмому if вы начинаете думать, что что-то происходит неправильно.
В одном из ботов, с которым нам довелось поработать на поздней стадии, ядром функционала была выросшая из двух if-ов простыня на 4000 строк и ~70 условий только верхнего уровня — с проверкой того, что на душу легло — иногда путей, иногда команд, иногда путей и команд. Все эти условия проверялись на каждое действие пользователя и обращались к вспомогательным функциям из соседнего объекта-простыни, также выросшего из нескольких строк. Надо ли говорить, сколь медленно и вразвалочку шел этот проект?
Hobot Framework
Начиная ActualizeBot мы уже представляли, каким большим он будет, и нашей первой задачей было сохранить, расширяемость и скорость разработки.
Для этого мы разбили клиентскую логику на контроллеры, закрепляемые за путями и написали небольшую абстракцию для навигации между этими контроллерами и обработки поступающих от пользователя сообщений в них.
Все это в расчете на большие проекты было написано на TypeScript и получило элегантное название Hobot, намекающее, конечно, на навигационные пайплайны.
Контроллер это простой объект из трех свойств:
- path — строковый идентификатор пути, используемый для инициализации и навигации по инициализированным путям
- get — метод, который выполняется, когда мы отправляем пользователя из другого контроллера с помощью метода hobot.gotoPath(ctx, path, data?). В него всегда отправляется контекст пользователя для работы с телеграмом и опционально — одноразовый объект data с кастомной информацией, которая может быть нужна вам в этом контроллере для логики или рендера
- post — тоже метод. Он выполняется всегда, когда пользователь отправляет сообщение или апдейт, находясь на данном пути. Всегда принимает контекст и updateType — один из типов апдейтов, которые может прислать пользователь: text, callback_query и так далее. updateType выдергивается из уже имеющегося ctx для лаконичности проверок действий пользователя
Пример контроллера:
const anotherController = {
path: 'firstPath',
get: async (ctx, data) =>
await ctx.reply('Welcome to this path! Say "Hi"'),
post: async (ctx, updateType) => {
// Проверяем тип и содержание апдейта: text / callback_query / etc...
if (updateType === updateTypes.text && ctx.update.message.text === 'Hi') {
await ctx.reply("Thank you!");
// hobot байндится в методы контроллеров в составе this:
this.hobot.gotoPath(ctx, 'secondPath', { userJustSaid: "Hi" });
} else {
// Не уходим с этого пути, продолжаем ждать от пользователя того что надо
await ctx.reply('We expect "Hi" text message here');
}
}
}
Выглядит чуть сложнее чем в начале, но зато сложность останется такой же, когда у вас будет 100 или 200 путей.
Внутренняя логика тривиальна
Эти контроллеры складываются в объект, ключами которого являются значения свойства path и вызываются из него по этим ключам при действиях пользователя или при навигации с помощью hobot.gotoPath(ctx, path, data?).
Навигация выделена в отдельный метод с целью не касаться переменной пути и логики навигации, а думать только о бизнес-логике, хотя всегда можно руками поменять ctx.session.path, что, конечно, не рекомендуется.
Все, что нужно сделать, чтобы ваш новый бот с нерушимой структурой заработал — запустить обычный telegraf-бот и передать его и объект конфига в конструктор Hobot. Объект конфига состоит из контроллеров, которые вы хотите инициализировать, дефолтного пути и пар команда / контроллер.
// Инициализируем обычный telegraf-бот
const bot = new Telegraf('ВАШ_ТОКЕН');
// Инициализируем фреймворк
export const hobot = new Hobot(bot, {
defaultPath: 'firstPath',
commands: [
// Список команд, по вводу которых будет выполняться
// get контроллера с данным путем:
{ command: 'start', path: 'firstPath' }
],
controllers: [
// Объекты-контроллеры, импортированные из своих файлов
startController,
nextController
]
});
// Cтартуем telegraf-бот, с которым мы можем продолжать свободно взаимодействовать
bot.launch();
В завершение
Неявные плюсы разделения простыни на контроллеры:
- Возможность поместить в отдельные файлы рядом с контроллерами изолированные функции, методы и интерфейсы, замыкающиеся на логику данного контроллера
- Значительное снижение риска случайно сломать все
- Модульность: включить / выключить / дать определенному сегменту аудитории ту или иную логику можно просто внося и удаляя из массива контроллеры, в том числе, апдейтом конфига без программирования — для этого, конечно, надо написать пару букв, так как мы до этих нужд еще не дошли
- Возможность внятно говорить пользователю, что именно от него ожидается, когда он (что бывает часто) делает что-то не так — и делать это там, где этому самое место — в конце обработки метода post
В наших планах:
В этой статье рассмотрен базовый сценарий работы Hobot с текстовыми сообщениями.
Если тема окажется актуальной, мы поделимся другими техническими тонкостями фреймворка и нашими наблюдениями из практики разработки и использования телеграм-ботов в дальнейших статьях.
Ссылки:
Установка бота выглядит так:
npm i -s hobot
Репозиторий с walkthrough в README.MD и песочница
Готовый бот, работающий в продакшене на базе Hobot.
Спасибо за внимание, буду рад услышать ваши вопросы, пожелания или идеи для новых ботов!