Making a Slack bot from scratch with embedded player (part 1)

hugogresse

Hugo Gresse

Posted on August 15, 2020

Making a Slack bot from scratch with embedded player (part 1)

Some time ago, I've found an awesome soundboard for french TV Show Kaamelott. I wanted to make it available on Slack like the Giphy app, and distribute it. Here is the story.
In this part 1, I will explain how to make a Slack bot, and in part 2, I will explain the pitfall & specific details for an embedded player within Slack.

Demo

Here is the actual result
Bot preview

Vocabulary

  • Interactivity: when a user click or interact with a message a bot sent. For example, when Giphy propose you a gif, selecting it to be sent in the channel will send an Interactivity request.
  • Slash command: when a user submit a new /mycommand myinput within Slack, this will trigger an HTTP Request.
  • OAuth 2: authentification protocol used by Slack to authentificate a bot and link it to a Slack workplace
  • Unfurling: the process that Slack does to add a preview (text/image/rich) below a link

Disclaimer: this is not an indepth tutorial but an overview on the steps that you'll need to do in order to make your first Slack command bot.

Preparing the server

Let's pick Firebase stack to make this project. Firebase offer all the services we need to setup this bot easily: an hosting, a serverless platform and a database + all the SDKs you need to communicate with them.
Within Firebase, we'll need at least 3 serverless functions:

  • slackCommand
  • slackInteractivity
  • slackOAuth
  1. Create a new project on firebase.google.com
  2. Install Firebase CLI: npm install -g firebase-tools
  3. Login: firebase login
  4. Init the local repo: firebase init and select Firestore, Functions, Hosting. I've picked Typescript for the functions language.
  5. Within functions/ dir, create the 3 HTTP functions listed above and implement them with a dumb answer: response.send().
  6. Export them in the index.ts: export {slackOAuth} from './https/slackOAuth'
  7. Deploy them using firebase deploy

An exemple of a dumb serverless functions on Firebase in TS:

import * as functions from 'firebase-functions'

