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 ;)