Telegram Bot In Scala 3 With Bot4s + http4s + Doobie for CI notifications by@vkopaniev

Telegram Bot In Scala 3 With Bot4s + http4s + Doobie for CI notifications

image
Kopaniev Vlad HackerNoon profile picture

Kopaniev Vlad

Scala Developer, Functional Programming Enthusiast

In this article, I want to show you how to brew a simple Telegram bot using Scala 3, cats, bot4s library and also discuss the current state of the Scala ecosystem regarding Scala 3 support.

Goal

Let’s say you have a team that uses telegram for communications, you also use some CI system e.g. TravisCI, and now you want to have a channel to receive notifications about build statuses.

You did a little research and you have found articles like this explaining how to add a simple script that can be triggered after the build to send notifications to the specified telegram chat.

Well that’s a solution, but you are not satisfied with it, because you don’t want to replicate that script for each of your microservice repositories, you want to define a single service that will handle notifications, a single place to change your notification format and a single place to store telegram credentials, there is also a possibility of switching to another CI system soon, so you really want to protect yourself from refactoring dozens of ad-hoc scripts in the future.

To solve all of those problems you can create your own simple telegram bot, and implementing it with Scala 3 is also a hell of fun!


Prerequisites

We will use Scala 3 and SBT (Scala Build Tool) for that purpose. You should have SBT installed on your computer, see this link for more details. Choose your favorite editor or IDE and you are good to go! (I’m using VSCode + Metals)


Requirements

Before jumping straight into coding let’s discuss what we want in particular from our little bot.

Ok. We know there is no native support of telegram notifications in our CI system, but it has configurable webhook support, so we need our bot to expose some webhook endpoint so CI can call it after the build. After the webhook is triggered we need to transform the incoming message into a telegram message and send it somehow to the appropriate chat. And we also want our bot to be able to notify different chats about different project builds, so we need to create and persist a mapping from the repo identity to chat.

Let’s gather all requirements for our bot into a list:

  1. Webhook endpoint for CI notifications
  2. Webhook request transformation into an appropriate, human-readable Telegram message
  3. Persisting mapping from repository Id to chat Id.
  4. Sending notifications upon receiving to chat accordingly to persisted mapping.
  5. Make it extensible enough to be able to add support for different CI systems.

Code!

For the sake of simplicity I obfuscated some parts of code used in the application like imports, factory methods, main function, etc. Please see the full code in the Github repository.

API and Models

Before implementing the bot itself let’s start by defining our model and interfaces.

Assuming we will have a class that will implement a bot functionality and speak to telegram’s bot API we want to abstract over notifying functionality so higher-level code would not interact with bot implementation directly but throughout the abstraction.

For this purpose, we will define a BuildInfoNotifyer trait:

trait BuildInfoNotifyer[F[_], BI]:
  def notify(buildInfo: BI): F[Unit]

Pay attention to F[_] , this signals we are using the tagless-final encoding. In a nutshell, it helps to abstract over the effect type, e.g. IO, Future, Task, etc, if you want to know more about this pattern please see this article. We also use the new python-ish braceless syntax of Scala 3.

Now, we need a model class to pass information through our application.

final case class BuildResultNotification(
  repoId: RepoId,
  subscriberId: SubscriberId,
  buildNumber: BuildNumber,
  commitMessage: CommitMessage,
  authorName: AuthorName,
  branch: BranchName,
  resultStatus: ResultStatus,
  failed: Boolean,
  buildUrl: BuildUrl
) extends HasSubscriberId

This case class you see above would be our general notification data carrier for all CI integrations, it will be passed to bot instance to send messages.

Field types of models in this project are implemented as a simple type aliases for the sake of simplicity. If you want to be an FP PRO you can use a combination of newtypes and refined types to disambiguate and validate your types.

Additionally, it would be nice to define a way to convert BuildResultNotification into a Telegram message and to separate this concern into an appropriate abstraction. The best way to do this is a type-class, let’s see how we can create one in Scala 3:

trait TelegramMarkup[M]:
  extension (m: M) def markup: String
  extension (m: M) def parseMode: ParseMode.ParseMode

More on that syntax in official doc.

Now we can put TelegramMarkup type-class instance into BuildResultNotification companion object:

object BuildResultNotification:

  import unindent._
  given TelegramMarkup[BuildResultNotification] with
    extension(brn: BuildResultNotification) def markup: String = 
      val result = if (brn.failed) "Failed" else "Succeeded"
      i"""
      *Build* [#${brn.buildNumber}](${brn.buildUrl}) *$result*

      *Commit*: `${brn.commitMessage}`
      *Author*: ${brn.authorName}
      *Branch*: ${brn.branch}
      """
    extension(brn: BuildResultNotification) def parseMode: ParseMode.ParseMode = ParseMode.Markdown

