Integrating custom workflow with Slack slash commands

a short guide on creating new slash commands served using a NodeJS app

Posted by Prasanna Crasta on October 12, 2018

Like most modern IT organizations, we at Bayer heavily use Slack within our software development community. Recently I had an opportunity to integrate a custom workflow within our slack team. The goal was to be able to search our company developer documentation using Slack and recommend a particular link by posting it back in a channel. As I went about this integration, I learnt quite a few things which I thought would be worth a blog post.

By the way, if you need to read up on slash commands I would encourage you to check out this link

So, what are we waiting for? Let’s get started.

Goal:

To create a slash command which upon invocation with some text (say “http cat codes”) will return matching document links along with some interactive buttons (only visible to the user). The user can then click the corresponding buttons which will submit the link to the document in the channel.

Http Cat Search Result

Technologies used

  • NodeJS
  • Javascript

NPM modules used

Creating a simple node app

Let’s spin up a NodeJS server using the express web framework. Whenever a user will use our slash command, this app will receive a url encoded POST request.

save below snippet as server.js

const express = require("express")
const bodyParser = require("body-parser")
const slashCommand = require("./slashCommand") //will be used for handling the slash command request
const slackInteractions = require("./slackInteractions") //will be used for handling button interactions.

const app = express()

const urlencodedParser = bodyParser.raw({
    type: "application/x-www-form-urlencoded",
    inflate: true,
    limit: "100kb"
})

app.post("/", urlencodedParser, slashCommand)
app.use("/slack/actions", slackInteractions().expressMiddleware())

app.use("/*", (err, req, res, next) => {
    console.error(err) // handle uncaught errors
    next()
})

const hostname = process.env.NODE_ENV === "production" ? undefined : "127.0.0.1" // unlike default, only reachable from this machine
const port = parseInt(process.env.PORT || 3000, 10);

const server = app.listen(port, hostname, () => {
    const address = server.address()
    const url = `http://${address.host || "localhost"}:${port}`
    console.info(`Listening at ${url}/`)
})

I am using body-parser to parse application/x-www-form-urlencoded type of data posted to the / url.

Note the use of this middleware before passing on the request to our slashCommand handler function.

I have a second route for handling slack actions, this is handled by the slackInteractions middleware. Remember to not use any parser middleware for this route, because the slackInteractions middleware expects a raw body to calculate the signature & verification.

Implementing the command handler

Below is the basic implementation. The first thing we need to do is verify that the request is a genuine one coming from slack (I’ll get to the implementation later in this section). If the request is validated we will parse the text that was submitted with the command, carry out the search and return the results as message attachments.

save as slashCommand.js (we are using this in server.js)

const queryString = require("query-string")
const verifySlackRequest = require("./verifySlackRequest")
const performSearch = require("./performSearch")

const slashCommand = async (req, res) => {
    if (verifySlackRequest(req)) {
        const message = queryString.parse(req.body.toString())
        const data = await performSearch(message, res)
        return res.json(data)
    }
    return res.status(401).send("request can not be fulfilled")
}

module.exports = slashCommand

Verifying the incoming slack request

Slack signs every request that is sent as part of slash command and includes this signature as a request header x-slack-signature. It is upon us to verify the request is a genuine one coming from slack by recreating the signature using a special Signing Secret assigned to our app. We will get this secret when we create a new app in Slack (we will get to this configuration step later).

The verification process involves recreating the signature by combining the Signing Secret of our app, the request timestamp and the request body using a standard HMAC-SHA256 keyed hash. The signature that we created is then matched against the signature passed in the request header. You can read more details about the process here.

To recreate the signature we will make use of the NodeJS crypto module.

const crypto = require("crypto")

const verifySlackRequest = (req) => {
    const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET
    const timestamp = req.headers["x-slack-request-timestamp"]
    const headerSignature = req.headers["x-slack-signature"]
    const basestring = `v0:${timestamp}:${req.body.toString()}`
    const hash = crypto
        .createHmac("sha256", SLACK_SIGNING_SECRET)
        .update(basestring)
        .digest("hex")
    const calculatedSignature = `v0=${hash}`
    return headerSignature === calculatedSignature
}

module.exports = verifySlackRequest

Performing search and returning results

This part involves querying a backend API that would return matching results, formatting these results as slack message attachments and including additional interactive buttons. To keep the example simple instead of writing the backend API we will match the search text against static mock data.

save as performSearch.js (we are using this in server.js)

