Building a Slack Bot in Golang
The Background #
This blog post details the steps in which I’ve built my first Slack Bot with less than trivial functionality. It was created over a hackathon and was then released on open source as Pricelinelabs’s leaderboard project under MIT License. This bot was built on Golang and uses nlopes’s Slack API wrapper. The post is not a how-to guide to building a Slack Bot using Golang but rather a recap of how the team built the Slack Bot.
Special thanks to Ben and Alexey for being part of the hackathon team.
The Problem #
If you use Slack internally, like any other company, you have likely ran into message saturation from being part of many channels and group direct messages. Conversely if you are the one who answers questions in a specific channel, this likely takes up at least 10% of your time. I proposed a product solution of a Slack Bot that measures the value of your collaboration, through helping your peers over Slack, in a quantifiable way.
The idea is to count how many emoji reactions your posts get and either increment or decrement your karma score. This karma score is then compare and contrasted with every other person in the company similar to such schemes in Reddit or StackOverflow; it gamifies the internal collaboration often taken for granted or misappropriated.
The API #
Slack provides a connection to any of its clients via Real Time Messaging (RTM) API for embedding real time chat functionality within clients. This WebSocket based API lets you programmatically send and receive events to Slack in real time.
Events can be new messages in a channel, new channels getting created, or even having a new user join the Slack team; this all gets continuously streamed to the websocket connection for the bot to process. The websocket connection uses JSON for requests and responses, and uses OAuth2 for authentication.
We use nlopes’s Slack API because it easily wraps all the convenience methods Slack’s SDK gives out for free to Node and Python.
RTM together with the SDK is the basis of the Slack Bot. We have a stream of data coming from RTM which we process and then use the SDK to do a certain action (to Slack). But let’s back up a little bit and talk about where this integration lies, the bot user.
The Bot User #
Bot users allow “real” Slack users to interact with external services. The external service in this example is the Go code running in some box, local server, or whatever.
You can create new bot users using your Slack’s team settings page with the appropriate access rights. Theres a slew of actions bot users can do from monitoring and processing channel activity to posting messages and reacting to users.
The important thing to note is the token which is associated to each bot user. The token is given to you after you created the bot user, you can provide fine grain access rights for the bot user through its token. This token allows your external service to interact with your specific Slack platform through Slack’s API and opening an RTM connection.
The Code #
I’m going to break down how we tackled creating the Slack Bot from connecting to the API to replying to “real” user commands to the Bot.
Step 1: Connecting to the API #
Connecting to Slack’s API is simple with nlopes’s Slack API simply create the Client with the token from the Bot User (this should be abstracted out of the logic since leaking this token could potentially lead to a lot of security issues).
*slack.Client api = slack.New(botKey.Token)
After the api
client is created we just create a new RTM instance and let Go handle concurrency issues by spawning a go channel through the API.
rtm := api.NewRTM()
go rtm.ManageConnection()
Loop:
for {
select {
case msg := <-rtm.IncomingEvents:
switch ev := msg.Data.(type) {
case *slack.ConnectedEvent:
botId = ev.Info.User.ID
case *slack.TeamJoinEvent:
// Handle new user to client
case *slack.MessageEvent:
// Handle new message to channel
case *slack.ReactionAddedEvent:
// Handle reaction added
case *slack.ReactionRemovedEvent:
// Handle reaction removed
case *slack.RTMError:
fmt.Printf("Error: %s\n", ev.Error())
case *slack.InvalidAuthEvent:
fmt.Printf("Invalid credentials")
break Loop
default:
fmt.Printf("Unknown error")
}
}
}
}
Above we have some basic events that we need to listen for to handle the functionality the Slack Bot provides. Upon connection we need to load in all the users of the Slack domain and then afterwards add in to our in-memory understanding whenever there are new users to keep track of each others karma score.
Step 2: Opening Go Channels #
Now that we have an RTM connection working, we need to pass in event actions to the respective handlers. Not to go through the entire code base but here is an abbreviated channels we created to handle the Slack Bot’s actions.
type BotCentral struct {
Channel *slack.Channel
Event *slack.MessageEvent
UserId string
}
type AttachmentChannel struct {
Channel *slack.Channel
Attachment *slack.Attachment
DisplayTitle string
}
var (
botCommandChannel chan *BotCentral
botReplyChannel chan AttachmentChannel
)
botCommandChannel = make(chan *BotCentral)
botReplyChannel = make(chan AttachmentChannel)
go handleBotCommands(botReplyChannel)
go handleBotReply()
Here is an architecture point we should highlight. A Go Channel is a typed connection/plumbing through which you can send and receive typed payload using the channel operator <-
.
By default, sends and receives block until the other side is ready. This allows goroutines to synchronize without explicit locks or condition variables.
This allows our Slack Bot to effectively handle incoming messages in real time and still keep state in a simple built-in manner that Golang provides us.
Step 3: Message Event #
So then inside the MessageEvent case (some code removed), we obtain the channel where this message was produced and parse the string to see if it is a normal message or a bot command.
case *slack.MessageEvent:
channelInfo, err := api.GetChannelInfo(ev.Channel)
botCentral := &BotCentral{
Channel: channelInfo,
Event: ev,
UserId: ev.User,
}
user := activeUsers.FindUser(ev.User)
if ev.Type == "message" && strings.HasPrefix(ev.Text, "<@" + botId + ">") {
botCommandChannel <- botCentral
}
if ev.Type == "message" && ev.User != botId {
userMessages = append(userMessages, Message{
User: user,
ChannelId: ev.Channel,
Timestamp: ev.Timestamp,
Payload: ev.Text,
})
}
When the bot user is mentioned Slack uses a pattern such as <@8675309>
to identify a certain user (or bot user). So a message like “@chrisbot hey do you like harry potter?” would be received as <@19800731> hey do you like harry potter?
with 19800731
being the bot id for chrisbot
. We handle a bot request different from a generic message to the channel since we produce some actions based on the keywords provided.
Step 4: Reaction Added or Removed #
When handling reactions, we had to first find out if an emoji is positive or negative. This gives us a problem since it would be cray cray to hard code every single emoji while a thumbs down emoji should hardly be an uptick in that user’s karma. The quick and dirty fix we did was merely naming a select few emojis as negative and defaulting all the rest as positive.
for i, v := range activeUsers {
if v.Info.ID == userId {
if (isAdded) {
activeUsers[i].Rating++
} else {
activeUsers[i].Rating--
}
}
}
Above is an example of the action taken when an emoji is positive. isAdded
is a configuration flag passed to denote if an emoji is being added or removed. This way we had some sort of DRY-ness into our logic.
Step 5: Bot Commands #
As we’ve gone over Step 3, if the message received is a bot command we let it run through a different flow. Here we utilize the botReplyChannel
created in Step 2 to pass in what the bot should reply. We need to let it know which channel, what text to say, and if there are some attachments provided.
commands := map[string]string{
"top":"See the top rank of user rating by a provided number of top spots.",
"bottom":"See the bottom rank of user rating by a provided number of bottom spots.",
"help":"See the available bot commands.",
"mean":"See how the rating of the selected user looks like, comparing to the mean of all users.",
"mean of":"See how the rating of the selected user looks like, comparing to the mean of all users.",
"top messages": "See the top ranking messages in the current channel.", }
case "help":
fields := make([]slack.AttachmentField, 0)
for k, v := range commands {
fields = append(fields, slack.AttachmentField{
Title: "<bot> " + k,
Value: v,
})
}
attachment := &slack.Attachment{
Pretext: "Guru Command List",
Color: "#B733FF",
Fields: fields,
}
attachmentChannel.Attachment = attachment
botReplyChannel <- attachmentChannel
Above is a snippet for the help
case where the user is looking to see what commands we have. We first create a Slack AttachmentField using their SDK, then fill in the values needed such as Title, Value, and the attachment itself. Afterwards we send this attachment to the botReplyChannel
for the goroutine to then post it to the channel when it has the time (remember its a lightweight thread managed by the Go runtime).
Step 6: Edge Cases #
Since this is a PoC, we never bothered to handle what happens if the server crashes or if this server runs in perpetuity (we’ll run out of memory). These are all next steps to making it a full fledged bot to deploy in a cloud instance.
We have however handled what happens when a new user joins the domain. This lowers the total average score of the team as well as adding the new user to the bottom ranking (if there are no negative contributors).
Another tricky part we handled is having the bot user be completely configurable outside of the Slack Bot. That is, we parse the name from configuration as well as its id; all done dynamically.
The Second Problem #
As hackathon PoCs go, we will always have some sort of scope creep from feature requests or new avenues discovered at the last hour of the last day of the hackathon. We were not an exception to this rule, as having internally demoed our product before we present it to the judges, a feature request popped up on showing the top messages in the channel (most upvoted or reaction-added).
So here we had to bootstrap a messages in-memory data structure to keep track of what was the message and how many karma points that message got.
type Message struct {
ChannelId string
Timestamp string
Payload string
Rating int
User User
}
type Messages []Message
We used the timestamp along with the channel id to hash out a unique key for each message and the associated user. This was made since when a Reaction is added or removed, we need to modify the Message data structure accordingly. So we need to find out where the message “Angular is a good framework” was said, either in #front-end-developers or #javascript as well as by whom. Since context matters in a lot of these cases.
for i, msg := range userMessages {
if msg.Timestamp == ev.Item.Timestamp && msg.ChannelId == ev.Item.Channel {
userMessages[i].Rating++
}
}
When a reaction is added we go through the data structure to increment or decrement the rating of the message found (or otherwise we store this new message if it is new and give it a default score of 0).
The Links #
As previously mentioned, the full source code is available on GitHub and was open sourced with an MIT license on October 7, 2016.