We have put this instance into a companion object so it would be accessible by scala compiler implicit search when needed.

Now let’s get to the web model classes.

final case class TravisCIBuildResultInfo(
  id: BuildId,
  number: BuildNumer,
  status: Status,
  result: Result,
  message: Message,
  authorName: AuthorName,
  branch: BranchName,
  statusMessage: StatusMessage,
  buildUrl: BuildUrl,
  repository: Repository
)

final case class Repository(id: RepositoryId)

object TravisCIBuildResultInfo:

  val TravisCiFailedStatuses =
    Seq("failed", "errored", "broken", "still failing")

  extension (bri: TravisCIBuildResultInfo)
    def asNotification(subscriberId: SubscriberId): BuildResultNotification =
      BuildResultNotification(
        repoId = bri.repository.id.toString,
        subscriberId = subscriberId,
        buildNumber = bri.number,
        commitMessage = bri.message,
        authorName = bri.authorName,
        branch = bri.branch,
        bri.statusMessage,
        failed = TravisCiFailedStatuses.contains(bri.statusMessage.trim),
        bri.buildUrl
      )

TravisCIBuildResultInfo class serves as a TravisCI webhook request body representation that we can convert to BuildResultNotification using Scala 3 feature called extension methods, in a nutshell, this allows us to add methods to a class without changing it, see more info in the official documentation.

Finally, we need to model our repository-to-chat connection, for that we will introduce a new entity -SubscriberInfo:

final case class SubscriberInfo(subscriberId: UUID, chatId: Long)

We will see how this model will be used in our application later on.

CI Webhook

Let’s start our concrete implementation by fulfilling our first requirement - the webhook endpoint. We will implement a simple route using http4s DSL:

class BotWebhookController[F[_]: Concurrent] private (
  notifier: BuildInfoNotifyer[F, BuildResultNotification],
  logger: Logger[F]
) extends Http4sDsl[F]:

  def ciWebHookRoute: HttpRoutes[F] =
    import org.http4s.circe.CirceEntityDecoder._
    HttpRoutes.of[F] {
      case req @ POST -> Root / "travis" / "build" / "notify" / "subscriber" / UUIDVar(subscriberId) =>
        for
          form <- req.as[UrlForm]
          maybePayload = form.values.get("payload").flatMap(_.headOption)
          _ <- maybePayload.fold(
            logger.warn("Payload was absscent in callback request!")
          ) { payload =>
            Concurrent[F]
              .fromEither(decode[TravisCIBuildResultInfo](payload))
              .flatMap(webhookData => notifier.notify(webhookData.asNotification(subscriberId)))
          }
          result <- Ok("ok")
        yield result
    }

ciWebHookRoute method will return a route that contains a POST HTTP method that receives a webhook notification from TravisCI. You don’t need to dive deep into the details of this function, but to sum things up, this method will get JSON payload out of the request body, parse it into an instance of BuildResultInfo case class, and then after transforming it into a general notification format webhook will call BuildInfoNotifyer#notify with this data.

Persistence with Doobie

As the last step before we start working on the bot let’s define a persistence layer that will help us store info about notification subscribers. PostgreSQL was chosen as a database because it’s fairly easy to deploy and manage on platforms like Heroku and it is also sufficient for our task where we need to query subscribers both by subscriberId and by chatId.

First, we will need to have an interface that will represent our data storage:

trait SubsriberInfoRespository[F[_]] {
  def save(si: SubscriberInfo): F[Unit]

  def find(subscriberId: SubscriberId): F[Option[SubscriberInfo]]

  def find(chatId: ChatId): F[Option[SubscriberInfo]]

  def delete(chatId: ChatId): F[Unit]

With SubsriberInfoRespository we declared a bunch of methods for saving, deleting and searching subscriptions. Good, now we need to implement it.

Concrete implementation of this trait in doobie will look like this:

class PostgreSubsriberInfoRespository[F[_]: MonadCancelThrow](xa: Transactor[F])
  extends SubsriberInfoRespository[F]:

  override def save(si: SubscriberInfo): F[Unit] =
    sql"""INSERT INTO subscriber("chatid", "subscriberid") VALUES(${si.chatId}, ${si.subscriberId})""".update.run
      .transact(xa)
      .void

