The Ops Community ⚙️

Cover image for How to Build a Multi-Workspace Slack Application in Go
David Abramov for Blink Ops

Posted on • Originally published at blinkops.com

How to Build a Multi-Workspace Slack Application in Go

Recently, I started working on a Slack application that connects Slack and our own Blink platform. If you’re not familiar, Blink is a no-code automation platform for cloud engineering teams, and it enables users to build automations within managed workspaces for different teams or projects. In order to support this use case, I needed a way to build a multi-workspace application in Slack.

When I started reading about and playing with the Slack platform, it suddenly became clear to me that Slack is so much bigger and more complex than I’d ever realized.

The Slack API Docs seem very large and complex at first glance, but they are actually quite detailed and user-friendly. Unfortunately, the official Bolt family of SDKs doesn’t include Go, my recent language of choice, and, although I did find some useful guides on the web, none of them really targeted my use case of building an app which can be distributed across multiple workspaces and eventually listed on the Slack app store, the App Directory.

After my research, I ended up using the Slack API in Go to build the needed Slack application, and I’m writing this hands-on article to share my experience. While I built this application to support Blink’s particular use case, I’ve written the rest of this blog post as a general how-to guide for anyone building a similar Slack application.

Creating a Slack Application

Before starting any coding, let’s create our application here.

Image description

Click on the ‘Create New App’ button.

Image description

Select the ‘From scratch’ option:

Image description

Give your app a name and select the workspace you will be developing your app in.

NOTE: As our app will be publicly distributed, the workspace selection here is not very important, and we’ll build our app in a way which will allows us to install it on any workspace, regardless of the choice you make in this phase.

The Scenario

Let’s imagine that you are developing a really awesome REST application that accepts as input 2 integers and returns their sum.

We’ll use the Gin web framework to write our app:

go get -u github.com/gin-gonic/gin
Enter fullscreen mode Exit fullscreen mode

Here’s the code:

package main

import (
    "encoding/json"
    "net/http"

    "github.com/gin-gonic/gin"
)

type Input struct {
    Num1 int `json:"num1"`
    Num2 int `json:"num2"`
}

type Output struct {
    Sum int `json:"sum"`
}

func handle(c *gin.Context) {
    input := &Input{}
    if err := json.NewDecoder(c.Request.Body).Decode(input); err != nil {
        c.String(http.StatusBadRequest, "error reading request body: %s", err.Error())
        return
    }
    c.JSON(http.StatusOK, &Output{
        Sum: input.Num1 + input.Num2,
    })
}

func main() {
    app := gin.Default()
    app.POST("/", handle)
    _ = app.Run()
}
Enter fullscreen mode Exit fullscreen mode

By default, if we don’t specify an argument for the ‘Run’ method, the app will listen on port 8080.

Let’s try it:

go run main.go
Enter fullscreen mode Exit fullscreen mode

And then:

curl -X POST http://localhost:8080 --data '{"num1":1,"num2":2}' -i
Enter fullscreen mode Exit fullscreen mode

And here’s the result:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 21 May 2022 17:46:26 GMT
Content-Length: 9

{"sum":3}
Enter fullscreen mode Exit fullscreen mode

Let’s call this app our ‘Core’ service that we want to integrate with Slack, and create a standalone Slack dedicated service that will connect the users of our Slack app to the ‘Core’ app.

Image description

Let’s visualize this:

The above diagram shows the data flow. Each time a user will interact with our Slack app, the Slack platform will access our Slack service which will in turn access our ‘Core’ service to process the input and return a response which will be returned to the Slack platform (and then to the user).

Supporting Multiple Slack Workspaces

Every access to the Slack API requires the caller to provide an access token.

There are several types of tokens. Our app will work only with bot tokens.

Every time our app will be installed on some workspace, we’ll get such a bot token from the Slack platform.

The installation process on some workspace is actually an OAuth authorization flow, in which we send the user to Slack to authorize our app on his workspace, and once he approves our app, we’ll need to exchange our temporary access code for a permanent access token which we’ll use in every operation which requires access to the Slack API.

Let’s start with a small snippet of our Slack service code.

