slackgo

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:

  1. See when it’s mentioned in chat. This is the app_mentions:read scope.
  2. 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.

Slack UI for managing OAuth scopes.

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”):

Slack UI for managing event subscriptions.

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 string v0:<timestamp>:<request body>, computed with a shared “signing secret”, and prefixed with v0=. (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.

Me. In your inbox?

Admit it. You're intrigued.

Subscribe