Лого


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


Но 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 (нужный для компиляции сценариев) могут иметь проблемы, являясь частью Fat JAR. Fat JAR – это когда ваш код и все ваши зависимости упаковываются в один файл (*.jar).


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


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

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


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


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


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


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


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


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


Заключение


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


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

Комментарии (37)


  1. ne_kotin
    20.10.2019 18:24

    По сравнению с тем же rubenlagus/TelegramBots — переусложнено.


    1. ruslanys Автор
      20.10.2019 18:53
      +2

      Перечитайте, пожалуйста, статью. Судя по комментарию, Вы совсем не поняли сути. Сравнивать Telegraff и TelegramBots просто бессмысленно. Библиотека, которую Вы указали, не способна решить проблемы, которые решает Telegraff.

      В TelegramBots все кончается на получении сообщения из Telegram в метод onUpdateReceived() (это первый раздел этой статьи об устройстве Telegraff). Я уже писал – эта задача тривиальна и на мой взгляд не требует сторонних зависимостей в принципе. Взгляните на пример TelegramBots (WeatherHandlers) и на перечень методов там: `onNewCurrentWeatherCommand`, `onLocationCurrentWeatherCommand`, `onCancelCommand` и т.д. и на то, как они вызываются:

      Обработка команд в TelegramBots
      private static SendMessage onForecastWeather(Message message, String language) {
          SendMessage sendMessageRequest = null;
          if (message.hasText()) {
              if (message.getText().startsWith(getNewCommand(language))) {
                  sendMessageRequest = onNewForecastWeatherCommand(message.getChatId(), message.getFrom().getId(), message.getMessageId(), language);
              } else if (message.getText().startsWith(getLocationCommand(language))) {
                  sendMessageRequest = onLocationForecastWeatherCommand(message.getChatId(), message.getFrom().getId(), message.getMessageId(), language);
              } else if (message.getText().startsWith(getCancelCommand(language))) {
                  sendMessageRequest = onCancelCommand(message.getChatId(), message.getFrom().getId(), message.getMessageId(),
                          getMainMenuKeyboard(language), language);
              } else {
                  sendMessageRequest = onForecastWeatherCityReceived(message.getChatId(), message.getFrom().getId(), message.getMessageId(),
                          message.getText(), language);
              }
          }
          return sendMessageRequest;
      }
      


      1. fougasse
        20.10.2019 19:26
        -3

        а это же не Котлин в примере, зачем сравнивать с джавой???


        1. ruslanys Автор
          20.10.2019 19:53
          +1

          Да какая разница? Ну представьте этот код на Kotlin, что изменится? Суть в том, что TelegramBots просто доставляет вам апдейты от Телеграм и все. Дальше вы уже сами читаете текст, находите нужную команду и т.д. Собственно, блок на Java отражает эти проблемы и суть здесь не в языке. Попробуйте реализовать при таком подходе сценарий с такси из статьи и поймёте разницу. TelegramBots это лишь обёртка над Telegram API. Telegraff — фреймворк, навязывающий определённый уровень абстракции для решения этой задачи. Решение может нравиться, а может нет, но сравнивать с TelegramBots это нельзя.


          1. fougasse
            20.10.2019 20:05
            -3

            как минимум на Котлин можно написать гораздо компактнее


            1. ruslanys Автор
              20.10.2019 20:07

              Сути это не меняет. Признаться, я считаю, и на Java можно это гораздо компактнее написать.


      1. ne_kotin
        20.10.2019 20:12
        -3

        Эм… Я кагбэ даже не одного бота на этой либе написал. Поэтому и говорю — ваша переусложнена. Шаги, фильтры. Что?!

        Я не хочу размазывать бизнес-логику по нескольким компонентам. Самый максимум — зарезолвить по команде и userid бин и передать ему сообщение — оставим за кадром поддержание состояния контекста.

        И тем более завертывать её (бизнес-логику) в лямбды.


        1. ruslanys Автор
          20.10.2019 20:20

          Чтобы разобраться в том, что такое шаги и фильтры достаточно прочитать эту статью.

          Бизнес-логику, как раз, не нужно размазывать. Ваш «контроллер» – это ваш обработчик (сценарий), не нужно бизнес-логику класть внутрь лямбд, возьмите нужный вам сервис из контекста и все.

          Повторюсь, TelegramBots это лишь обёртка над Telegram API. Telegraff — фреймворк, навязывающий определённый уровень абстракции для решения этой задачи. Решение может нравиться, а может нет, но сравнивать с TelegramBots это нельзя.

          Telegraff – не серебряная пуля и всех проблем не решает. Но каждый имеет право на свое мнение. В любом случае, мне очень жаль, что мне не удалось донести до Вас идею удобства Telegraff.


          1. ne_kotin
            20.10.2019 20:36
            -3

            Ваш «контроллер» – это ваш обработчик (сценарий)

            Нет. В одном боте может быть много сценариев, они могут быть нелинейны.

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

            Ваш код. Как я по коду должен догадаться, что шаг locationFrom ссылается в next на locationTo? по
            next { state ->
                        null // ?
                    }
            ?
            Отличный момент молчаливой скрытности котлиновского кода.

            Telegraff — фреймворк, навязывающий определённый уровень абстракции для решения этой задачи.

            Я пока не вижу «уровень абстракции», но вижу лишь бойлерплейт который позволяет быстро наколбасить визарда. Шаг влево-вправо — ой? Вот я внутри сценария жду команду. Т.е. со стороны пользователя отправил сначала /a потом может прилететь /b, /c, /d — как ваш фреймворк предлагает решать подобные кейсы?
            Статья на эту тему ничего не говорит.


            1. ruslanys Автор
              20.10.2019 20:50

              Т.е. со стороны пользователя отправил сначала /a потом может прилететь /b, /c, /d — как ваш фреймворк предлагает решать подобные кейсы?

              На самом деле, не очень понял Вашу проблему. Ну, опишите обработчик под каждую команду, да и все. Пример ниже. Каждую команду можно хранить в отдельном файле. В будущем будет еще и Hot Reload.

              Пример
              handler("/a") {
              
                  process { _, _ ->
                      MarkdownMessage("Привет!")
                  }
              
              }
              handler("/b") {
              
                  process { _, _ ->
                      MarkdownMessage("Привет!")
                  }
              
              }
              


              1. ne_kotin
                20.10.2019 20:58
                -3

                На самом деле, не очень понял Вашу проблему. Ну, опишите обработчик под каждую команду,

                Вооот. У вас каждая команда — это отдельный контекст с линейными шагами.
                В нашей модели команда — ручка к контексту.


                1. ruslanys Автор
                  20.10.2019 21:00

                  Я даю платные консультации, могу помочь решить вашу проблему более элегантным методом. В случае заинтересованности, пишите в ЛС.


                  1. ne_kotin
                    20.10.2019 21:03
                    -4

                    Я даю платные консультации

                    Я тоже )


            1. mayorovp
              21.10.2019 13:39

              Как я по коду должен догадаться, что шаг locationFrom ссылается в next на locationTo?

              Неужели того факта, что это именно что два шага, и они идут подряд, недостаточно?


              1. ne_kotin
                21.10.2019 13:45

                Абсолютно недостаточно. Более того, если простое перемещение двух блоков кода в тексте файла приводит к изменению поведения — это плохая структура языка. Потому что получается не то, что написано.
                Вот next = null по умолчанию — это логично, как и явное связывание в виде next = (тут какая-то переменная).


                1. mayorovp
                  21.10.2019 13:50

                  Почему это получается не то что написано?


                  Написано два шага в определенном порядке. Именно в этом порядке они и выполняются...


                  1. ne_kotin
                    21.10.2019 14:09

                    Написано два абсолютно не связанных между собой шага. Указания на то, что один от другого зависит — нету.


                    1. mayorovp
                      21.10.2019 14:16

                      Скажите, а когда вы в программе два оператора (которые statement) подряд пишете — вы тоже как-то указываете, что второй должен выполняться после первого?


                      1. ne_kotin
                        21.10.2019 14:26

                        операторы — нет.
                        но судя по нотации Step — ни фига не оператор


                        1. mayorovp
                          21.10.2019 15:14

                          А что тогда, если не оператор?


                          1. ne_kotin
                            21.10.2019 15:34
                            -2

                            Ну, выглядит похоже на класс.


                    1. ruslanys Автор
                      21.10.2019 14:34

                      Вы можете написать следующее:


                      Пример
                      step<String>("locationFrom") {
                          question {
                              MarkdownMessage("Откуда поедем?")
                          }
                      
                          next { "locationTo" }
                      }
                      
                      step<String>("locationTo") {
                          question {
                              MarkdownMessage("Куда поедем?")
                          }
                      
                          next { "paymentMethod" }
                      }


      1. ipodman
        24.10.2019 00:50

        В TelegramBots все кончается на получении сообщения из Telegram в метод onUpdateReceived()

        Вы не совсем правы, потому что у TelegramBots есть abilities
        В целом согласен, что в этой либе не хватает готовых решений, но почему решили написать свою библиотеку, а не построить а-ля фреймворк на основе TelegramBots?


        1. ruslanys Автор
          24.10.2019 01:22

          Вы правы, но дело в том, что abilities – это не то. Гляньте #Abstraction.


          Abilities, условно говоря, это кодогенератор (визард). Он не решает проблем, он упрощает API самой библиотеки, и его функциональность очень примитивна.


          Что касается вопроса о велосипеде и TelegramBots, объясню.
          Дело в том, что моё и ребят из TelegramBots представления о хорошем коде расходятся. Я не могу такое писать) Я вообще не понимаю, как можно было наплодить моделей с конструктором по умолчанию, игнорируя обязательные поля и пропихивать все через сеттеры, делать это везде и еще писать в доке.
          Я бы переписал чуть ли не всё. Но даже если переписывать, то точно на Котлин. А кому это нужно?


          Мне кажется, мы с ними концептуально различны. Я смотрю со стороны API разработчика, мне нужен удобный API, пускай и не полностью функциональный (те же inline-клавиатуры), но надежный и решающий проблему. Хотя цель TelegramBots, как раз, как мне кажется, покрыть API Telegram в первую очередь, а потом как-нибудь все прикрутить поудобнее.


          Может быть они посмотрят на Telegraff и захотят у себя переделать, и мир станет лучше)


          Вместе с тем, я считаю, что концептуально вся библиотека TelegramBots умещается в Telegraff в 3 класса: PollingClient, WebhookClient, TelegramApi.


          К тому же Telegraff – это не просто библиотека. Это, скорее, был проект в продакшене, который нужно было перевести на свой DSL. TelegramBots по указаным выше причинам я не хотел использовать, написал для себя и долго еще не мог написать статью, привести в порядок. По этой же причине Telegraff полностью на spring-boot. И по большому счету это легко поправить, но это не приоритетная задача. Конечно, спринг не каждому проекту подходит, но для Телеграм-бота почему бы и нет?


          Опять-таки, если кому-то понравилась идея, он хочет пользоваться, внес нужные для себя изменения и оформил в PR – честь и хвала.


          Была идея, что если кто-то поддержит донатом проект (но для этого нужно на английский перевести), то эти деньги буду выдавать за PR. Такой фонд. Да и сам готов в этот фонд вкладываться, поэтому пишите код, высылайте PR, отблагодарю)


  1. Prototik
    20.10.2019 18:36
    +2

    Есть куда стремиться. Можно еще больше упростить код и избавиться от "имён шагов", если использовать suspension и делегирование пропертей:


    val from by question { ... }
    val to by question { ... }
    // logic
    respond { ... }


    1. ruslanys Автор
      20.10.2019 18:54
      +1

      Стремиться, однозначно, есть куда (и думаю, всегда будет). Из ближайшего – inline-клавиатура.

      Идея с ключами шагов прекрасна, благодарствую! Попробую что-нибудь такое!


      1. Shockoway
        20.10.2019 19:18
        +2

        В своё время, тоже упаковал описание клавиатуры в DSL, но только поверх rubenlagus/TelegramBots


        val someKeyboard = keyboard {
            row {
                button("Yes") { callbackData = "yes" }
                button("No") { callbackData = "no" }
            }
            row {
                button("Cancel") { callbackData = "cancel" }
            }
        }


        1. ruslanys Автор
          20.10.2019 19:30
          +1

          Это очень похоже на то, как планируется решить вопрос с inline-клавиатурой. Спасибо, хороший пример!


  1. EXL
    21.10.2019 05:33

    Подскажите, а параметризованные команды поддерживаются?
    Что-то вроде /send <chat_id> <text_message> и подобное.


    1. ruslanys Автор
      21.10.2019 07:10

      Спасибо за вопрос! Сегодня — нет. Но уже такой запрос был, будет интерес (а вы своим вопросом его проявили), реализую. В принципе, если не заморачиваться и просто прокидывать содержимое сообщения (параметры), то это можно сделать очень быстро.


  1. Artyushov
    21.10.2019 13:59

    Подход выглядит очень интересно, сам испытывал боль с описанием сценариев пользуясь чистым API. Бота можно безопасно перезапускать или всё состоянии только в памяти?


    1. ruslanys Автор
      21.10.2019 14:27

      Приятно отвечать на умные комментарии/вопросы. Спасибо.


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


      Отвечая на ваш вопрос, подумал, что наиболее разумным будет вынести эту конкретную функциональность под интерфейс, вроде StateProvider и отдать на откуп пользователю, добавив в поставку in-memory хранилище по умолчанию. Захотел своё – переопределил bean.


  1. silentproger
    21.10.2019 14:38

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


    1. ruslanys Автор
      21.10.2019 14:40

      Чем же вариант с DSL плох? Мне кажется, DSL в принципе для подобного рода задач существует. А так да, это в отдельном файле, посмотрите здесь.


      1. silentproger
        21.10.2019 16:50

        По крайней мере в тех примерах, ссылку на которые вы скинули, есть синтаксис языка программирования, а в реальном мире обучением (построение диалогов, ответы на вопросы) ботов всё-таки заниматься должны не программисты (вариант с нейронками сейчас опустим), а какие-нибудь редакторы текстов и тп, и они не должны писать такие вещи. Я пару лет назад работал в компании, занимающейся разработкой диалоговых систем (чат-боты), где как раз редакторы заполняли файлики, у них там за день генерилось сотни диалоговых сценариев, далее парсили эти файлы и в базу закидывали. В таком случае проще что-то подправить что-то в тексте и диалоге, просто загружаем новую версию файла или правим текст прямо в базе и сразу получаем исправленный вариант диалога, без необходимости что-то пересобирать/перезапускать


        1. ruslanys Автор
          21.10.2019 17:57

          Ну, смотрите. Как я упоминал, можно сделать Hot Reload этих DSL скриптов без всякой перезагрузки. С другой стороны, не очень понимаю, как можно писать сценарии редакторами в отрыве от программистов, но, в принципе, это возможно и в Telegraff. Сделать, например, в методе process вызов единственного сервисного метода с передачей всех ответов туда и все. Тогда редактора могут делать все, что хотят в скриптах. Им и синтаксис языка знать не нужно.


    1. Prototik
      21.10.2019 16:17
      +1

      А чем dsl — не файл? И если выносить в файл — в каком формате это всё хранить? Ещё один dsl придумывать?


      Так же можно вынести котлин код в kts скрипт — и хоба, расширяйте бота сколько хотите, можно даже нескольких ботов / разные их задачи разносить по своим файлам.