...

воскресенье, 20 октября 2019 г.

Telegraff: Kotlin DSL для Telegram

Лого

На Хабре тысячи статей про то, как сделать Телеграм бота под разные языки программирования и платформы. Тема далеко не новая.

Но Telegraff – лучший фреймворк для реализации Телеграм ботов и я это под катом докажу.


Преамбула

В 2015 году российский рубль лихорадило. У меня были сбережения в долларах и я буквально каждые пять минут проверял курс, чтобы продать валюту по нужному мне курсу. Лихорадка затянулась, я устал и написал Телеграм бота (@TinkoffRatesBot), который оповещает, в случае достижения курса валют порогового (ожидаемого) значения.
Меня очень тронула эта задача. Бота написал довольно быстро, но только вот удовлетворения не получил.

В интеграции с Телеграм нет и не было никаких проблем. Этот вопрос решается за пару часов. И я даже удивлён, что есть целые библиотеки на Java (субъективно, с отвратительным по качеству кодом) по интеграции с Телеграм, заработавшие больше тысячи звезд на Github.

Основным вызовом для меня стала система сценариев: пользователь вызывает команду, например, "/taxi", бот задаёт ему ряд вопросов, каждый ответ валидируется и может влиять на порядок последующих вопросов, формируется привычная "форма", отдаётся в конечный метод на обработку для формирования ответа.
Я сделал это, но структура классов, уровни абстракции, все это было так неоднородно, что на это было горько смотреть. Меня мучал вопрос: Как это лаконично и органично перенести в объектно-ориентированную модель?

Хотелось иметь что-то простое, удобное, а самое главное — иметь возможность описать весь сценарий в одном изолированном файле, чтобы не нужно было просматривать половину проекта, чтобы понять цепочку взаимодействия с пользователем.

Не сказать, что вопрос стоял очень остро, потому что задача была уже решена. Скорее, иногда я подумывал о нем. В мыслях был Groovy DSL, но когда появился Kotlin, выбор стал очевиден. Так появился Telegraff.

Да, конечно, не было никакого соревнования, в котором бы победил Telegraff. И утверждения о том, что Telegraff – лучший, не нужно воспринимать буквально. Но Telegraff – новый, уникальный подход в рамках этой задачи. Легко быть лучшим, будучи единственным.


Как этим пользоваться?


Зависимости

Первым делом нужно указать дополнительный репозиторий для зависимостей. Возможно, на определенном этапе я опубликую Telegraff в Maven Central или в JCenter, но пока так.


Gradle
repositories {
    maven {
        url "https://dl.bintray.com/ruslanys/maven"
    }
}

Maven
<repositories>
    <repository>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
        <id>bintray-ruslanys-maven</id>
        <name>bintray</name>
        <url>https://dl.bintray.com/ruslanys/maven</url>
    </repository>
</repositories>

Осталось дело за малым. Для использования Telegraff нужно указать лишь одну зависимость spring-boot-starter:


Gradle
compile("me.ruslanys.telegraff:telegraff-starter:1.0.0")

Maven
<dependency>
    <groupId>me.ruslanys.telegraff</groupId>
    <artifactId>telegraff-starter</artifactId>
    <version>1.0.0</version>
</dependency>

Конфигурация

Конфигурация проекта проста и может ограничиваться первыми двумя-тремя параметрами:


application.properties
telegram.access-key=123 # ①
telegram.mode=webhook # ②
telegram.webhook-base-url=https://ruslanys.me # ③
telegram.webhook-endpoint-url=/telegram # ④
telegram.handlers-path=handlers # ⑤
telegram.unresolved-filter.enabled=false # ⑥

  1. Ваш ключ к Telegram API.
  2. Режим получения сообщений (обновлений) от Telegram. Может принимать значение «polling» или «webhook».
  3. Если метод получения обновлений указан «webhook», обязательно необходимо указать путь до вашего приложения.
  4. При желании можно указать собственный путь до эндпоинта. В случае, если этот параметр не переопределен, будет сгенерирован путь следующего вида: /telegram/${UUID}. Перед запуском приложения указанный адрес устанавливается в качестве адреса веб-хука. При завершении работы адрес веб-хука затирается, чтобы иметь возможность при следующем запуске переключиться на поллинг.
  5. При желании можно изменить папку, в которой будут находиться сценарии обработчиков. По умолчанию это папка handlers.
  6. В «поставку» входит UnresolvedFilter и по умолчанию он включен. В случае, если на сообщение пользователя не был найден ни один обработчик, UnresolvedFilter отвечает чем-то, вроде «Извини, я тебя не понимаю :(».

Пора писать сценарии!


Обработчики

Обработчики (сценарии) – ключевая часть Telegraff. Именно здесь задается цепочка взаимодействия с пользователем. Суть в том, что каждая команда, вроде «/start», «/taxi», «/help» – это отдельный сценарий/скрипт/обработчик/handler.

Сценарий может содержать набор шагов (вопросов), необходимых пройти пользователю, для того, чтобы выполнить команду. Другими словами, пользователь должен заполнить форму. И так, как из интерфейса – мессенджер, надо с пользователем разговаривать, спрашивать.

Надо ли объяснять, что ответы пользователя нужно валидировать? Первым делом пользователь то и сделает, что ответит не так, как вы ожидаете.

Ну и в конце концов, сценарий может ветвиться, т.е. каждый ответ на вопрос может влиять на порядок последующих.

К примеру!

Для того, чтобы начать, положите файл с расширением .kts в папку c ресурсами handlers: src/main/resources/handlers/ExampleHandler.kts.


Сценарий вызова такси
enum class PaymentMethod {
    CARD, CASH
}

handler("/taxi", "такси") { // ①
    step<String>("locationFrom") { // ②
        question { // ③
            MarkdownMessage("Откуда поедем?")
        }
    }

    step<String>("locationTo") {
        question {
            MarkdownMessage("Куда поедем?")
        }
    }

    step<PaymentMethod>("paymentMethod") {
        question { state ->
            MarkdownMessage("Оплата картой или наличкой?", "Картой", "Наличкой") // ④
        }

        validation { // ⑤
            when (it.toLowerCase()) {
                "картой" -> PaymentMethod.CARD
                "наличкой" -> PaymentMethod.CASH
                else -> throw ValidationException("Пожалуйста, выбери один из вариантов") // ⑥
            }
        }

        next { state ->
            null // ⑦
        }
    }

    process { state, answers -> // ⑧
        val from = answers["locationFrom"] as String
        val to = answers["locationTo"] as String
        val paymentMethod = answers["paymentMethod"] as PaymentMethod // ⑨

        // Business logic

        MarkdownMessage("""
            Заказ принят от пользователя #${state.chat.id}. 
            Поедем из $from в $to. Оплата $paymentMethod.
        """.trimIndent()) // ⑩
    }
}

Ключи степов нарочито небыли вынесены в константы. В продакшене, конечно, такого лучше избегать.

Разберемся:


  1. Объявляем сценарий. Требуется к заполнению, как минимум одно имя команды. В данном случае команды две: «/taxi», «такси». В случае, если сообщение пользователя будет начинаться с этих слов, будет вызван соответствующий обработчик.
  2. Определяем шаги (вопросы). Требуется к заполнению уникальное имя шага, т.к. в последующем, к ответу пользователя можно будет обратиться именно по этому ключу («locationFrom»).
  3. Каждый шаг содержит три секции, первая из них – сам вопрос. Вопрос – это обязательная секция, которая должна присутствовать в каждом шаге. Без вопроса в шаге смысла нет.
  4. Оформлять вопрос можно как угодно. В данном случае пользователю будет предложено через клавиатуру выбрать один из вариантов: «Картой» или «Наличкой». В качестве результата вызова этого блока, должен быть объект типа TelegramSendRequest. Извините, по имени ничего лучше придумать не смог, чем суффикс SendRequest, характеризующий структуру, как исходящий запрос в Telegram.
    Структура классов
  5. Вторая по важности секция шага – проверка ответа пользователя. Тип каждого шага параметризован (дженерик), а следовательно, блок валидации должен возвращать именно тот тип, которым параметризован его шаг.
  6. В случае, если ответ пользователя неудовлетворителен, можно выбросить ValidationException с уточняющим текстом, но той же клавиатурой, если она была указана в вопросе.
  7. Заключительная секция шага – блок, указывающий на следующий этап. По умолчанию, шаги будут исполняться в порядке их объявления, сверху вниз. Но на этот процесс можно повлиять, переопределив соответствующий блок. В качестве результата выполнения этого блока можно вернуть либо ключ следующего шага (String), либо «null», свидетельствующий о том, что шагов больше нет и пора перейти к исполнению команды.
  8. Когда запрос пользователя сформирован, требуется его обработка. В качестве аргументов в лямбде выступают Состояние (это что-то вроде сессии) и ответы пользователя.
  9. Обратите внимание, что провалидированный ответ – больше не строка ответа пользователя, а уже обработанный объект нужного типа.
  10. Реакция на команду может быть любой, аналогично п. 4. В случае, если ответ на команду не требуется, можно вернуть «null».

Обработчик может и не иметь шагов вовсе. В таком случае, нужно определить лишь поведение обработчика на вызов команды.


Сценарий приветствия
handler("/start") {

    process { _, _ ->
        MarkdownMessage("Привет!")
    }

}

Пробуем

Для того, чтобы попробовать, форкаем репозиторий, клоним на локальную машину и заходим в папку telegraff-sample. Конфигурируем, запускаем, трогаем!

Вообще telegraff-sample – нарочито независимый проект, который не связан с родительским и имеет даже собственный Gradle Wrapper. Можно оставить только эту папку. Это своего рода архетип.


Как это устроено?


Telegram

Интеграция с Telegram очень проста и реализована в TelegramApi.

Каждый метод был нарочито реализован индивидуально в силу ряда обстоятельств: начиная от того, что используется спринговский RestTemplate (и тесты под него), заканчивая специфичностью API от Telegram.

Как можно было заметить из конфигурации, в Telegraff существуют два типа клиентов этого API: PollingClient, WebhookClient. В зависимости от конфигурации, будет объявлен тот или иной бин.

И хотя методы получения обновлений (новых сообщений) от Telegram отличаются, суть неизменна и сводится к одному – публикации события (TelegramUpdateEvent) о новом сообщений через спринговский EventPublisher (паттерн «Наблюдатель»). При желании можно реализовать собственного слушателя, подписавшись на этот тип событий. Логичный, как мне кажется, слой абстракции, ведь абсолютно не имеет значения каким именно образом было получено сообщение.


Фильтры

Как только было получено новое сообщение, требуется его обработать и ответить пользователю. Для этого сообщению нужно пройти через цепочку фильтров.

Это похоже на привычные для Java программистов фильтры из Java EE. Разница лишь в том, что так называемые Обработчики (если проводить параллель с Java EE, то это Сервлеты) не являются независимыми от фильтров, а являются их частью.

Цепочка фильтров

Итак, фильтры упорядочены и могут пускать сообщения дальше по цепочке, могут нет.

LoggingFilter, очевидно, самый приоритетный (первый) фильтр, который будет вызван в рамках процесса обработки нового сообщения. Залогирует информацию по входящему сообщению и отправит его дальше по цепочке. Я нарочно добавил LoggingFilter в пример. На деле же в нем смысла может не быть, т.к. входящие сообщения логируются на уровне клиентов.

Следующий фильтр – CancelFilter. Он, по сути, работает в связке с HandlersFilter и является его дополнением. Его задача проста: если пользователь хочет отказаться от текущего сценария, он может написать «/cancel», либо «отмена» и его Состояние (сессия) должно быть очищено. Он может начать любой новый сценарий, не завершив предыдущий. По этой причине CancelFilter «выше» (приоритетнее) HandlersFilter.

HandlersFilter – основной фильтр в текущем процессе. Именно этот фильтр хранит состояния пользовательских чатов, находит и вызывает нужный обработчик (сценарий), применяет блоки валидации, определяет порядок шагов и отвечает пользователю.

В случае, если HandlersFilter не нашел ни одного подходящего обработчика для пользовательского сообщения ни в сессии, ни по содержимому, сообщение отправляется дальше по цепочке. Крайним фильтром является UnresolvedFilter. Это фильтр, который знает, что он последний, поэтому его функциональность проста: если дошли до меня, то как отвечать на сообщение – непонятно, скажу, что ничего не понял. Как мне кажется, лучше хоть какие-то сообщения от бота получать, если он не знает как реагировать, чем ничего совсем не получать.

Для того, чтобы добавить свой фильтр, нужно объявить Bean класса TelegramFilter и указать аннотацию @TelegramFilterOrder(ORDER_NUMBER).


Пример фильтра
@Component
@TelegramFilterOrder(Integer.MIN_VALUE)
class LoggingFilter : TelegramFilter {

    override fun handleMessage(message: TelegramMessage, chain: TelegramFilterChain) {
        log.info("New message from #{}: {}", message.chat.id, message.text)
        chain.doFilter(message)
    }

    companion object {
        private val log = LoggerFactory.getLogger(LoggingFilter::class.java)
    }

}

Именно таким образом в @TinkoffRatesBot реализован «калькулятор». Без вызова всякого сценария и команды можно отправить число, например, «1000», или даже целое выражение, например, «4500 * 3 — 12000». Бот посчитает результат выражения, к результату применит актуальные курсы валют и выведет об этом информацию. По сути же, результатом подобных действий является исполнение CalculationFilter, который находится в цепочке ниже HandlersFilter, но выше UnresolvedFilter.


Обработчики

Система сценариев (обработчики) Telegraff построена на Kotlin DSL. Если очень вкратце, то это про лямбды и про билдеры.

Отдельно обозревать Kotlin DSL смысла не вижу, т.к. это совсем другой разговор. Есть замечательная документация от JetBrains и исчерпывающий доклад от i_osipov.


Нюансы

Этот раздел посвящен присутствующим особенностям. Все они, на мой взгляд, не являются критичными, некоторые из них можно исправить, некоторые нет. Но об этих аспектах необходимо знать.

Если у вас есть желание участвовать или знание того, как поправить тот или иной пункт из этого раздела, буду очень благодарен.


Telegram

Вероятно, слой интеграции с Telegram описан не полностью. Реализованы лишь те методы, которые мне были нужны. Если есть чего-то, чего вам лично не хватает, поправьте TelegramApi и засылайте PR!

Из важных частей на текущий момент – отсутствие поддержки inline-клавиатуры (это когда клавиатура прям под сообщением в ленте). Задача отягощена тем, что inline-клавиатуры нужно правильно «вписать» в действующую структуру так, чтобы это оставалось просто, удобно, изолированно. Уже есть хорошая идея по реализации этого функционала, но она еще ни в каком виде не реализована и не опробована.


Fat JAR

К сожалению, некоторые библиотеки, такие как JRuby и, вероятно, Kotlin Embedded Compiler (нужный для компиляции сценариев) могут иметь проблемы, являясь частью Far JAR. Fat JAR – это когда ваш код и все ваши зависимости упаковываются в один файл (*.jar).

Для того, чтобы эту проблему решить, можно распаковывать зависимости в runtime. То есть, когда приложение запускается, JARка зависимости из основного пакета разворачивается где-нибудь на диске и до нее указывается classpath. Сделать это довольно просто через конфигурацию bootJar:


Конфигурация плагина
bootJar {
    requiresUnpack "**/**kotlin**.jar"
    requiresUnpack "**/**telegraff**.jar"
}

Однако, для того, чтобы ссылаться из обработчиков (скриптов) к вашим бинам (сервисам, например), они тоже должны быть распакованы. Что, в принципе, нивелирует пользу такого подхода.

Как мне видится, наиболее надежным, простым и удобным методом остается использование Gradle плагина application. Более того, если вы контейнеризуете ваше приложение, то по итогу разницы нет.

Обо всем этом я довольно детально писал здесь.


Порядок инициализации

Здесь хотелось бы отметить два обстоятельства.

Во-первых, если обратить внимание на сценарий вызова такси, можно увидеть, что enum класс определен выше вызова handler(...). Эта необходимость навязана тем, что по факту, handler – вызов функции. Вызов функции, результатом которого должна быть некоторая структура, которую в последующем будет использовать Telegraff. Если по итогу исполнения вашего скрипта фабрика не сможет привести результат к нужному типу, вывалится ошибка на этапе инициализации.

Во-вторых, нужно помнить, что ваши скрипты могут быть проинициализированы раньше, чем все ваше приложение и бины. Если положить, например, ссылку на контекст в статическую переменную и первой же строкой в файле скрипта попытаться достать какой-нибудь сервис, может статься, что его не будет у контекста, т.к. он еще не был проинициализирован. Для того, чтобы на такие проблемы не натыкаться, воспользуйтесь этим методом Telegraff. Он гарантирует, что контекст будет проинициализирован и все нужные бины будут доступны. Пример можно посмотреть здесь.


Заключение

Захотелось попробовать — форкай,
Захотелось поправить — отправляй PR,
Захотелось отблагодарить — поставь звездочку в Github, лайкни пост и расскажи друзьям!

Репозиторий проекта

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий