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

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

error := r.URL.Query().Get("error")
if error != "" {
  redirectUrl := errorURL
  http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
}

// We require a code
code := r.URL.Query().Get("code")
if code == "" {
  redirectUrl := errorURL
  http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
}

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

   
form := url.Values{}
  form.Add("code", code)
  form.Add("client_id", os.Getenv("CLIENTID"))
  form.Add("client_secret", os.Getenv("CLIENTSECRET"))

  req, err := http.NewRequest("POST", routerURL, strings.NewReader(form.Encode()))
  if err != nil {
    fmt.Println(err)
    redirectUrl := errorURL
    http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
    return
  }

  req.PostForm = form
  req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
  // Do the request
  res, err := hc.Do(req)
  if err != nil {
    redirectUrl := errorURL
    http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
    return
  }

  // Get the response
  decoder := json.NewDecoder(res.Body)

  var oauthResult jsonOauthResult
  err = decoder.Decode(&oauthResult)
// 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.

 
  // Default headers always..
  w.Header().Set("Content-Type", "application/json")
  w.Header().Set("Access-Control-Allow-Origin", "*")

  // We need the raw body to create a signature
  bodyBytes, err := ioutil.ReadAll(r.Body)
  if err != nil {
    w.WriteHeader(http.StatusInternalServerError)
    return
  }

  bodyString := string(bodyBytes)

  // Return the bytes to the body for the FormParser, because using r.FormValue is easy.
  r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))

  // Parse POST values
  if err := r.ParseForm(); err != nil {
    w.WriteHeader(http.StatusInternalServerError)
    return
  }

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

    
// Prepare the vars for our signing
slackVersion := "v0:"
slackTimestamp := r.Header.Get("X-Slack-Request-Timestamp")
slackSignature := r.Header.Get("X-Slack-Signature")

// Check if the request is within 5 minutes - replay attack
now := time.Now()
n, err := strconv.ParseInt(slackTimestamp, 10, 64)
if err != nil {
  fmt.Printf("%d of type %T", n, n)
}
if (now.Unix() - n) > 60*5 {
  fmt.Println("replay attack")
  w.WriteHeader(http.StatusInternalServerError)
  return
}

// Create our hash and compare it with the slack Signature.
sigBasestring := slackVersion + slackTimestamp + ":" + string(bodyString)
secret := os.Getenv("SIGNINGSECRET")
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(sigBasestring))

sha := hex.EncodeToString(h.Sum(nil))
sha = "v0=" + sha

if sha != slackSignature {
  fmt.Println("signature mismatch")
  w.WriteHeader(http.StatusInternalServerError)
  return
}

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

  
w.WriteHeader(http.StatusOK)
data := jsonResult{Text: text, ReponseType: responseType, Attachments: attachment}
json.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..):

   
# Start from the latest golang base image
FROM golang:latest as builder

# Add Maintainer Info
LABEL maintainer="Wiard van Rij <[email protected]>"

# Set the Current Working Directory inside the container
WORKDIR /app

# Copy go mod and sum files
COPY go.mod go.sum ./

# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download

# Copy the source from the current directory to the Working Directory inside the container
COPY . .

# Build the Go app
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .


######## Start a new stage from scratch #######
FROM alpine:latest  

RUN apk --no-cache add ca-certificates
WORKDIR /root/

# Copy the Pre-built binary file from the previous stage
COPY --from=builder /app/main .


# Expose port 8080 to the outside world
EXPOSE 8080

# Command to run the executable
CMD ["./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.

   
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: slackoverflow
  annotations:
    kubernetes.io/ingress.class: nginx
    certmanager.k8s.io/cluster-issuer: production
spec:
  tls:
    - hosts:
        - stackoverflow.sysrant.com
      secretName: slackapp
  rules:
    - host: stackoverflow.sysrant.com
      http:
        paths:
          - backend:
              serviceName: stackoverflow
              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.

    
apiVersion: v1
kind: Service
metadata:
  name: slackoverflow
  labels:
    app: slackoverflow
spec:
  ports:
    - port: 80
      targetPort: 80
  selector:
    app: slackoverflow
    tier: frontend
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: slackoverflow
  labels:
    app: slackoverflow
spec:
  replicas: 2
  selector:
    matchLabels:
      app: slackoverflow
      tier: frontend
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: slackoverflow
        tier: frontend
    spec:
      containers:
        - image: something/something:1
          env:
          - name: CLIENTID
            value: "redacted"
          - name: CLIENTSECRET
            value: "redacted"
          - name: SIGNINGSECRET
            value: "redacted"
          name: slackoverflow
          resources:
            limits:
              memory: "250Mi"
              cpu: "200m"
            requests:
              cpu: "20m"
              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:

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