Начав разрабатывать боты для 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.

Спасибо за внимание, буду рад услышать ваши вопросы, пожелания или идеи для новых ботов!