Scalackbot is a framework for building Slack bots in Scala.
- Define the state you care about
- Define handlers that operate on that state in response to Slack messages
- Build your bot with an initial state and a list of handlers (and some other details)
- Run it!
To ground these steps in reality, each will be explained in the context of a hypothetical bot for managing code reviews. People can be added to a list of code reviewers that the bot manages, and the bot can assign a pull request to the next reviewer in line.
Your bot can keep track of any kind of state you like.
This can be a class
or a case class
, depending on how much you like (im)mutability.
Our code review bot simply needs to keep track of a list of reviewers:
case class ReviewerBotState(reviewers: List[String])
Each handler is responsible for a specific action the bot can perform. Let's dive into each part of the Handler trait.
First, isRelevantMessage(data: SlackMessage): Boolean
determines whether the incoming message from a Slack user should trigger this action.
Typically this just involves checking for a keyword or phrase in the message body text.
If the above check returns true
, then perform(state: ClientState, data: SlackMessage): Handler.DefiniteOutcome[ClientState]
is called.
This function provides all the in-the-moment data you need: the current state, and the current "heard" Slack message.
The return value, Handler.DefiniteOutcome[ClientState]
, holds a new/updated state and a response that the bot will send back to Slack.
Lastly, the trait requires you define val helpText: String
, which is simply a static description of what the handler does and how to use it.
If no handler is called, the bot will respond with a help message that aggregates each handler's helpText
description.
Putting it all together, our code review bot needs two handlers—one to add new reviewers to the group, and another to assign pull requests to the next reviewer in line:
object AddReviewerHandler extends Handler[ReviewerBotState] {
// I recommend highlighting the keyword or phrase required to trigger the handler
val helpText: String = "*add* someone to the list of available code reviewers"
def isRelevantMessage(data: SlackMessage): Boolean => {
data.text contains "add"
}
def perform(state: ReviewerBotState, data: SlackMessage): Handler.DefiniteOutcome[ReviewerBotState] = {
val newReviewer = parseReviewerName(data.text)
val response = SlackResponse(s"$newReviewer has been added!")
val newState = state.copy(reviewers = state.reviewers :+ newReviewer)
new Handler.DefiniteOutcome(newState, response)
}
private def parseReviewerName(messageBody: String): String = /* details elided */ "John"
}
object AssignReviewerHandler extends Handler[ReviewerBotState] {
val helpText: String = "*assign* a PR to the next available code reviewer"
def isRelevantMessage(data: SlackMessage): Boolean => {
data.text contains "assign"
}
def perform(state: ReviewerBotState, data: SlackMessage): Handler.DefiniteOutcome[ReviewerBotState] = {
val nextReviewer = state.reviewers.head
val response = SlackResponse(s"Hey $nextReviewer, you are up next for this PR!")
val newState = state.copy(reviewers = state.reviewers.tail :+ nextReviewer)
new Handler.DefiniteOutcome(newState, response)
}
}
The bot requires four details:
- An initial state.
- A list of all the handlers. Note: at most only one handler will be called in response to a message, so the order of the handlers in this list matters. If a heard message would return
true
for two handlers'isRelevantMessage
implementations, only the first handler is called. - An API token (see Slack's official bot documentation).
- A name. Scalackbots will only react to messages that address/reference them by name.
All four must be present—your project will not compile if any are missing.
val handlers = List(AddReviewerHandler, AssignReviewerHandler)
val emptyState = ReviewerBotState(List.empty)
val bot = Bot.builder()
.withName("reviewerbot")
.withToken(System.getenv("BOT_API_TOKEN"))
.withHandlers(handlers)
.withInitialState(emptyState)
.build
Production.run(bot)
To run the bot, we pass it to a runner function, Production#run
.
We can also pass a constructed bot to a different kind of runner: IntegrationTesting#run
.
The testing runner also takes a list of messages representative of a conversation going on in the channel.
After running through all the messages, IntegrationTesting#run
returns an IntegrationTestResult
struct exposing the final resulting state and a log of replies from the bot.
Note that if you build your bot outside of your main
method, you can pass the very same bot you'll run in production to the integration test runner, preventing production configuration from potentially straying from test configuration without breaking stale tests.