export const slackCommand = functions.https.onRequest(async (request, response) => {
    return response.send()
}
Enter fullscreen mode Exit fullscreen mode

At this time, you'll have deployed three functions. For each function you have a unique URL that you can call from everywhere. Save those URLs for the following part.

Creating the Slack App

useless screenshot of a Slack app dashboard

We now need to create a new Slack app here. The app will be registered to a workplace so you can test it easily. You'll need to fill some information & images, and then configure it as bellow:

  • Enable Interactivity: this will allow you to receive an HTTP Request when a user click on a button your Slack bot sent. The URL needs to be the one you've setup in the last parth for the slackInteractivity function.
  • Add a Slash command, use the URL for the slackCommand function.
  • Configure OAuth:
    • add a Bot Token Scope: commands that will allow the bot the receive the command
    • add a Redirect URLs: the URL for the slackOAuth function

This is almost all of what you'll need related to Slack. At this time, a bot user will already be available within your Slack workplace, and the command too, though it will not do much.

You can also find the "Add to Slack" button in "Manage distribution" of the app, the URL will be useful to test the OAuth proccess.

Add the Slack app credentials as environment variable for the functions

We'll need the following environement var in the serverless functions:

  • Slack client_id
  • Slack client_secret
  • Slack signing_secret

You can add them to the function using Firebase CLI:

firebase functions:config:set slack.client_id=XXXX
Enter fullscreen mode Exit fullscreen mode

After deploying the functions, your code can access them with:

import * as functions from 'firebase-functions'

functions.config().slack.client_id
Enter fullscreen mode Exit fullscreen mode

To run the functions locally (not very useful for a Slack bot), you'll need to populate a .runtimeconfig within the functions dir:

firebase functions:config:get > .runtimeconfig.json
Enter fullscreen mode Exit fullscreen mode

Implementing the OAuth

slak install process

The goal of this part is to handle any workplace when the bot is added using the "Add to Slack" button.
After allowing the bot to a workplace with the "Add to Slack" button, Slack will redirect the user to the slackOAuth function. This request will allow the Slack app to add its configuration to the new workplace (slash command, command scope, bot profil) as well has having a valid token on this workplace.
The function is composed of the following parts:

  1. Validating that the environment variables exist
  2. Answering for the SSL Check Slack does regularly
  3. Validating the request (code, method)
  4. Exchange the temporary Slack OAuth verifier code for an access token (using oauth.v2.access Slack API)
  5. Saving the token to Firestore database.
  6. Redirecting the user to a success or failure page.

You can find the full code of this function here, but here is some interesting part:

Get environment variable in functions using functions.config().my_object.my_key

Call the Slack oauth.v2.access API

    import fetch from 'node-fetch'
    import {URLSearchParams} from 'url'

    const params = new URLSearchParams()
    params.append('code', `${request.query.code}`)
    params.append('client_id', `${slack.client_id}`)
    params.append('client_secret', `${slack.client_secret}`)
    params.append('redirect_uri', `https://us-central1-${process.env.GCLOUD_PROJECT}.cloudfunctions.net/slackOAuth`)

    const oauthV2AccessResult = await fetch("https://slack.com/api/oauth.v2.access", {
        method: "POST",
        body: params
    })
Enter fullscreen mode Exit fullscreen mode

Save the token to Firestore:

   import * as admin from "firebase-admin"
   admin.initializeApp()

   const slackResultData = await oauthV2AccessResult.json()

   admin.firestore()
        .collection("installations")
        .doc(slackResultData.team.id).set({
            token: slackResultData.access_token,
            teamId: slackResultData.team.id,
            teamName: slackResultData.team.name,
            createdAt: serverTimestamp()
        })
Enter fullscreen mode Exit fullscreen mode

The not funny part is that Slack cannot call a local URL, so you'll need to deploy this each time to test it. Here is a shortcut to only deploy a function:

firebase deploy --only functions:slackOAuth
Enter fullscreen mode Exit fullscreen mode

At this time, if everything work, upon calling the app installation URL (within Slack Manage distribution app page), you should be asked to allow the app and then redirected to success page (in my case, it's this one).
The token will have been saved in Firestore:
Firestore database installations

Slack OAuth documentation

Responding to a command request

When a new command is executed in Slack, the slackCommand function will be called. This is post of your bot logic will take place. I believe this was the hardest part for me.

slak command

Implementation guidelines:

  1. Verify the request integrity:
    • only POST request
    • checking if x-slack-signature & x-slack-request-timestamp headers are present
    • preventing request attack
    • making a hash of the request body using hmac 256
    • comparing the hash with the x-slack-signature
  2. Sending a ACK to Slack: response.send() to prevent a timeout after 3s on Slack side.
  3. Getting the app token, if any
  4. [Your bot logic here]
  5. Answering to the response_url with the message.

Let's go a little deeper in the integrity check.

  1. First of all, you'll need to stringify the request body using RFC1738 which is possible using the qs package in Node. const requestBody = qs.stringify(request.body, {format: 'RFC1738'})
  2. Using the Node crypto package, make the digest:

    import * as crypto from "crypto"
    const digest = 'v0=' + crypto.createHmac('sha256', signingSecret)
        .update(`v0:${requestSlackTimestamp}:${requestBody}`)
        .digest('hex')
    
  3. Checking the Slack signature and your new digest:

crypto.timingSafeEqual(
        Buffer.from(digest, 'utf8'),
        Buffer.from(requestSlackSignature, 'utf8'))
Enter fullscreen mode Exit fullscreen mode

We are using this safe equal method to prevent a timing attack on the string compare (more info here)

Slack verifying documentation

Sending some Block back to Slack

Assuming you now have the token (bot token usually start with xoxb-), you can send message in Slack using the response_url provided in the request body:

    const responseUrl = request.body.response_url

    return fetch(responseUrl, {
        method: "POST",
        body: JSON.stringify({
            blocks: [
                {
                    type: "section",
                    text: {
                        type: "mrkdwn",
                        text: `An \n awesome _text_`
                    },
                    accessory: {
                        type: "button",
                        text: {
                            type: "plain_text",
                            text: "Send",
                            emoji: true
                        },
                        value: someValue
                    }
                }
            ]
        })
    })
Enter fullscreen mode Exit fullscreen mode

There is two ways to compose message in Slack:

  • the block format
  • the legacy format: a text and some optional attachments

Usually, you should go with the block format, as the BlockKit Builder is really great and the layout possibilities are all you need.

The API is here

Some messages are only visible to the user issuing the command (ephemeral) and other are public within a conversation (in_channel). This will be explained in part 2.

Ephemeral message from the bot

Interactivity implementation

When an user interact with a bot message (clicking on button from the message), slackInteractivity function is called.

Here is some implementation steps:

  1. Send an empty response to prevent the 3s Slack timeout. (some functions may be longer to start if they are cold (not called for 10min)
  2. Verify the signature of the message
  3. Get the payload type & check request data a. type === block_actions? b. action value present? (this will be the accessory.value of the block in example above)
  4. [your bot logic]
  5. Call the response_url with a new payload of your choosing.
  6. To convert a ephemeral message to a in_channel you'll need to wait for the part 2 soon.

And 🎉

You'll then need to deploy everything, add your bot logic and fix the bugs.

In part 2, I'll explain my own bot logic where I'm using Twitter as an embeded player for Slack as well as some other implementation details. Stay tuned.

Sources code of the bot is here on github.com.

Thanks Roch Dardié for proofreading me.

💖 💪 🙅 🚩
hugogresse
Hugo Gresse

Posted on August 15, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related