To store these tokens, we’ll use the popular and quite simple Bolt DB framework:

go get github.com/boltdb/bolt/...
Enter fullscreen mode Exit fullscreen mode

Let’s initialize the DB and create a bucket of tokens, in which tokens are mapped to their workspaces (by ID):

package main

import (
    "log"

    "github.com/boltdb/bolt"
)

func configureDb() (*bolt.DB, error) {
    db, err := bolt.Open("slack.db", 0600, nil)
    if err != nil {
        return nil, err
    }
    if err = db.Update(func(tx *bolt.Tx) error {
        if _, err = tx.CreateBucketIfNotExists([]byte("tokens")); err != nil {
            return err
        }
        return nil
    }); err != nil {
        return nil, err
    }
    return db, nil
}

func main() {
    db, err := configureDb()
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = db.Close() }()
...
}
Enter fullscreen mode Exit fullscreen mode

NOTE: When developing a real app, consider storing your access tokens in a more secure way!

Developing Locally

As we’re developing locally and the Slack platform requires us to provide publicly accessible URLs, we need to create some connection between our local device and the wider network. For this we can use ngrok:

ngrok http 5400
Enter fullscreen mode Exit fullscreen mode

Running the above command should print a URL which exposes port 5400 on your local device to the outer world (e.g. ‘https://03e2-109-66-212-180.ngrok.io’).

Configuring Our App

To keep things simple, we’ll register a Slash Command for our app:

Navigate to Features → Slash Commands and click the ‘Create New Command’ button:

Image description

Fill out the following form:

Image description

Provide the name of the command (e.g. ‘plus’), the ngrok URL from the previous section suffixed with ‘/cmd/’ and the command name (e.g. ‘https://03e2-109-66-212-180.ngrok.io/cmd/plus’) and a description (e.g. ‘Sum 2 Integers’). The rest of the fields are optional.

*Setting Up App Installation *

As we’re developing a multi workspace app, we need to enable the OAuth installation flow.

Navigate to Features → OAuth & Permissions:

Image description

And click the ‘Add New Redirect URL’ button in the ‘Redirect URLs’ section:

Image description

Enter your ngrok URL with a ‘/install’ suffix attached to it (e.g. ‘https://03e2-109-66-212-180.ngrok.io/install’), click ‘Add’ and then click ‘Save URLs’.

Scopes

The scopes requested by our app are those that our app will request upon each installation to some workspace. You can see in the ‘Scopes’ section that we already have the ‘commands’ bot scope, as we’ve added a Slash Command to our app.

Enabling Public Distribution

Navigate to Settings → Manage Distribution:

Image description

And under the ‘Share Your App with Other Workspaces’ section, make sure that the hard coded information removal checkbox is checked:

Image description

Finally, click the ‘Activate Public Distribution’ button.

Connecting It All Together

Now that we have our awesome ‘Core’ Service and we’ve configured our Slack app, we can go ahead and continue writing the code of our Slack service.

We’ve already configured our storage mechanism, and you’ve probably noticed that we are already committed to implementing some endpoints we shared with the slack platform:

  • /install - to install our app using the OAuth flow.

  • /cmd/plus - to forward the request to sum 2 integers to our ‘Core’ service, using the input from the user on Slack.

Implementing the OAuth Installation Flow

As with every standard OAuth flow, we need a pair of client ID and secret. You can view both under the ‘App Credentials’ section after navigating to Settings → Basic Information:

Image description

We need to access the Slack platform, so let’s import the Go SDK:

go get -u github.com/slack-go/slack
Enter fullscreen mode Exit fullscreen mode

Let’s have a look at the installation handler:

func handleInstallation(db *bolt.DB) func(c *gin.Context) {
    return func(c *gin.Context) {
        _, errExists := c.GetQuery("error")
        if errExists {
            c.String(http.StatusOK, "error installing app")
            return
        }
        code, codeExists := c.GetQuery("code")
        if !codeExists {
            c.String(http.StatusBadRequest, "missing mandatory 'code' query parameter")
            return
        }
        resp, err := slack.GetOAuthV2Response(http.DefaultClient,
            os.Getenv("CLIENT_ID"),
            os.Getenv("CLIENT_SECRET"),
            code,
            "")
        if err != nil {
            c.String(http.StatusInternalServerError, "error exchanging temporary code for access token: %s", err.Error())
            return
        }
        if err = db.Update(func(tx *bolt.Tx) error {
            bucket := tx.Bucket([]byte("tokens"))
            if bucket == nil {
                return errors.New("error accessing tokens bucket")
            }
            return bucket.Put([]byte(resp.Team.ID), []byte(resp.AccessToken))
        }); err != nil {
            c.String(http.StatusInternalServerError, "error storing slack access token: %s", err.Error())
            return
        }
        c.Redirect(http.StatusFound, fmt.Sprintf("slack://app?team=%s&id=%s&tab=about", resp.Team.ID, resp.AppID))
    }
}
Enter fullscreen mode Exit fullscreen mode

Let’s break it down. First, we check for the presence of the ‘error’ query parameter. If this parameter is present, then it means the installation failed for some reason (e.g. the user declined to authorize our app). In that case we’ll abort the rest of the flow and just notify of an error.

Next, as in every standard OAuth flow, we’re checking for the temporary code by looking for the ‘code’ query parameter, and if it is present we use the Slack Go SDK to exchange that code for an access token, and store that token in our storage, mapped to the workspace ID.Finally, we redirect the user to our app page in his Slack client using Deep linking.

Notice how we’re reading the ‘CLIENT_ID’ and ‘CLIENT_SECRET’ env variables to perform the exchange operation.

Let’s also register our installation handler:

func main() {
    db, err := configureDb()
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = db.Close() }()
  app := gin.Default()
    app.Any("/install", handleInstallation(db))
...
}
Enter fullscreen mode Exit fullscreen mode

IMPORTANT: When developing a real app, consider adding a ‘state’ parameter to your OAuth flow for extra security!

Implementing the Slash Command

Before we implement our slash command endpoint, let’s implement some middleware that will make sure that requests to this endpoint are really coming from Slack.

There are several methods provided by the Slack platform to achieve such verification, but we’ll use the recommended and straightforward way of using the Signing Secret present under the ‘App Credentials’ section in Settings → Basic Information:

Image description

Here’s the signature verification middleware code:

func signatureVerification(c *gin.Context) {
    verifier, err := slack.NewSecretsVerifier(c.Request.Header, os.Getenv("SIGNATURE_SECRET"))
    if err != nil {
        c.String(http.StatusBadRequest, "error initializing signature verifier: %s", err.Error())
        return
    }
    bodyBytes, err := ioutil.ReadAll(c.Request.Body)
    if err != nil {
        c.String(http.StatusInternalServerError, "error reading request body: %s", err.Error())
        return
    }
    bodyBytesCopy := make([]byte, len(bodyBytes))
    copy(bodyBytesCopy, bodyBytes)
    c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytesCopy))
    if _, err = verifier.Write(bodyBytes); err != nil {
        c.String(http.StatusInternalServerError, "error writing request body bytes for verification: %s", err.Error())
        return
    }
    if err = verifier.Ensure(); err != nil {
        c.String(http.StatusUnauthorized, "error verifying slack signature: %s", err.Error())
        return
    }
    c.Next()
}
Enter fullscreen mode Exit fullscreen mode

