Building a Slack slash command bot with Golang and Kubernetes

I use Slack ever so often, therefor I challenge myself to explore it further, rather than just being a user. For the fun, experience and just random interest in how things work. That's was also the reason for https://sysrant.com/500-bounty-man-in-the-middle-on-slack/ ;)

Anyhow, within Slack you can create bots with various capabilities.

I just want to create small apps/bots in my spare time. Making something of a Slash Command is fairly easy.

Slash Commands

A slash command is something like /the-command input which can be fired from the chat in Slack. It will do an HTTP request and expects a response back which will get posted by your bot in the channel.

Endpoints

We will need two endpoints:

  • OAuth endpoint - is used to validate that a person wants to add our bot to their Slack. * Command endpoint - is the endpoint on which you tell Slack to post the command to.

OAuth

On the OAuth endpoint, we will receive a "code" - First we check for an error, then we grab the code.

 1error := r.URL.Query().Get("error")
 2if error != "" {
 3  redirectUrl := errorURL
 4  http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
 5}
 6
 7// We require a code
 8code := r.URL.Query().Get("code")
 9if code == "" {
10  redirectUrl := errorURL
11  http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
12}

We use the code to create a new POST request and include our bot clientID and client secret. Which could be something like:

 1  
 2form := url.Values{}
 3 form.Add("code", code)
 4 form.Add("client_id", os.Getenv("CLIENTID"))
 5 form.Add("client_secret", os.Getenv("CLIENTSECRET"))
 6
 7 req, err := http.NewRequest("POST", routerURL, strings.NewReader(form.Encode()))
 8 if err != nil {
 9   fmt.Println(err)
10   redirectUrl := errorURL
11   http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
12   return
13 }
14
15 req.PostForm = form
16 req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
17 // Do the request
18 res, err := hc.Do(req)
19 if err != nil {
20   redirectUrl := errorURL
21   http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
22   return
23 }
24
25 // Get the response
26 decoder := json.NewDecoder(res.Body)
27
28 var oauthResult jsonOauthResult
29 err = decoder.Decode(&oauthResult)
30// And then do something with the error..otherwise redirect for success.

This is basically the steps for the chain of OAuth. Since we never require to use any permissions we don't need any access_token or whatever. Everything above is a one-time step for people installing your Slash command.

Command

We start our command endpoint by parsing the POST request.

 1 
 2  // Default headers always..
 3  w.Header().Set("Content-Type", "application/json")
 4  w.Header().Set("Access-Control-Allow-Origin", "*")
 5
 6  // We need the raw body to create a signature
 7  bodyBytes, err := ioutil.ReadAll(r.Body)
 8  if err != nil {
 9    w.WriteHeader(http.StatusInternalServerError)
10    return
11  }
12
13  bodyString := string(bodyBytes)
14
15  // Return the bytes to the body for the FormParser, because using r.FormValue is easy.
16  r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
17
18  // Parse POST values
19  if err := r.ParseForm(); err != nil {
20    w.WriteHeader(http.StatusInternalServerError)
21    return
22  }

Then a pretty important step is to check for a replay attack and to verify the signature of the POST request:

 1    
 2// Prepare the vars for our signing
 3slackVersion := "v0:"
 4slackTimestamp := r.Header.Get("X-Slack-Request-Timestamp")
 5slackSignature := r.Header.Get("X-Slack-Signature")
 6
 7// Check if the request is within 5 minutes - replay attack
 8now := time.Now()
 9n, err := strconv.ParseInt(slackTimestamp, 10, 64)
10if err != nil {
11  fmt.Printf("%d of type %T", n, n)
12}
13if (now.Unix() - n) > 60*5 {
14  fmt.Println("replay attack")
15  w.WriteHeader(http.StatusInternalServerError)
16  return
17}
18
19// Create our hash and compare it with the slack Signature.
20sigBasestring := slackVersion + slackTimestamp + ":" + string(bodyString)
21secret := os.Getenv("SIGNINGSECRET")
22h := hmac.New(sha256.New, []byte(secret))
23h.Write([]byte(sigBasestring))
24
25sha := hex.EncodeToString(h.Sum(nil))
26sha = "v0=" + sha
27
28if sha != slackSignature {
29  fmt.Println("signature mismatch")
30  w.WriteHeader(http.StatusInternalServerError)
31  return
32}

If all is good, we can proceed with our own logic.

We can retrieve the "text" after the Slash Command by getting the POST value like:

text := r.FormValue("text")

Returning data back

If we want to make a response we have to form a message. Slack has various options to do so. I tend to go with the following struct:

type jsonResult struct {
	Text        string        `json:"text"`
	ReponseType string        `json:"response_type"`
	Attachments []Attachments `json:"attachments"`
}