  override def find(subscriberId: SubscriberId): F[Option[SubscriberInfo]] =
    sql"SELECT subscriberid, chatid FROM subscriber WHERE subscriberid = $subscriberId"
      .query[SubscriberInfo]
      .option
      .transact(xa)

  override def find(chatId: ChatId): F[Option[SubscriberInfo]] =
    sql"SELECT subscriberid, chatid FROM subscriber WHERE chatid = $chatId"
      .query[SubscriberInfo]
      .option
      .transact(xa)

  override def delete(chatId: ChatId): F[Unit] =
    sql"DELETE FROM subscriber WHERE chatid = $chatId".update.run
      .transact(xa)
      .void

Doobie has a simple API that covers all of our scenarios perfectly, it uses ConnectionIO free monad that can be thought of as program description or “how” you want to query your DB, it’s not going to execute anything. Then, Transactor is an entity that knows how to manage DB connections, and using this entity we can transform ConnectionIO to our Monadic type F .

Telegram Bot

Now everything is set in place for the bot implementation.

The class that implements the bot is too big to fit one code block so I’m gonna put it in a series of blocks and comment on each separately.

We will start from a class declaration:

class CITelegramBot[F[_]: Sync, N <: HasSubscriberId: TelegramMarkup] private (
  botAppConfig: BotAppConfig,
  subscriberInfoRepository: SubsriberInfoRespository[F],
  backend: SttpBackend[F, Any],
  logger: Logger[F]
)(using MCT: MonadCancelThrow[F])
  extends TelegramBot[F](botAppConfig.telegramApiConfig.token, backend),
    Commands[F],
    Polling[F],
    Callbacks[F],
    BuildInfoNotifyer[F, N]:

First of all, our class need to extend bot4s class called TelegramBot . This class requires a telegram token and another abstraction called SttpBackend, which is an HTTP client that will be used to call telegram API (for more info about STTP see official doc). MonadCancelThrow is a type-class that allows us to raise errors with our F monad. Traits like Commands, Callbacks and Pollingthat are implemented by our CIBuddyBot are all parts of the bot4s API and they allow us to add commands, callbacks and poll telegram API.

Let’s add a couple of commands to give users a way to subscribe/unsubscribe from notifications:

onCommand("subscribe") { implicit msg =>
  for
    _ <- createSubscriberWhenNew(msg.chat.id)
    _ <- reply(
      "Please choose your CI tool type:",
      replyMarkup = Some(SupportedCiBuildToolsKeyboard)
    ).void
  yield ()
}

onCommand("unsubscribe") { implicit msg =>
  for
    _ <- subscriberInfoRepository.delete(msg.chat.id)
    _ <- reply("Done").void
  yield ()
}

Those are pretty self-descriptive commands. On “subscribe“ we create a new subscriber in the DB if it doesn’t exist yet and we reply user with a list of supported CI build tools. On “unsubscribe“ command we simply delete the current subscription from the DB.

Now let’s make the bot react to the CI tool user has chosen:

onCallbackQuery { callback =>
    (for
      msg <- callback.message
      cbd <- callback.data
      ciToolType = cbd.trim
    yield
      given message: Message = msg
      CiToolTypesAndCallbackData
        .get(ciToolType)
        .fold(
          reply(s"CI build tool type $ciToolType is not supported!").void
        ) { replyText =>
          OptionT(subscriberInfoRepository.find(msg.chat.id))
            .foldF(
              logger.warn(
                s"No subscriptions found for chat: chatId - ${msg.chat.id}"
              )
            ) { subscriberInfo =>
              reply(replyText.format(subscriberInfo.subscriberId.toString)).void
            }
        }
    ).getOrElse(logger.warn("Message or callback data was empty"))
  }

This onCallbackQuery function will register a new callback handler that will react when bot user will press the button. In our case earlier in “subscribe“ command we have implemented functionality that will respond to the user with a keyboard with all available CI tools options and this callback handler will receive a tool option that the user has chosen. Upon callback receive, this handler will search for chosen CI build tool option in a list of available ones and then it will get the subscriptionId using the current chatId.

It will respond to the user with a message with a format:

Use this url in your travisci webhook notification config: 
'https://bot-host-url.com/travis/build/notify/subscriber/$subscriberId'

Users can then use provided webhook URL to configure their build.

Alright, we have implemented a full subscription flow, now the last thing left to implement is actual notifications:

override def notify(notification: N): F[Unit] =
  OptionT(subscriberInfoRepository.find(notification.subscriberId))
    .foldF(
      logger.warn(
        s"Subscriber not found : subscriberId - ${notification.subscriberId}"
      )
    ) { subscriber =>
      request(
        SendMessage(
          subscriber.chatId,
          notification.markup,
          parseMode = Some(notification.parseMode)
        )
      ).void
    }

notify is an implementation of BuildInfoNotifyer trait, this function will send a message to a Telegram chat according to subscriberId. Notice how we use the information from notification. We don’t extract it directly, we use a markup function that comes from

TelegramMarkup type-class that we declared earlier and we also said that our abstract N type has an instance of it using context bounds.

Good, now as bot implementation is done, we can start testing it (bot run/deployment details are described in the projects’ README file), check out the next section to see the results!


Results

Now let’s see the actual results in Telegram!

We will assume we already configured our bot (see this doc for more instructions) and deployed the bot application somewhere.

First, we need to subscribe to notifications:

image

And now assuming we configured our CI to send webhook notifications let’s see an example of such notification received in a chat:

image

Perfect! Our CI bot works as expected, we were able to subscribe and receive a message from a webhook! The only thing that is not shown here is how to configure a webhook on the CI side, but that depends on the specific CI build configuration (for TravisCI see this doc).


Notes and comments on Scala 3 support

At the time of writing this article (October 2021) I have encountered a bunch of issues regarding Scala 3 support by ecosystem:

