Building a Slack bot
… and using it to send status messages or other notifications to Slack from any browser app
TL;DR
If you are looking for a way to send messages to a Slack channel look no further than their webhooks. But if you need to send those message from a client side app running in a browser you‘ll need some extra help. Web-hooks are POST requests and will fail CORS pre-flight checks done by the browser. You could hack it by using a 'Content-type’: ‘application/x-wxw-form-urlencoded
header. This skips pre-flight check and POST’s as a “simple” request. But it’s definitely a hack and if the receiving server changes its policy you’ll be dead in the water. Instead you’ll need to build a Slack bot. It’ll act as middleware between your Slack channels and your client app. Slack has Node, Python & Java SDK’s for doing that.
I’ll be showcasing Node. It’s an Express like framework that gives you the full power of Slacks web-api and app SDK. Using it to message channels from client apps is as simple as hitting an endpoint. You can also create slash commands for slack-to-bot communication. I’ll show you how to deploy the bot using Google cloud functions. See the link in references for other cloud provider examples.
This blog takes a look at why you shouldn’t use webhooks in a client, even though you can. How to setup and build a Node slack bot that receives requests from a client app and posts to any Slack channel. Along with Slack’s Bolt.js SDK, I’ll be using Typescript, Express, and Node. I’ll also show you how we deploy it to Google cloud functions.
What are we solving and why bother
At the company I work at we needed to receive notification from an internal app browser app. It’s used for uploading drone imagery to our services. An “upload” can be anywhere from 100s to 1000s of images from a single flight. We wanted notification to track the start, progress, errors and completion. Users can also pause and resume an upload if their internet connection is weak, which we also want to track.
We looked at three options for notifications, Sentry, Slack and Google Analytics. We settled with Slack for three reasons;
- Notifications are for non-technical people,
- Implementation looked straight forward using webhooks… (a false assumption at best), and
- We wanted the results to be accessible to anyone in the company.
Sentry is for technical people and we don’t use Google Analytics much. So it needed to be Slack.
Webhooks for messaging a Slack channel
…and why you should always test your assumptions!
Slack has more than four ways to interface with its services. One of the simplest is channel scoped webhooks. All you need is a Slack app installed in the channel you want to message (creating one is well documented, see the resources). Generate the incoming webhook and post to it with a JSON payload. Done! All I had to do was use fetch
from my uploader app and post to the channel on each event. Not so easy…
Access to XMLHttpRequest at'WEBHOOK_ADDRESS'from origin 'https://my.uploaderapp.com' has been blocked byCORS policy: Response to preflight request doesn't pass access control check:It does not have HTTP ok status.
This stumped me for an embarrassingly long time. I thought up numerous reasons why it wouldn’t work:
- was it
https
in my local dev env — nope! - maybe it was my local env? Deployed it ad-hoc and still no fix — damn!
- Let me try Slacks
@slack/web-api
Node package… again no! It only works on the server…
The penny slowly started to drop. But I didn’t want to give up yet, I’d invested so much time trying to get my request to work…! Some deep Googling eventually surfaced a Content-type
header that does not need a preflight request. The cause of my CORS error.
'Content-type': 'application/x-www-form-urlencoded'
Further research indicates it’s common practice for API’s to accept this content type. But, we didn’t like the idea of using it to get around a CORs issue for a couple of reasons.
- We don’t have control over Slacks services and there’s no guarantee it will be supported in the future.
- Using the webhook this way is not idiomatic. There’s no official documentation. And I couldn’t find any tutorials or blogs about how it could be done either.
It was clear that we should build our own Slack bot and use it as middleware for notifications.
Building a Slack bot using Typescript, Node and Bolt
Slack has a Javascript framework called Bolt for building bots. It’s api provides most of what we need to communicate with Slack and to receive requests from the internet. Its built on top of Express so if you’re familiar with it you’ll feel right at home.
1. Scaffolding a Node app and setting up a development environment
Starting with an empty git repo and after I’ve initialised with yarn
I install all these dependencies:
yarn install -D typescript nodemon tsc-watch @types/node @types/node-fetch
Typescript for compiling code to ES5. nodemon
for restarting the Node server when a file changes (think HMR for node apps). tsc-watch
for running both together while using Typescript in watch mode.
I then initialised a Typescript project with npx tsc --init
. I like doing it this way because the .tsconfig.json
file it produces has most of the properties with comments included (all commented out except a handful). I then whittled down the config options to these:
The compiled files will go into the dist
folder, both for development and then when building the deployable code. We need commonjs
modules to satisfy a Google cloud functions requirement. The output code can be whatever you like. I’m choosing to use a node>=14.1
runtime so using a target of ES2020
will be fine. Using a more modern version will increase the speed of build too.
To start my dev environment I’ll use tsc-watch
, running Typescript in watch mode, and auto re-compiling each time I make a change. It also allows me to run a second command on the same process. I need to run an Express server which I’ll walk through in a bit. My Node server needs to re-start each time a file is changed and for that I use nodemon
. The script I use in my package.json looks like this:
// package.json.scripts "start": "NODE_ENV=development tsc-watch --onSuccess \"nodemon dist/index.js\""
2. Setting up a Bolt app and running a local server
With the foundations in place I can make a start on building my bot. First, some more dependancies:
yarn install @slack/bolt @slack/web-api body-parser cors dotenv express
I also need a Slack app. I recommend the getting started steps in the Bolt docs. If you’re following along come back once you have a token and signing secret. Their guide covers many of the steps I show here but I’ll be adding extra pieces for setting up in-coming requests and making it deployable to GCP functions.
The heart of the app looks like this:
The code speaks for itself, but there are a few things to point out. The receiver
is an instance of ExpressReciever
. It’s a built in class that gives us an Express app and router
— handy for endpoints. In development we’ll need to start the app locally when running yarn start
(line 31).
Running yarn start
will spin up the dev server and we should see Started slack bot 🚀 — PORT: 3000
in the console. You’ll also see output from tsc-watch
and nodemon
.
3. Middleware for using Express
The bare necessities for an Express app as far as middleware goes is solving CORS
and providing a way to parse response bodies. There’s nothing to complex here as I’ve already installed the packages we need, cors
and body-parser
, and I’m using the default configuration for each. I’ll apply them to the express receiver I created in step 2:
Using Slacks web-api for messaging a channel
Now that we’ve got a server running we can get to the fun stuff. Our bot can receive request from the internet thanks to ExpressReceiver. To make it happen I’ll build an endpoint to hit and I’ll need a way to send messages back to Slack.
1. Creating an Express router
I’ll create my routes in a separate directory for clarity and readability.
You can use Express to create a Router
instance which I can add multiple routes too. I’ll then pass this router into my primary catch all route back in the index.ts
.
2. Adding an instance of the web-api to each request
To send messages to Slack we’ll need access to the bolt apps client
. If I had all my route logic in index.ts
I’d be able to use the app
directly. But I’ve chosen to put them in a separate directory so I need a way of accessing it in each request. To do this I instantiated Slacks web-api used custom middleware to add it to the response.locals
property.
I’d tried putting it directly onto the request
object (because that made more sense to me) but the tsc
compiler was not happy with me. I couldn’t find a way to extend the Request
interface, so after some googling I settled on the result
option. The middleware runs before my routes, so every request will have access to the webClient
. This gives me a way to interact with my Slack workspace with over 100 different methods. For this exercise all I’ll need it one — webClient.chat.postMessage
3. Messaging a channel with a POST and postMessage
postMessage
is a POST request that requires a channel-id. It takes a json payload which you can send simple text or rich blocks. Because we’re building “middleware” between client apps and Slack I’m not to concerned with what the body looks like — thats up to the client. I’ll receive the incoming request, make sure it’s a POST, pass it’s body on to postMessage
and handle any errors. Job done.
The naming convention I settled on matched the web-api method that it uses. postMessage
becomes /post-message
.
Tip: Add in some authentication middleware to the incoming web requests.
That’s it. Grab a channel id from the workspace you installed you bot in. Send request using your favourite tool. I’ll be following this post up with another showing how I used the endpoint in our Uploader web app.
Deploying to Google Cloud Functions
At my company we use GCP for all things cloud. Cloud functions are easy to deploy and cheep to run. You’ll need to have a Google Cloud project, gcloud
CLI installed on your machine and be authenticated. If you need, here’s the getting started guide.
Cloud functions require commonjs
export syntax even if you use a Node >14. 99% of this is solved when we compile using tsc
but, we need to export or entry point from index.ts
. I found the easiest was to write it explicitly. Take note of the exported property name, slackBot
, we’ll need it below when we deploy.
To deploy to cloud functions you can run this command:
Give the function a name and point it at the correct project.
--source
is the directly that we want to upload to the function. Out built app is in thedist
directory so originally I thought I’d just upload that. This was a mistake. The function needs apackage.json
in the root so it knows how to find the entry point — the value of tomain
. Set it asdist/index.js
and upload the whole root directory. To keep my deployment clean I use a.gcloudignore
file. Here I add all my non-build directories and config files.--entry-point
is property name we used when fromindex.ts
. This is the entry point to the whole app and is called by the function when it initialises.- Environment variables can be set in the deploy command using the
--set-env-vars
flag. But, you can also set them from the Google cloud console. I prefer using the console because when I set-up CI/CD with CircleCI I don’t have to manage adding them there too. Yes you’ll need to add them for each separate deployment but, that’s perfect, 12 Factor App for the win. To set variables in the console go to your function (you’ll need to have run the deploy script already). Click edit, then click theRUNTIME, BUILD AND CONNECTION SETTINGS
drop down and add/edit any variables you need. Then clickNEXT
, thenDEPLOY
. This will re-deploy the function with the updated variables.
To run my deploy script I put it in a bash file called scripts/deploy.sh
. Then from the terminal run bash ./scripts/deploy.sh
. The terminal will put out some “in progress messaging” before show a success one. If you’re setting the environment variables using the console your first deployment will fail. This is because the missing variables will cause the app to crash when it’s initialised. Once you’ve set the variables you should be all set. Future deployments will work as expected!