visit
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 . We also use the new python-ish braceless syntax of Scala 3.
final case class BuildResultNotification(
repoId: RepoId,
subscriberId: SubscriberId,
buildNumber: BuildNumber,
commitMessage: CommitMessage,
authorName: AuthorName,
branch: BranchName,
resultStatus: ResultStatus,
failed: Boolean,
buildUrl: BuildUrl
) extends HasSubscriberId
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
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
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 .
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)
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.
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.
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
.
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 ). MonadCancelThrow
is a type-class that allows us to raise errors with our F monad. Traits like Commands
, Callbacks
and Polling
that are implemented by our CIBuddyBot
are all parts of the bot4s API and they allow us to add commands, callbacks and poll telegram API.
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 ()
}
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.
Use this url in your travisci webhook notification config:
'//bot-host-url.com/travis/build/notify/subscriber/$subscriberId'
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 .
And now assuming we configured our CI to send webhook notifications let’s see an example of such notification received in a chat:
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 ).
At the time of writing this article (October 2021) I have encountered a bunch of issues regarding Scala 3 support by ecosystem:
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)
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.
On the bright side:
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.