Опубликовал небольшую Scala-библиотеку tgbot-utils, набор расширений для Telegramium, которые я использую для разработки своих Telegram-ботов; в этом посте расскажу об одном из них.

Представим, что нужно создать бота для продажи мороженого. Он умеет обрабатывать два типа callback query запросов (далее просто “запросы”), представленные в виде ADT:

sealed abstract class CallbackData

final case class BuyIcecream(flavor: String) extends CallbackData
case object SayHello extends CallbackData

В прошлом посте показывал, как можно преобразовывать ADT в CSV-строку для передачи в Telegram API и обратно.

Модуль tgbot-callback-queries позволяет описывать обработчики для callback query запросов в стиле сервисов http4s (HTTP-библиотека для Scala):

import cats.effect.IO
import ru.johnspade.tgbot.callbackqueries.CallbackQueryDsl._
import ru.johnspade.tgbot.callbackqueries.CallbackQueryRoutes
import telegramium.bots.client.Method

val routes = CallbackQueryRoutes.of[CallbackData, Option[Method[_]], IO] {
  case BuyIcecream(flavor) in cb =>
    IO {
      println(s"${cb.from.firstName} have chosen: $flavor")
      None
    }
}

Сначала поясню, какую проблему я решал. Пока у бота мало типов запросов, их можно обрабатывать в едином блоке if-else, например, вызывая соответствующую функцию-обработчик в зависимости от типа. Но если их становится много, такой файл начинает сильно разрастаться, а ручной роутинг начинает утомлять. Мне хотелось иметь возможность разместить обработчики в отдельных файлах с автоматическим роутингом между ними, по образцу контроллеров в веб-фреймворках, поддерживающих архитектуру MVC.

Выше приведен пример такого контроллера (или сервиса, если использовать термины http4s). Метод CallbackQueryRoutes.of объявляет сервис и принимает три тайп-параметра – родительский тип всех запросов, тип возвращаемого значения и тип эффекта. Вебхук-боты могут отвечать на запрос объектом метода Telegram API, поэтому типом возвращаемого значения здесь является Option[Method]; для ботов на long polling типом результата может быть Unit. Сервис является частичной функцией, конкретные типы запросов выбираются с помощью паттерн-матчинга. Здесь обработчик запроса BuyIcecream просто выводит параметр запроса (выбранный вкус мороженого) в консоль. Благодаря удобному DSL обработчику сразу доступны параметры запроса и объект исходного callback query запроса (cb).

В своих ботах я сохраняю пользователей в БД, при обработке запросов обычно требуется информация о пользователе. Для этого можно использовать CallbackQueryContextRoutes:

import cats.effect.IO
import ru.johnspade.tgbot.callbackqueries.CallbackQueryDsl._
import ru.johnspade.tgbot.callbackqueries.CallbackQueryContextRoutes
import telegramium.bots.high.Methods

case class User(id: Int, firstName: String, language: String)

val contextRoutes = CallbackQueryContextRoutes.of[CallbackData, User, Option[Method[_]], IO] {
  case SayHello in cb as user =>
    IO {
      Some(Methods.answerCallbackQuery(cb.id, text = Some(s"Hello, ${user.firstName}")))
    }
}

Здесь user – объект с информацией об отправителе запроса, обработчик отвечает пользователю приветствием с обращением по имени. Такие сервисы позволяют получать контекстную информацию из запросов и сразу провайдить ее в обработчики. Для этого нужно создать CallbackQueryContextMiddleware:

import cats.data.{Kleisli, OptionT}
import ru.johnspade.tgbot.callbackqueries.{CallbackQueryContextMiddleware, CallbackQueryData, ContextCallbackQuery}

val userMiddleware: CallbackQueryContextMiddleware[CallbackData, User, Option[Method[_]], IO] =
  _.compose(
    Kleisli { (cb: CallbackQueryData[CallbackData]) =>
      val from = cb.cb.from
      val user = User(from.id, from.firstName, from.languageCode.getOrElse("en"))
      OptionT.liftF(IO(ContextCallbackQuery(user, cb)))
    }
  )

Теперь можно объединить все сервисы бота в едином роутере CallbackQueryHandler. Для композиции сервисов используется метод <+> (или combineK) тайпкласса Semigroup. Обратите внимание, что для композиции CallbackQueryContextRoutes с другими сервисами нужно преобразовать его в обычный сервис CallbackQueryRoutes вызовом userMiddleware. Также требуется передать инстанс CallbackDataDecoder, я оставил на усмотрение пользователя библиотеки выбор механизма декодирования ADT из строки callback query data. В примере применяется вышеупомянутое преобразование из формата CSV.

import cats.syntax.semigroupk._
import cats.syntax.either._
import ru.johnspade.tgbot.callbackqueries.{CallbackQueryHandler, CallbackDataDecoder, ParseError, DecodeError}

val allRoutes = routes <+> userMiddleware(contextRoutes)

private val cbDataDecoder: CallbackDataDecoder[IO, CallbackData] =
  CallbackData.decode(_).left.map {
    case error: kantan.csv.ParseError => ParseError(error.getMessage)
    case error: kantan.csv.DecodeError => DecodeError(error.getMessage)
  }
    .toEitherT[IO]

// query: CallbackQuery

val handler = CallbackQueryHandler.handle(
  query,
  routes = allRoutes,
  decoder = cbDataDecoder, 
  onNotFound = _ => IO(Option.empty[Method[_]])
)

Когда я делал своего первого бота на Kotlin, то прочитал и применил статью Разработка telegram бота с использованием Spring, получились идиоматичные для Спринга контроллеры в стиле Spring MVC. Начав разрабатывать ботов на Scala, я быстро понял, что для комфорта разработки нужно что-то подобное. Логично было так же применить наработки веб-библиотек, и мне нравилась простота http4s. Так я решил адаптировать его код для Telegram-ботов, благо лицензия это позволяет.

В процессе узнал много интересного о том, как устроена http4s. Так, вся библиотека построена вокруг простой функции Request => F[Response]. Сервис – это просто обертка над этой функцией вида Kleisli[OptionT[F, *], Request, Response], что из коробки дает возможность комбинировать сервисы с combineK, так как для Kleisli существует инстанс Semigroup. Благодаря простоте устройства ядра http4s адаптация оказалась легким и приятным делом, заодно изучил, как устроена эта замечательная библиотека.

Поиск