First, we’re reading the signature verification secret from the ‘SIGNATURE_SECRET’ env variable.

Then, we’re reading the request body, while copying it and preserving it for later reads which may be invoked by other handlers in the chain, and finally we verify the signature of the request body. If all is well, we’re calling the ‘Next’ method to invoke any other handlers in the chain.

Let’s have a look at the code of the actual handler:

func handlePlus(db *bolt.DB) func(c *gin.Context) {
    return func(c *gin.Context) {
        cmd, err := slack.SlashCommandParse(c.Request)
        if err != nil {
            c.String(http.StatusBadRequest, "invalid slash command payload: %s", err.Error())
            return
        }
        inputArr := strings.Split(strings.TrimSpace(cmd.Text), " ")
        if len(inputArr) != 2 {
            c.String(http.StatusBadRequest, "invalid number of input parameters provided")
            return
        }
        num1, err := strconv.Atoi(inputArr[0])
        if err != nil {
            c.String(http.StatusBadRequest, "invalid 1st input parameter: %s", err.Error())
            return
        }
        num2, err := strconv.Atoi(inputArr[1])
        if err != nil {
            c.String(http.StatusBadRequest, "invalid 2nd input parameter: %s", err.Error())
            return
        }
        buf, err := json.Marshal(&Input{
            Num1: num1,
            Num2: num2,
        })
        if err != nil {
            c.String(http.StatusInternalServerError, "error preparing request body: %s", err.Error())
            return
        }
        resp, err := http.Post("http://localhost:8080", "application/json", bytes.NewBuffer(buf))
        if err != nil {
            c.String(http.StatusInternalServerError, "error preparing http request: %s", err.Error())
            return
        }
        if resp.StatusCode != http.StatusOK {
            c.String(http.StatusInternalServerError, "invalid status code returned: %d", resp.StatusCode)
            return
        }
        output := &Output{}
        if err = json.NewDecoder(resp.Body).Decode(output); err != nil {
            c.String(http.StatusInternalServerError, "invalid response: %s", err.Error())
            return
        }
        var token string
        if err = db.View(func(tx *bolt.Tx) error {
            bucket := tx.Bucket([]byte("tokens"))
            if bucket == nil {
                return errors.New("error accessing tokens bucket")
            }
            token = string(bucket.Get([]byte(cmd.TeamID)))
            return nil
        }); err != nil {
            c.String(http.StatusInternalServerError, "error reading slack access token: %s", err.Error())
            return
        }
        if _, _, _, err = slack.New(token).SendMessage(cmd.UserID, slack.MsgOptionText(fmt.Sprintf("Sum is %d", output.Sum), true)); err != nil {
            c.String(http.StatusInternalServerError, "error sending slack message: %s", err.Error())
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Let’s break it down. We’re parsing the request body to get the slash command input.

Then, we’re parsing the parameters passed in with the command to verify we have 2 integers (e.g. ‘/plus 1 2’ is valid, but ‘/plus 1 a’ is not).

Finally, we’re using the 2 provided parameters to invoke the endpoint on our ‘Core’ service to get the sum of the 2 given integers, sending the user a direct message with the calculated sum returned by the ‘Core’ service.

In order to send a direct message to the user we are reading the token mapped to the relevant workspace from the DB.

Also, we need to add the ‘chat:write’ bot scope for our app, otherwise our app will not be able to send direct messages to users.

Navigate to Settings → OAuth & Permissions, and under the ‘Scopes’ section add the required scope to the list of bot scopes required by your app:

Image description

Finally, let’s register our slash command handler under a new ‘cmd’ API group. Here’s the current code of our Slack service:

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"

    "github.com/boltdb/bolt"
    "github.com/gin-gonic/gin"
    "github.com/slack-go/slack"
    "github.com/slack-go/slack/slackevents"
)

type Input struct {
    Num1 int `json:"num1"`
    Num2 int `json:"num2"`
}

type Output struct {
    Sum int `json:"sum"`
}

func configureDb() (*bolt.DB, error) {
    db, err := bolt.Open("slack.db", 0600, nil)
    if err != nil {
        return nil, err
    }
    if err = db.Update(func(tx *bolt.Tx) error {
        if _, err = tx.CreateBucketIfNotExists([]byte("tokens")); err != nil {
            return err
        }
        return nil
    }); err != nil {
        return nil, err
    }
    return db, nil
}

func handleInstallation(db *bolt.DB) func(c *gin.Context) {
    return func(c *gin.Context) {
        _, errExists := c.GetQuery("error")
        if errExists {
            c.String(http.StatusOK, "error installing app")
            return
        }
        code, codeExists := c.GetQuery("code")
        if !codeExists {
            c.String(http.StatusBadRequest, "missing mandatory 'code' query parameter")
            return
        }
        resp, err := slack.GetOAuthV2Response(http.DefaultClient,
            os.Getenv("CLIENT_ID"),
            os.Getenv("CLIENT_SECRET"),
            code,
            "")
        if err != nil {
            c.String(http.StatusInternalServerError, "error exchanging temporary code for access token: %s", err.Error())
            return
        }
        if err = db.Update(func(tx *bolt.Tx) error {
            bucket := tx.Bucket([]byte("tokens"))
            if bucket == nil {
                return errors.New("error accessing tokens bucket")
            }
            return bucket.Put([]byte(resp.Team.ID), []byte(resp.AccessToken))
        }); err != nil {
            c.String(http.StatusInternalServerError, "error storing slack access token: %s", err.Error())
            return
        }
        c.Redirect(http.StatusFound, fmt.Sprintf("slack://app?team=%s&id=%s&tab=about", resp.Team.ID, resp.AppID))
    }
}

func signatureVerification(c *gin.Context) {
    verifier, err := slack.NewSecretsVerifier(c.Request.Header, os.Getenv("SIGNATURE_SECRET"))
    if err != nil {
        c.String(http.StatusBadRequest, "error initializing signature verifier: %s", err.Error())
        return
    }
    bodyBytes, err := ioutil.ReadAll(c.Request.Body)
    if err != nil {
        c.String(http.StatusInternalServerError, "error reading request body: %s", err.Error())
        return
    }
    bodyBytesCopy := make([]byte, len(bodyBytes))
    copy(bodyBytesCopy, bodyBytes)
    c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytesCopy))
    if _, err = verifier.Write(bodyBytes); err != nil {
        c.String(http.StatusInternalServerError, "error writing request body bytes for verification: %s", err.Error())
        return
    }
    if err = verifier.Ensure(); err != nil {
        c.String(http.StatusUnauthorized, "error verifying slack signature: %s", err.Error())
        return
    }
    c.Next()
}

