Building a Slack bot

… and using it to send status messages or other notifications to Slack from any browser app

Sean Rennie
9 min readApr 26, 2021
Photo by Loic Leray on Unsplash

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 the dist directory so originally I thought I’d just upload that. This was a mistake. The function needs a package.json in the root so it knows how to find the entry point — the value of to main. Set it as dist/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 from index.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 the RUNTIME, BUILD AND CONNECTION SETTINGS drop down and add/edit any variables you need. Then click NEXT, then DEPLOY. 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!

Resources

--

--

Sean Rennie

Software engineer with a love of maps and anything geospatial. I enjoy techno, the outdoors, tequila & coffee. Currently working at Sensat Ltd