const mockData = [
    {"id": 0, "title": "http cat 300", "url": "https://http.cat/300" },
    {"id": 1, "title": "http cat 301", "url": "https://http.cat/301" },
    {"id": 2, "title": "http cat 302", "url": "https://http.cat/302" },
    {"id": 3, "title": "http cat 303", "url": "https://http.cat/303" },
    {"id": 4, "title": "http cat 304", "url": "https://http.cat/304" },
    {"id": 5, "title": "http cat 400", "url": "https://http.cat/400" },
    {"id": 6, "title": "http cat 401", "url": "https://http.cat/401" },
    {"id": 7, "title": "http cat 402", "url": "https://http.cat/402" },
    {"id": 8, "title": "http cat 403", "url": "https://http.cat/403" },
    {"id": 9, "title": "http cat 404", "url": "https://http.cat/404" }
]

const createMessageWithAttachments = (searchText, messageAttachments, buttonValues) => {
    const text = `Found ${messageAttachments.length} document(s) matching _*${searchText}*_`
    let attachments = []
    if (messageAttachments.length > 0) {
        attachments = messageAttachments.slice(0, 5)
        attachments.push({
            title: 'Click one the below button(s) to post link to the channel',
            callback_id: 'single_link_btn',
            actions: buttonValues.map(({title, url}, index) => ({
                name: 'results',
                text: `Link ${index + 1}`,
                type: 'button',
                style: 'primary',
                value: `${url}?title=${title}`
            }))
        })
    }
    return {text, attachments}
}

const noResultsMessage = (searchText) => ({
    text: `no results found matching _*${searchText}*_`
})

const performSearch = (message) => {
    const searchText = message.text
    const matchingDocs = mockData.filter((item) => item.title.includes(searchText))
    const fields = matchingDocs.map((doc, index) => ({
        title: `${index + 1}. ${doc.title}`,
        title_link: `${doc.url}`
    }))
    const buttonValues = matchingDocs.map((doc) => {
        const {title} = doc
        return {
            title,
            url: `${doc.url}?title=${encodeURIComponent(title)}`
        }
    })
    if (fields.length > 0) {
        return Promise.resolve(createMessageWithAttachments(searchText, fields, buttonValues))
    }
    return Promise.resolve(noResultsMessage(searchText))
}

module.exports = performSearch

Handler for interactive buttons

Okay, this is the final piece of code that we need. This is for handling the request when the user clicks one of the link buttons as shown above. We need to supply the signing secret to the adapter.

Note: the callbackId single_link_btn should match the callback id specified for the button links when we generated the original message.

So, in this case if the user clicks a button Link 1 it will submit a message back in the channel with link 1, this time visible to everyone in that channel and remove the original message that was posted (only visible to the user who initiated the command).

save as slackInteractions.js (we are using this in server.js)

const {createMessageAdapter} = require('@slack/interactive-messages')
const querystring = require('query-string')

module.exports = () => {
    const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET

    const slackInteractions = createMessageAdapter(SLACK_SIGNING_SECRET)

    slackInteractions.action({callbackId: 'single_link_btn', type: 'button'}, (payload, respond) => {
        const {actions, channel} = payload
        const url = actions[0].value.split('?title=')[0]
        const title = decodeURIComponent(actions[0].value.split('?title=')[1])
        respond({
            replace_original: true,
            response_type: 'in_channel',
            delete_original: true,
            text: 'see this link',
            attachments: [
                {
                    color: '#36a64f',
                    title,
                    title_link: url
                }
            ]
        })
    })

    return slackInteractions
}

Now that we are done you could run the app on a server where it is publicly accessible. (say e.g. https://my-super-useless-https-cats-search-api.com)

Configuring Slack App

Create a new app using the slack api here.

Provide a name for your app (say HTTP Cats Search) and choose your slack workspace.

Next in the Add Features and functionality section of your slack app

  1. Enable Slash Commands and provide details for command & request url

    • Give a descriptive command (say /http-cats)

    • Provide a request url (say https://my-super-useless-https-cats-search-api.com)

    • Note: we serve the request at base url as configured in server.js

    In addition provide a short description & usage hints.

  2. Enable Interactive Components and provide request url

    This would be the second route we configured in server.js. So in our case https://my-super-useless-https-cats-search-api.com/slack/actions

  3. Finally install the app in your workspace.

Make a note of the Signing Secret assigned to this slack app and provide that as an env variable to your node app.

At this time, you should have a /http-cats command available in your workspace and if you invoke it (say /http-cats 40) you should get back the results like above. Click one of the link buttons and that link should be posted in the channel visible to everyone.

See the demo below

Http Cat Search Result Demo

I hope you enjoyed this post, and are now ready to create your own custom slash commands for your workflow needs.

posted on October 12, 2018 by
Prasanna Crasta