func handlePlus(db *bolt.DB) func(c *gin.Context) {
    return func(c *gin.Context) {
        cmd, err := slack.SlashCommandParse(c.Request)
        if err != nil {
            c.String(http.StatusBadRequest, "invalid slash command payload: %s", err.Error())
            return
        }
        inputArr := strings.Split(strings.TrimSpace(cmd.Text), " ")
        if len(inputArr) != 2 {
            c.String(http.StatusBadRequest, "invalid number of input parameters provided")
            return
        }
        num1, err := strconv.Atoi(inputArr[0])
        if err != nil {
            c.String(http.StatusBadRequest, "invalid 1st input parameter: %s", err.Error())
            return
        }
        num2, err := strconv.Atoi(inputArr[1])
        if err != nil {
            c.String(http.StatusBadRequest, "invalid 2nd input parameter: %s", err.Error())
            return
        }
        buf, err := json.Marshal(&Input{
            Num1: num1,
            Num2: num2,
        })
        if err != nil {
            c.String(http.StatusInternalServerError, "error preparing request body: %s", err.Error())
            return
        }
        resp, err := http.Post("http://localhost:8080", "application/json", bytes.NewBuffer(buf))
        if err != nil {
            c.String(http.StatusInternalServerError, "error preparing http request: %s", err.Error())
            return
        }
        if resp.StatusCode != http.StatusOK {
            c.String(http.StatusInternalServerError, "invalid status code returned: %d", resp.StatusCode)
            return
        }
        output := &Output{}
        if err = json.NewDecoder(resp.Body).Decode(output); err != nil {
            c.String(http.StatusInternalServerError, "invalid response: %s", err.Error())
            return
        }
        var token string
        if err = db.View(func(tx *bolt.Tx) error {
            bucket := tx.Bucket([]byte("tokens"))
            if bucket == nil {
                return errors.New("error accessing tokens bucket")
            }
            token = string(bucket.Get([]byte(cmd.TeamID)))
            return nil
        }); err != nil {
            c.String(http.StatusInternalServerError, "error reading slack access token: %s", err.Error())
            return
        }
        if _, _, _, err = slack.New(token).SendMessage(cmd.UserID, slack.MsgOptionText(fmt.Sprintf("Sum is %d", output.Sum), true)); err != nil {
            c.String(http.StatusInternalServerError, "error sending slack message: %s", err.Error())
            return
        }
    }
}

