Сегодня, в предпоследний день уходящего года, хочу рассказать о созданном мной сервисе, помогающем быстро проектировать, отлаживать и следить за работоспособностью ботов в мессенджере Телеграм. Надеюсь, он окажется удобным инструментом. Под катом — довольно подробный рассказ о том, как этот сервис зародился, какие технологии я для него выбрал и обзор того, что он сейчас умеет.
Для тех же, кому уже захотелось ознакомиться с Botsman (но не очень хочется много читать) — вот ссылка, милости прошу: https://bots.mn/. Главное, о чём стоит помнить — платформа только-только запустилась, и (пока что) не стоит переносить на неё что-то серьёзное и масштабное.
Предыстория: путь к созданию Ботсмана
Пять лет назад в Telegram появилась возможность создавать ботов — автоматизированные аккаунты, которыми можно управлять с помощью своих скриптов. Меня это сразу заинтересовало: я люблю делать маленькие, но полезные утилитки.
Правда, в отличие от десктопных приложений, браузерных расширений и простых веб-сервисов, с ботами есть некоторая сложность в их разработке: в отличие от традиционных веб-сервисов, их труднее отлаживать. Даже если настроить логирование, бывает трудно понять, что именно происходит с ботом и всё ли работает так, как нужно.
Поэтому почти сразу пришла в голову идея сделать такую платформу, которая возьмёт весь мониторинг на себя: будет логировать и входящие обновления, и то, как бот на них реагирует (и какие возникают при этом ошибки). Заодно можно и графики удобные строить.
Но, к сожалению, дальше придумывания красивого названия — Botsman — и покупки короткого домена bots.mn дело поначалу не зашло. Проект оказался непривычно большим для того, чтобы осилить его в одиночку, и за пять лет я то брался за него, то снова откладывал на потом.
И только к концу злополучного 2020-го я нашёл в себе силы довести его до рабочего состояния, которое можно показать широкой публике. Специально поставил цель зарелизиться до Нового года: чтобы добавить этому году плюс одно хорошее событие :)
Итак, что же сейчас предлагает данная платформа?
Проксирование запросов
Во-первых, мой сервис умеет работать как прокси: вы подключаете бота к Botsman, он ловит все приходящие от Telegram события, логирует, и передаёт вашему серверу. Это удобно, если у вас уже написан и где-то крутится бот: ваш код может даже не подозревать про эту прослойку.
У этой фичи есть очевидный недостаток: небольшое увеличение времени отклика (поскольку в цепочке «Telegram
→ ваш сервер
» появляется дополнительное звено).
Но этой осенью Telegram сделал крутую вещь: они выложили в открытый доступ код сервера-посредника Bot API. По своей сути это такое приложение, которое внутри общается с Телеграмом как клиент по их протоколу MTProto, а снаружи у него торчит уже простое и понятное Bot API. И когда вы обращаетесь к публичному Bot API по HTTPS — запрос на самом деле идёт к инстансу такого сервера, а теперь стало можно поднять его самому. И конечно же, внутри Ботсмана я так и сделал (и это новшество оказалось ещё одним мотиватором закончить проект).
Таким образом, вашего бота можно настроить так, что цепочка не станет длиннее: вместо
«Telegram
→ сервер Bot API
→ ваш сервер
» будет
«Telegram
→ Botsman
→ ваш сервер
».
Правда, тут уже потребуются правки в коде вашего бота: исходящие запросы придётся делать не на api.telegram.org
, а на api.bots.mn/telegram
. Зато Botsman сможет логировать и их тоже!
Собственно, поговорим о логировании:
«Живая» лента обновлений
После настройки бота в Botsman, можно сразу открыть страницу «Events», отправить что-то в Телеграме своему боту, и увидеть, как это сообщение появилось на экране в реальном времени. Если у вас настроен прокси — вы увидите и результат перенаправления запроса вашему серверу. Если ваш сервер шлёт запросы через проксирующий эндпоинт api.bots.mn/telegram
— они тоже туда попадут.
С технической стороны, конечно, тут всё просто: я использую веб-сокеты чтобы проталкивать все обновления на клиент, если у вас открыта соответствующая вкладка (если открытых страниц нет, лишней нагрузки это создавать не должно). Возможно, если бы я начинал этот проект сейчас, я бы попробовал использовать Server-sent events вместо веб-сокетов, но вряд ли есть какой-то повод переходить на них.
Кроме бесконечного лога, куда все события сыпятся вперемешку, Botsman ещё умеет показывать интерфейс, похожий на обычный список диалогов в самом мессенджере — то, как их «видит» ваш бот. При желании можно даже никак не обрабатывать запросы, а просто вручную открывать диалоги и отвечать от имени бота (этакий режим техподдержки — возможно, впоследствии я добавлю поддержку управления ботами с нескольких аккаунтов с разными ролями, если это действительно будет актуально).
Слежение за показателями бота
Ну и конечно же, статистика и графики, куда без них. Честно говоря, аналитик из меня так себе, поэтому сейчас Botsman показывает только довольно базовые метрики — общее число апдейтов, число чатов, число пользователей, дневную и месячную аудиторию (DAU и MAU). Графики — по числу апдейтов на каждый день/час, и по среднему времени обработки запросов. Было бы, конечно, интересно смотреть на всякую демографию, но в Telegram в этом плане мало информации о пользователях.
Для подсчёта статистики я с самого начала выбрал старый добрый Redis, а вот на клиентской стороне решил рисовать графики с помощью библиотеки, которую до этого сам же написал на конкурс того же Телеграма. Не пропадать же добру :)
Скриптинг
Но вернёмся к тому, как вообще «оживить» вашего бота. Просто проксировать запросы к другому серверу скучно (да и от дополнительного «звена» всё-таки лучше избавиться). Мне хотелось дать возможность прямо внутри сервиса описать логику работы бота. Знаю, что существует множество конструкторов ботов для этого (хотя я и старался всё делать без оглядки на них), но мне хотелось возможности писать полноценный код прямо в браузере.
При этом мне не очень хотелось прибегать к компилируемым в нативный код языкам: это производительно, но тогда пришлось бы строить сложную систему, оборачивая код каждого бота в контейнер и держать их все непрерывно запущенными.
Поэтому я выбрал JavaScript: моя изначальная идея была взять встроенную в Node песочницу, немного доработать (как это сделано в библиотеках Sandcastle или vm2), чтобы сделать её безопаснее, и выполнять код ботов в ней.
Но в процессе очередного «подхода» к допиливанию проекта пришло осознание, что даже с доработками такой подход неидеален: код в таком случае выполнялся бы все равно в одном общем потоке и при росте нагрузке неизбежно привёл бы к тормозам. Да и риск проблем с изолированностью исполняемого кода всё-таки сохраняется.
В итоге я обратил внимание на библиотеку isolated-vm: она тоже реализует песочницу в JS, но делает это другим, более безопасным (и, что важно, многопоточным) образом. По сути это обёртка над присутствующим в V8 механизмом «изолятов» — независимых контекстов, которые ничего не знают друг про друга. Эта же библиотека, кстати, используется в игре Screeps, где игрокам тоже нужно писать своих ботов.
Что особенно хорошо для ботов: состояние одного такого изолята можно держать в памяти как кусок данных (не расходуя процессорное время), и только когда боту понадобится обработать прилетевший апдейт от Телеграма — «оживлять» это состояние, вызывая в нём функцию-обработчик. И конечно можно ограничить как потребляемую память, так и процессорное время (с этим, помнится, в нодовской песочнице были некоторые сложности).
Скриптинг: внутреннее API, обработчики событий
Дальше одним из камней преткновения было дать удобное внутреннее API для написания ботов. Конечно, можно было сказать «вашему коду будет доступна переменная update и метод callMethod, а дальше делайте, что хотите». Но раз уж я проектирую всю песочницу, нужно идти до конца.
В частности, мне хотелось реализовать механизм «состояний»: часто боты рассчитаны на пошаговый диалог, и это надо учесть, как-то запоминать контекст, даже если пользователь бросил диалог и вернулся к нему на следующий день. В итоге я решил, что для каждого чата, пользователя или сообщения я позволю хранить объект с произвольными полями. А у чатов (как личных, так и групповых) в придачу есть понятие «пути», route — оно позволяет раскидать обработчики событий по тому, на каком шаге находится пользователь.
Само добавление обработчиков делается довольно просто:
on(ctx => {
ctx.log('Some update received: ', update);
});
Это самый универсальный обработчик: он вызывается при любом событии (напомню, кроме события «пришло сообщение» Telegram присылает и ряд других оповещений).
Перед функцией-коллбэком можно указать один конкретный тип события: тогда обработчик будет вызываться исключительно для него.
on('message', ctx => {
ctx.message.reply('Hi!');
});
А что будет, если объявить два обработчика и они оба подходят для текущего апдейта? Botsman вызовет только первый из них — но можно передать управление следующему, если вернуть false
(ну или промис, резолвящийся в false
— разумеется, всё делалось с расчётом на асинхронный код).
Ещё есть удобные способы обработывать только текстовые сообщения с помощью on.text
(их можно заодно ещё и фильтровать по регэкспу), только команды — с помощью on.command
, инлайн-запросы — on.inline
, и коллбэк-запросы (нажатия на кнопки под отправленными сообщениями) — on.callback
. О них можно почитать в документации.
В примерах выше видно, что обработчику передаётся единственный параметр — экземпляр класса EventContext, и через него делаются все вызовы. Это позволяет Ботсману связывать, например, вызовы API или возникающие ошибки с конкретным апдейтом, который к ним привёл.
Ну а как разграничить обработчики для разных состояний (путей) чата? Для этого предназначена глобальная функция state
:
state('step1', (on, state) => {
// Этот обработчик вызовется для любого сообщения,
// если наш чат в состоянии 'step1' - и переведёт его
// в состояние 'step2'
on.text(ctx => {
ctx.route.to('step2');
});
});
state('step2', (on, state) => {
// А этот обработчик вызывается, если наш чат в
// состоянии 'step2' и возвращает его в 'step1'
on.text(ctx => {
ctx.route.to('step1');
});
});
Обратите внимание: функция state
немедленно вызывает переданный ей коллбэк с двумя аргументами, которые заменяют собой глобальные функции on
и state
. Добавленный с помощью локальной функции on
обработчик будет вызываться только в указанном состоянии, а с помощью локальной функции state
можно создавать «вложенные» состояния (хотя их можно создать и вручную, просто записывая путь, разделённый слэшами: 'step1/substep1/branchA'
). Пока что, впрочем, иерархическая структура состояний особых преимуществ по сравнению с линейной не имеет (но может помочь их логически упорядочить).
Скриптинг: форматируем сообщения с помощью tagged template literals
Отдельно поделюсь одной, казалось бы, незначительной, но весьма радующей лично меня деталью. Если вы уже пробовали использовать Telegram API, то возможно сталкивались со сложностями при отправке текста с форматированием — особенно когда в него нужно подставить пользовательские данные. Telegram умеет принимать и HTML, и Markdown, но и в том, и в другом случае подставляемые данные нужно обрабатывать, эскейпить управляющие символы, что не очень удобно.
К счастью, не так давно при отправке сообщения (и в других методах, где можно отправлять форматированный текст) появилась возможность просто указать отдельно, какие участки в нём нужно отформатировать. Добавляем к этому tagged templates из ES6 и получаем вот что:
await ctx.call('sendMessage', {
chat_id: 12345,
...fmt`Hello ${fmt.bold(foo)}! You can combine ${fmt.italic(bar).bold()} styles together.
Links are supported ${fmt.text_link(linkLabel, linkUrl)}.`,
});
Выглядит немного непривычно, зато а) не нужно ничего эскейпить, б) невозможно сломать вёрстку, потеряв какой-нибудь HTML-тэг или символ разметки Markdown. Если у вас валидный JS — будет и валидная вёрстка. Под капотом запись fmt`something`
возвращает объект с двумя полями — text
и entities
— поэтому его нужно распаковывать с помощью ...
(spread syntax). Ну или его можно передать напрямую в короткие методы типа ctx.message.reply(fmt`something`)
или ctx.chat.say(fmt`something`)
.
Мне кажется, что у tagged template literals вообще не очень много уместных применений в реальном мире, но тут у меня получилось найти одно из них :)
Скриптинг: код по расписанию и запросы к внешним API
Должен сделать важную оговорку: так как код выполняется в изолированных контекстах, у скриптов нет ни доступа к API самой Node, ни возможности импортировать внешние модули. Однако я реализовал метод fetch
(по аналогии с одноимённым браузерным API) — он позволяет делать не слишком тяжёлые запросы к внешним серверам. Кроме того, доступна глобальная функция cron
— с помощью неё можно запланировать регулярное выполнение повторяющихся действий:
cron('0 0 * * FRI', ctx => {
ctx.log('This function should execute each Friday at midnight');
});
Первый параметр тут — либо привычный формат crontab, либо интервал в секундах (правда, и то, и другое с разрешением не чаще раза в пять минут). Я думаю, что возможности выполнять код по расписанию и делать запросы к внешним API позволят реализовать множество практических задач. И не исключено, что в будущем список возможностей (например, импорт каких-то проверенных модулей) будет расширяться.
Скриптинг: веб-интерфейс
Писать код прямо в браузере — не самое комфортное занятие, но я стремлюсь сделать его хотя бы сравнимым с разработкой в IDE. Сейчас в качестве редактора я использую CodeMirror (в первую очередь из-за его относительно небольшого веса), в дальнейшем, вероятно, добавлю возможность переключиться на Monaco — он ощутимо тяжелее, зато должен быть шустрее и функциональнее (именно он используется внутри VS Code).
Пока что в Botsman нет разбивки кода на отдельные «файлы» — в этом тоже может быть неудобство, если вы собираетесь писать сложного бота. Зато код сохраняется автоматически, и есть возможность хранить все его предыдущие версии — это ещё не Git, но всё же.
И ещё одна небольшая, но довольно ценная возможность: проверка кода перед «деплоем» с помощью тестов. При этом Botsman по нажатию одной кнопки сразу покажет подробный лог выполнения (см. скриншот выше): время инициализации, обработки запроса, отладочный вывод и возникшие ошибки (код с синтаксическими ошибками задеплоить не выйдет в принципе).
Песочница для запросов к Telegram
Ну и последняя небольшая, но довольно полезная фича Botsman, о которой хотелось бы рассказать — панель для формирования запросов к Telegram. Нередко социальные сети предоставляют собственный инструмент для этого, но у Телеграма его, к сожалению, нет. Поэтому я сделал страницу, где можно выбрать нужный метод API, заполнить его параметры и наглядно увидеть результат вызова. Заодно можно сразу скопировать эквивалентный код, делающий этот запрос.
Будущие планы
Как было сказано ранее, Botsman находится в самом начале своего пути. Возможно, его даже настигнет Хабраэффект (надеюсь, что нет!). Возможно, ему станет тяжко, если созданные на нём боты наберут популярность — не исключено, что тогда придётся вводить платные возможности. Поскольку занимаюсь им я сейчас в одиночку, сложно сказать, что с ним будет.
В очень примерных планах сейчас такие фичи:
-
Визуальный конструктор в дополнение к скриптингу
-
Глобальное key-value хранилище + создание собственных хранилищ
-
Поддержка других платформ, кроме Telegram
-
Доступный снаружи эндпоинт для вызова кода бота
-
Управление ботом с нескольких аккаунтов
-
Навигация по коду, разбивка на модули, поддержка сторонних модулей
-
Более гибкое тестирование кода, автоматические тесты
-
Больше статистики и графиков
-
Оповещения (если с ботом что-то не так)
-
Улучшение вида чатов
-
Улучшение работы с файлами (скачивание, загрузка), в том числе в песочнице
В любом случае, надеюсь, что из этого сервиса выйдет что-то хорошее! Буду рад услышать ваши мысли, пожелания и соображения в комментариях.
Автор: Денис Ольшин