Shove It Up Your Bot: an Intro to Slack Bots
by Steve Marx on
In season 8, episode 1 of The Office, Stanley says that his “thing now” is to pretend to help people by giving lengthy instructions and then saying “… and shove it up your butt!”
Inspired by this episode, I created a Slack bot a couple years ago called “shove it up your bot”. It’s been entertaining me ever since. In this post, I’ll walk through the basics of this type of mention/response Slack bot and share the full code for the bot.
Multiple Slack APIs
The Slack API comes in a few different flavors. The two most popular and relevant are these:
- Web API – A straightforward HTTP-based API. This can be used to create channels, send messages, search chat history, etc. Requests here are initiated by your code. I use this to send messages from
@shoveitupyourbot
. - Events API – A webhook-based API. You configure what events your app is subscribed to, and when one occurs, Slack makes a request to the bot’s registered URL with the event as a payload. I use the events API to know when somebody mentions the bot, e.g. “
@shoveitupyourbot
make scrambled eggs”.
Note that not every Slack app is a bot. For example, integrations like Giphy post messages on behalf of existing users. Bots are specifically the type of Slack integration where the app appears in chat under its own name.
Permissions
To do anything in Slack, a bot needs to be added to a Slack instance, which grants it an OAuth token. That OAuth token is tied to a set of scopes, which define what the bot can do.
A bot like @shoveitupyourbot
needs to do exactly two things:
- See when it’s mentioned in chat. This is the
app_mentions:read
scope. - Send chat messages. This is the
chat:write
scope.
The first scope will actually be added for you if you subscribe to that event, but the second scope needs to be added manually.
Subscribing to events
You can subscribe to events directly in the Slack app UI. Here I’ve subscribed to just the app_mention
event, which is triggered any time a user @-mentions the bot (e.g. “@shoveitupyourbot
hello”):
Mutual authentication
When a bot sends an HTTP request to Slack, it uses an OAuth bearer token to authenticate itself, but it’s also important to do the reverse. When Slack sends an HTTP request with an event, it cryptographically signs that message so the bot can be sure it comes from Slack.
The signature scheme is pretty simple:
- The
X-Slack-Request-Timestamp
contains a Unix timestamp for the request. Apps should check that this is reasonably close to the current time to avoid replay attacks. - The
X-Slack-Signature
contains the HMAC-SHA256 digest for the stringv0:<timestamp>:<request body>
, computed with a shared “signing secret”, and prefixed withv0=
. (v0
is a version string to allow for future signature schemes.)
An app can validate a request by recomputing the signature and checking it against the header.
An appropriate library/SDK will handle this for you, but it’s not too hard to implement manually. I implemented @shoveitupyourbot
in Go without the help of a Slack library:
func (b *bot) eventHandler(w http.ResponseWriter, r *http.Request) {
body, _ := ioutil.ReadAll(r.Body)
// Check for a recent timestamp to avoid replay attacks.
t, _ := strconv.Atoi(r.Header.Get("X-Slack-Request-Timestamp"))
if math.Abs(float64(time.Now().Unix()-int64(t))) > 300 {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, "Timestamp differs by more than 5 minutes.")
return
}
// Compute HMAC-SHA256
mac := hmac.New(sha256.New, []byte(b.secret))
fmt.Fprintf(mac, "v0:%d:%s", t, body)
digest := mac.Sum(nil)
// Reject invalid signatures
if r.Header.Get("X-Slack-Signature") != "v0="+hex.EncodeToString(digest) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, "Invalid signature.")
return
}
Handling the challenge request
Here’s a fun way to spam a web server! Create a Slack app, add it to a busy Slack instance, and subscribe to a bunch of events with a URL that points to the victim server. Slack avoids this type of HTTP spam attack much like many services avoid email spam, by validating the address before sending any additional messages.
When you register an event URL, Slack makes a single request to that URL with a challenge
parameter in JSON, e.g.:
{
"type": "url_verification",
"challenge": "abc123...",
"token": "..."
}
(The token
field is a now-deprecated alternative to the request signature above.)
If the server fails to respond with the same string that’s found in the challenge
field, verification fails, and no more requests will be sent to that server.
Here’s the @shoveitupyourbot
code for handling the challenge request:
var msg eventMessage
json.Unmarshal(body, &msg)
// For challenge requests, just echo the challenge field
if len(msg.Challenge) > 0 {
fmt.Fprintln(w, msg.Challenge)
return
}
Handling bot mentions
Event HTTP requests can be described by the eventMessage
struct below (and the nested event
struct):
type event struct {
Type string
Text string
Channel string
ThreadTS string `json:"thread_ts"`
}
type eventMessage struct {
Challenge string
Event event
AuthedUsers []string `json:"authed_users"`
}
There are a lot more fields in the actual requests, but these are the only ones relevant to responding to bot mentions and the initial challenge request. This code detects a mention and prepares a response:
var msg eventMessage
json.Unmarshal(body, &msg)
...
// When the bot is mentioned, respond with a funny message
if msg.Event.Type == "app_mention" {
// Remove the bot's user ID from the message.
// E.g. "<@U123abc> how do you make scrambled eggs?" -->
// "how do you make scrambled eggs?"
user := msg.AuthedUsers[0]
text := strings.TrimSpace(
strings.ReplaceAll(msg.Event.Text, "<@"+user+">", ""))
// Make a response
response := getInstructions(text)
I’m going to skip over the implementation of getInstructions
, but it uses the goquery to parse pages loaded from wikiHow. It then chops off some of the steps and replaces them with “Shove it up your butt.” You can read the code in instructions.go
in case you’re curious.
Sending a message
To send a message, I’m using the web API, specifically chat.postMessage
.
The following Go struct describes the shape of the JSON body:
type chatMessage struct {
Channel string `json:"channel"`
Text string `json:"text"`
ThreadTS string `json:"thread_ts,omitempty"`
}
If supplied, the thread_ts
field tells Slack what thread a message is being sent to. It just mirrors the thread_ts
field received in the app_mention
event.
In addition to this JSON body, it’s important to attach the OAuth token in the Authorization
header. This code sends the response chat message:
// Send the message to the right channel/thread
c := chatMessage{msg.Event.Channel, response, msg.Event.ThreadTS}
j, _ := json.Marshal(c)
req, _ := http.NewRequest(http.MethodPost,
"https://slack.com/api/chat.postMessage", bytes.NewBuffer(j))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+b.token) // OAuth token
http.DefaultClient.Do(req)
Development tip: use ngrok
While developing a bot, you need a publicly-accessible HTTP(S) URL were the challenge request and other events can be sent.
ngrok
is a tool that forwards public URL requests to a port on your local computer. (You can build something similar on your own with SSH tunneling.)
This is incredibly helpful when you’re rapidly iterating on your code and want to be able to see changes instantly without deploying them.
Deploying to Heroku
The bot code can be deployed anywhere, but I chose to use Heroku. It’s lightweight, and I’m already familiar with it. This isn’t a Heroku tutorial, so I’m not going to go into much depth in this section.
Heroku figures out what version of Go to run from a comment in go.mod
. This isn’t strictly necessary because there’s always a default, but I like to be explicit:
// +heroku goVersion go1.14
A few quick commands with git
and heroku
create a new Heroku app and add the necessary environment variables:
$ heroku create siuyb
$ git init
$ heroku git:remote --app siuyb
$ echo web: ~/bin/shoveitupyourbot >> Procfile
$ heroku config:set TOKEN=xoxb-... SECRET=...
$ git commit -am "initial commit"
$ git push heroku master
A note about multitenancy
I engineered this bot to only work in one Slack instance at a time because it has a hardcoded OAuth token. It could easily be extended to work in multiple Slack instances by looking up the appropriate token based on the “authenticated user” in the event payload.
This would be a necessary step before distributing this app broadly.
Full source code
You can find the full source code at github.com/smarx/shoveitupyourbot.