func main() {
    db, err := configureDb()
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = db.Close() }()
    app := gin.Default()
    app.Any("/install", handleInstallation(db))
    cmdGroup := app.Group("/cmd")
    cmdGroup.Use(signatureVerification)
    cmdGroup.POST("/plus", handlePlus(db))
    _ = app.Run()
}
Enter fullscreen mode Exit fullscreen mode

For convenience, we copied the definitions of the ‘Input’ and ‘Output’ structs from our ‘Core’ service.

Notice that when we’re sending the direct message to the user we’re using the access token of the workspace the user invoked the command from.

Playing With Our App

Run your app using the following command, providing values for the required env variables:

PORT=5400 CLIENT_ID=... CLIENT_SECRET=... SIGNATURE_SECRET=... go run main.go

Enter fullscreen mode Exit fullscreen mode

The value of the ‘PORT’ variable is used by the gin framework if no argument is passed to the ‘Run’ method.

Let’s first install our app on some workspace. To do that, we need to initiate the OAuth installation flow. To do that, Navigate to Settings → Manage Distribution:

Image description

Press the ‘Copy’ button in the ‘Sharable URL’ section and paste the copied URL into a new tab in your browser.

This should open a page which resembles the following page, allowing you to choose a workspace and install the app to it:

Image description

