Hugo Gresse
Posted on August 15, 2020
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
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
- Create a new project on firebase.google.com
- Install Firebase CLI:
npm install -g firebase-tools
- Login:
firebase login
- Init the local repo:
firebase init
and select Firestore, Functions, Hosting. I've picked Typescript for the functions language. - Within
functions/
dir, create the 3 HTTP functions listed above and implement them with a dumb answer:response.send()
. - Export them in the
index.ts
:export {slackOAuth} from './https/slackOAuth'
- 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()
}
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
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
- add a Bot Token Scope:
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
After deploying the functions, your code can access them with:
import * as functions from 'firebase-functions'
functions.config().slack.client_id
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
Implementing the OAuth
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:
- Validating that the environment variables exist
- Answering for the SSL Check Slack does regularly
- Validating the request (code, method)
- Exchange the temporary Slack OAuth verifier code for an access token (using
oauth.v2.access
Slack API) - Saving the token to Firestore database.
- 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
})
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()
})
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
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:
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.
Implementation guidelines:
- 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
- Sending a ACK to Slack:
response.send()
to prevent a timeout after 3s on Slack side. - Getting the app token, if any
- [Your bot logic here]
- Answering to the
response_url
with the message.
Let's go a little deeper in the integrity check.
- First of all, you'll need to stringify the request body using
RFC1738
which is possible using theqs
package in Node.const requestBody = qs.stringify(request.body, {format: 'RFC1738'})
-
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')
Checking the Slack signature and your new digest:
crypto.timingSafeEqual(
Buffer.from(digest, 'utf8'),
Buffer.from(requestSlackSignature, 'utf8'))
We are using this safe equal method to prevent a timing attack on the string compare (more info here)
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
}
}
]
})
})
There is two ways to compose message in Slack:
- the block format
- the legacy format: a
text
and some optionalattachments
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.
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:
- 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)
- Verify the signature of the message
- Get the payload type & check request data
a. type ===
block_actions
? b. action value present? (this will be theaccessory.value
of the block in example above) - [your bot logic]
- Call the
response_url
with a new payload of your choosing. - To convert a
ephemeral
message to ain_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.
Posted on August 15, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.