  1. bot4s doesn’t support Scala 3 yet and it also has a dependency on cats which means that I can’t use Scala 3 versions of other libraries dependent on cats. So I was forced to mark all main dependencies as .cross(CrossVersion.for3Use2_13)

  2. Consequently, I was not able to use macro-based functions from libraries like doobie, log4cats, circe because in my case they were all built for Scala 2.13 and you can’t use 2.13 macros in Scala 3 project. So for example instead of writing

    Slf4jLogger.getLogger[F] I was forced to use functions without a macro like Slf4jLogger.fromName[F](“CiBot“)in the case of doobie I’ve backported Scala3 macros inside of the project because without them it’s just unusable. In the case of circe I defined decoder by hands instead of using auto derivation which is based on macros.

  3. IntelliJ IDEA doesn’t support Scala 3 so well yet, I’ve started working on this project in IDEA, but encountered some compilation issues I was not able to overcome, so I resorted to VSCode + Metals and was surprised how good it is nowadays, so I would definitely recommend this combination for your Scala 3 project!

These are all the major problems I’ve encountered, mainly lack of Scala 3 support in some libraries, but those are mitigate-able. If you encounter such issues remember that you always can use Scala 2.13 version of libraries in your Scala 3 project which is great. If that library has macros search for alternative macros-less functions inside of that library or backport/port macros by hands to Scala 3 macros.

On the bright side:

  1. You can use 2.13 libraries in Scala 3, that is exactly what enabled me to produce this application! This means you could already migrate your application because all major libraries compile to 2.13 (yes, even Spark!), although I would recommend not to migrate at least until Scala 3.2.0 release because there are some talks that this version will solve forward compatibility problems.
  2. Recently Scala 3.1.0 was released and I bumped my Scala version in the build.sbt file and it just works without any changes in code!! From now on Scala minor versions are backward compatible which is great although this version is not forward compatible, which means you can’t use a 3.1.0 library in a 3.0.2 project, you will be forced to bump your Scala version, but as already mentioned there is a chance that Scala team will try to make version 3.2.0 fix this issue.

Overall I think Scala made good progress in terms of language usability and compatibility between language versions which makes it more comfortable to use. It needs just a bit more time to bump the ecosystem and make it even more compatible, but in the end, it will be a great choice for your next application, much better than previous versions of Scala.

* Bot Deployment Notes *

I’ve deployed my instance of bot using heroku platform, it is fairly simple and straightforward.

Bot project is packaged using sbt-native-packager so you could use that for your deployments, see more info in the bot project README file.


Summary

In this article, we have seen how to implement a working application in Scala 3! We satisfied all our initial requirements and built our application in an extensible and persistent way. We also have shown how to build a convenient solution for your Telegram notifications. Not only it was fun to code in Scala 3 and it was also pretty efficient, with a new syntax Scala developer day-to-day work is greatly simplified.

We also discussed how well Scala 3 is compatible with previous and future versions and in my opinion, great work is done in that field and I really appreciate Scala team effort they have put into this.


Useful Links

Bot code repository itself

Tagless-final based application example - Example of a shopping cart application built with modern Scala FP stack, also includes a great number of best practices examples.

Practical FP In Scala - great book by Gabriel Volpe, describes best practices of building modern FP applications in Scala

unindent - I’ve used this library to deal with multiline strings

TravisCI webhook config doc

Comments

Signup or Login to Join the Discussion

Tags

Related Stories