Press ‘Allow’ and in the following window:

Image description

Press the ‘Open Slack’ button, which should take you to the ‘About’ page of your app in your Slack client:

Image description

Now, navigate to some public channel in your workspace and type in ‘/plus’:

Image description

Press enter to choose the ‘/plus’ command of your app, suffix the ‘/plus’ command with 2 integers of your choice and send the message, e.g.:

Image description

Once you send the message, you should receive a direct message from your app, notifying you of the result:

Image description

Image description

Listening to Events

The Slack Events API is an integral part of any Slack application, especially those that are intended to be distributed across multiple workspaces.

There are 3 kinds of events:

  • URL Verification - The Slack platform sends a challenge string, and our app should respond with that string and with a 200 OK status code. In this way the Slack platform checks that our app is alive.

  • Rate Limiting - The Slack platform informs our app of some rate limit being reached on some API endpoint. We can just acknowledge this event with a 200 OK status code.

  • Callback - The ‘interesting’ events. These events have an inner event which can have various types.

Let’s register our app to a basic app deletion event.

Here’s our Slack service code, including our event handler:

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"

    "github.com/boltdb/bolt"
    "github.com/gin-gonic/gin"
    "github.com/slack-go/slack"
    "github.com/slack-go/slack/slackevents"
)

type Input struct {
    Num1 int `json:"num1"`
    Num2 int `json:"num2"`
}

type Output struct {
    Sum int `json:"sum"`
}

func configureDb() (*bolt.DB, error) {
    db, err := bolt.Open("slack.db", 0600, nil)
    if err != nil {
        return nil, err
    }
    if err = db.Update(func(tx *bolt.Tx) error {
        if _, err = tx.CreateBucketIfNotExists([]byte("tokens")); err != nil {
            return err
        }
        return nil
    }); err != nil {
        return nil, err
    }
    return db, nil
}

func handleInstallation(db *bolt.DB) func(c *gin.Context) {
    return func(c *gin.Context) {
        _, errExists := c.GetQuery("error")
        if errExists {
            c.String(http.StatusOK, "error installing app")
            return
        }
        code, codeExists := c.GetQuery("code")
        if !codeExists {
            c.String(http.StatusBadRequest, "missing mandatory 'code' query parameter")
            return
        }
        resp, err := slack.GetOAuthV2Response(http.DefaultClient,
            os.Getenv("CLIENT_ID"),
            os.Getenv("CLIENT_SECRET"),
            code,
            "")
        if err != nil {
            c.String(http.StatusInternalServerError, "error exchanging temporary code for access token: %s", err.Error())
            return
        }
        if err = db.Update(func(tx *bolt.Tx) error {
            bucket := tx.Bucket([]byte("tokens"))
            if bucket == nil {
                return errors.New("error accessing tokens bucket")
            }
            return bucket.Put([]byte(resp.Team.ID), []byte(resp.AccessToken))
        }); err != nil {
            c.String(http.StatusInternalServerError, "error storing slack access token: %s", err.Error())
            return
        }
        c.Redirect(http.StatusFound, fmt.Sprintf("slack://app?team=%s&id=%s&tab=about", resp.Team.ID, resp.AppID))
    }
}