ReponseType defaults to in_channel , but can also be changed that we only sent the response to the user that has issued the slash command.

The text field is primarily used for plain text, while the attachment is the "mark up" for Slack. More info about that can be found here: https://api.slack.com/docs/messages/builder

1  
2w.WriteHeader(http.StatusOK)
3data := jsonResult{Text: text, ReponseType: responseType, Attachments: attachment}
4json.NewEncoder(w).Encode(data)

And we are done.

For a full example, I recommend checking out https://sysrant.com/slack-ping/ I have actually posted the full source code on Github for that: https://github.com/wiardvanrij/slack-slashcommand-ping

Running it on Kubernetes

First I just start off with a Dockerfile, which is pretty simple and basic. Perhaps things could be tweaked, but it should cover the base (if not more..):

 1   
 2# Start from the latest golang base image
 3FROM golang:latest as builder
 4
 5# Add Maintainer Info
 6LABEL maintainer="Wiard van Rij <[email protected]>"
 7
 8# Set the Current Working Directory inside the container
 9WORKDIR /app
10
11# Copy go mod and sum files
12COPY go.mod go.sum ./
13
14# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
15RUN go mod download
16
17# Copy the source from the current directory to the Working Directory inside the container
18COPY . .
19
20# Build the Go app
21RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
22
23
24######## Start a new stage from scratch #######
25FROM alpine:latest  
26
27RUN apk --no-cache add ca-certificates
28WORKDIR /root/
29
30# Copy the Pre-built binary file from the previous stage
31COPY --from=builder /app/main .
32
33
34# Expose port 8080 to the outside world
35EXPOSE 8080
36
37# Command to run the executable
38CMD ["./main"] 

I host my images on either Harbor (in my own cluster) or just on docker.io, depending on the nature of the Dockerfile ;)

The next part is creating a Nginx ingress for our application.

 1   
 2apiVersion: extensions/v1beta1
 3kind: Ingress
 4metadata:
 5  name: slackoverflow
 6  annotations:
 7    kubernetes.io/ingress.class: nginx
 8    certmanager.k8s.io/cluster-issuer: production
 9spec:
10  tls:
11    - hosts:
12        - stackoverflow.sysrant.com
13      secretName: slackapp
14  rules:
15    - host: stackoverflow.sysrant.com
16      http:
17        paths:
18          - backend:
19              serviceName: stackoverflow
20              servicePort: 80

As you might notice I use certmanager to take care of my certificates.

The next part is having a service and a deployment.

 1    
 2apiVersion: v1
 3kind: Service
 4metadata:
 5  name: slackoverflow
 6  labels:
 7    app: slackoverflow
 8spec:
 9  ports:
10    - port: 80
11      targetPort: 80
12  selector:
13    app: slackoverflow
14    tier: frontend
15---
16apiVersion: apps/v1
17kind: Deployment
18metadata:
19  name: slackoverflow
20  labels:
21    app: slackoverflow
22spec:
23  replicas: 2
24  selector:
25    matchLabels:
26      app: slackoverflow
27      tier: frontend
28  strategy:
29    type: RollingUpdate
30    rollingUpdate:
31      maxSurge: 1
32      maxUnavailable: 0
33  template:
34    metadata:
35      labels:
36        app: slackoverflow
37        tier: frontend
38    spec:
39      containers:
40        - image: something/something:1
41          env:
42          - name: CLIENTID
43            value: "redacted"
44          - name: CLIENTSECRET
45            value: "redacted"
46          - name: SIGNINGSECRET
47            value: "redacted"
48          name: slackoverflow
49          resources:
50            limits:
51              memory: "250Mi"
52              cpu: "200m"
53            requests:
54              cpu: "20m"
55              memory: "100Mi"

Our app just runs on port 80, so I use that as a target in my service.

For my deployment, I tend to run at least two replicas. This way I'm fairly certain that when updating and/or with outages one pod will stay alive (or at least, a higher chance ;) ).

I pass on my env vars in the deployment itself, so it isn't baked into my Dockerfile or application.

Things to remember

I create these things in around 2-3 hours max, including my logic. I really, really, do this for FUN. To actually be able to deliver a working MVP in a small time window. Minimum effort, maximum satisfaction.

Therefore there are a few things that you should differently if you really want to run this more stable:

  • Create pipelines for deployment
  • Actually log much better than I do
  • Check for errors much better than I do
  • Create code in a more OO way, less procedural

Though I really urge people to try this out: Create something that WORKS, is FUN and is doable within a limited timeframe. It's not about the pressure to deliver, it's about getting some fun results in your spare time. Rather than spending weeks on something, making it feel like true work ;)

comments powered by Disqus