func signatureVerification(c *gin.Context) {
    verifier, err := slack.NewSecretsVerifier(c.Request.Header, os.Getenv("SIGNATURE_SECRET"))
    if err != nil {
        c.String(http.StatusBadRequest, "error initializing signature verifier: %s", err.Error())
        return
    }
    bodyBytes, err := ioutil.ReadAll(c.Request.Body)
    if err != nil {
        c.String(http.StatusInternalServerError, "error reading request body: %s", err.Error())
        return
    }
    bodyBytesCopy := make([]byte, len(bodyBytes))
    copy(bodyBytesCopy, bodyBytes)
    c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytesCopy))
    if _, err = verifier.Write(bodyBytes); err != nil {
        c.String(http.StatusInternalServerError, "error writing request body bytes for verification: %s", err.Error())
        return
    }
    if err = verifier.Ensure(); err != nil {
        c.String(http.StatusUnauthorized, "error verifying slack signature: %s", err.Error())
        return
    }
    c.Next()
}

func handlePlus(db *bolt.DB) func(c *gin.Context) {
    return func(c *gin.Context) {
        cmd, err := slack.SlashCommandParse(c.Request)
        if err != nil {
            c.String(http.StatusBadRequest, "invalid slash command payload: %s", err.Error())
            return
        }
        inputArr := strings.Split(strings.TrimSpace(cmd.Text), " ")
        if len(inputArr) != 2 {
            c.String(http.StatusBadRequest, "invalid number of input parameters provided")
            return
        }
        num1, err := strconv.Atoi(inputArr[0])
        if err != nil {
            c.String(http.StatusBadRequest, "invalid 1st input parameter: %s", err.Error())
            return
        }
        num2, err := strconv.Atoi(inputArr[1])
        if err != nil {
            c.String(http.StatusBadRequest, "invalid 2nd input parameter: %s", err.Error())
            return
        }
        buf, err := json.Marshal(&Input{
            Num1: num1,
            Num2: num2,
        })
        if err != nil {
            c.String(http.StatusInternalServerError, "error preparing request body: %s", err.Error())
            return
        }
        resp, err := http.Post("http://localhost:8080", "application/json", bytes.NewBuffer(buf))
        if err != nil {
            c.String(http.StatusInternalServerError, "error preparing http request: %s", err.Error())
            return
        }
        if resp.StatusCode != http.StatusOK {
            c.String(http.StatusInternalServerError, "invalid status code returned: %d", resp.StatusCode)
            return
        }
        output := &Output{}
        if err = json.NewDecoder(resp.Body).Decode(output); err != nil {
            c.String(http.StatusInternalServerError, "invalid response: %s", err.Error())
            return
        }
        var token string
        if err = db.View(func(tx *bolt.Tx) error {
            bucket := tx.Bucket([]byte("tokens"))
            if bucket == nil {
                return errors.New("error accessing tokens bucket")
            }
            token = string(bucket.Get([]byte(cmd.TeamID)))
            return nil
        }); err != nil {
            c.String(http.StatusInternalServerError, "error reading slack access token: %s", err.Error())
            return
        }
        if _, _, _, err = slack.New(token).SendMessage(cmd.UserID, slack.MsgOptionText(fmt.Sprintf("Sum is %d", output.Sum), true)); err != nil {
            c.String(http.StatusInternalServerError, "error sending slack message: %s", err.Error())
            return
        }
    }
}

func handleEvent(db *bolt.DB) func(c *gin.Context) {
    return func(c *gin.Context) {
        bodyBytes, err := ioutil.ReadAll(c.Request.Body)
        if err != nil {
            c.String(http.StatusInternalServerError, "error reading slack event payload: %s", err.Error())
            return
        }
        event, err := slackevents.ParseEvent(bodyBytes, slackevents.OptionNoVerifyToken())
        switch event.Type {
        case slackevents.URLVerification:
            ve, ok := event.Data.(*slackevents.EventsAPIURLVerificationEvent)
            if !ok {
                c.String(http.StatusBadRequest, "invalid url verification event payload sent from slack")
                return
            }
            c.JSON(http.StatusOK, &slackevents.ChallengeResponse{
                Challenge: ve.Challenge,
            })
        case slackevents.AppRateLimited:
            c.String(http.StatusOK, "ack")
        case slackevents.CallbackEvent:
            ce, ok := event.Data.(*slackevents.EventsAPICallbackEvent)
            if !ok {
                c.String(http.StatusBadRequest, "invalid callback event payload sent from slack")
                return
            }
            ie := &slackevents.EventsAPIInnerEvent{}
            if err = json.Unmarshal(*ce.InnerEvent, ie); err != nil {
                c.String(http.StatusBadRequest, "invalid inner event payload sent from slack: %s", err.Error())
                return
            }
            switch ie.Type {
            case slackevents.AppUninstalled:
                if err = db.Update(func(tx *bolt.Tx) error {
                    bucket := tx.Bucket([]byte("tokens"))
                    if bucket == nil {
                        return errors.New("error accessing tokens bucket")
                    }
                    return bucket.Delete([]byte(event.TeamID))
                }); err != nil {
                    c.String(http.StatusInternalServerError, "error handling app uninstallation")
                }
            default:
                c.String(http.StatusBadRequest, "no handler for event of given type")
            }
        default:
            c.String(http.StatusBadRequest, "invalid event type sent from slack")
        }
    }
}

func main() {
    db, err := configureDb()
    if err != nil {
        log.Fatal(err)
    }
    defer func() { _ = db.Close() }()
    app := gin.Default()
    app.Any("/install", handleInstallation(db))
    cmdGroup := app.Group("/cmd")
    cmdGroup.Use(signatureVerification)
    cmdGroup.POST("/plus", handlePlus(db))
    eventGroup := app.Group("/event")
    eventGroup.Use(signatureVerification)
    eventGroup.POST("/handle", handleEvent(db))
    _ = app.Run()
}
Enter fullscreen mode Exit fullscreen mode

When an event is sent, we check the type, and respond accordingly. The only callback event we’re going to handle is app uninstallation. We handle it by deleting the access token we used to access the Slack platform when we needed to interact with the workspace the app was deleted from.

Run the updated version of the app which includes the event handler and then navigate to Features → Event Subscriptions:

Image description

Enable events and under the ‘Request URL’ section, enter your ngrok URL suffixed with ‘/event/handle’ (e.g. ‘https://03e2-109-66-212-180.ngrok.io/event/handle’) and wait for the Slack platform to verify your events URL:

Image description

Then, under the ‘Subscribe to bot events’ section, add the ‘app_uninstalled’ event.

Deleting Our App

From the direct message channel your app opened with your user to inform you of the result of summing 2 integers, click on the app name:

Image description

Press ‘Configuration’, which should open a browser window with the details of the app installation on your workspace:

Image description

And press the ‘Remove App’ button under the section with the same name.

Open your workspace from your Slack client. You should notice that the direct message channel with your app is not there anymore:

Image description

Also, you can see in the output of your Slack service, that a request was made to the events endpoint and that it was responded with a 200 OK status code:

[GIN] 2022/05/22 - 00:33:34 | 200 |   39.002459ms |             ::1 | POST     "/event/handle"

Enter fullscreen mode Exit fullscreen mode

Summary

Here’s a Github repository containing all of the code relevant for this article. The tools referenced in this article include:

Hope you enjoyed reading and writing a cool Slack application together!

Top comments (1)

Collapse
 
johnson_brad profile image
Brad Johnson

Wow @uduhamudu! This must be a record for the longest, but also most thorough and thoughtful Ops Community post!

Thank you for contributing, I'm sure many other members have been faced with this challenge before.