How to write a Slack bot using Javascript (Part 1)

·

8 min read

Or, How I wrote a Slack bot to track my band mates' availability for upcoming gigs.

Context

My band (Colonel Spanky's Love Ensemble) uses Slack to organise rehearsals, record setlists and generally communicate everything that we need to know about upcoming gigs.

Traditionally we have used a Google spreadsheet to record availability for gigs and rehearsals, with a call-out on Slack from me when the gig request first comes in, to prompt people to fill out the spreadsheet.

Sometimes people would take a long time to fill in their availability, or they would miss dates out and forget to come back to them. I was keen to find a way to make the gathering availability step as quick and easy as possible...

I had noticed that people would generally respond much quicker via Slack DM than in a Slack channel. I presume this is to do with being asked directly, rather than being able to fade into the background in a channel where several other people can answer. Anyway I thought I'd test this out and practise my API skills by writing a Slack bot.

Hopefully, being asked by a bot would mean the band members wouldn't feel bad that I was asking them for availability in such a direct way - the layer of anonymity removes judgement. But hopefully we'll still get the benefits of messaging via DM - a quick response that I can act on to say yes or no to gig requests with confidence.

Requirements

  • Should be invoked in the Slack workspace by using a slash command, e.g. /availability.

  • Should send a direct message to a band member with details of the gig (date/time).

  • The band member should be able to respond with a single click.

  • The answer should be recorded somewhere that I can see it.

How to write the Slack bot

Basics - writing a server

First up, I needed a server to listen out to requests and responses from the Slack API. I used Express for this.

In a new repository initialised with a package.json (run npm init in the root of your project), I installed the express npm package using npm install express.

Then I made a new file called server.js and initialised a basic server:

// server.js

const express = require("express");

const server = express();

I then added a basic route to the server, and made the server listen out on port 3000:

server.get("/", (request, response) => {
  response.send("hello");
});

const PORT = 3000;

server.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`));

To test it was working, I opened a new terminal tab (making sure I was in the root of this project still), and ran node server.js:

image.png

I also checked localhost:3000 in my browser, and saw that the GET request had been sent successfully to my server, with the "hello" response rendered on the web page.

Create a Slack app

Next, I needed to create a Slack app instance and install it in my Slack workspace.

image.png

I clicked "from Scratch":

image.png

After giving your app a name and pointing it to your workspace, you'll reach this page where you can see your App Credentials:

image.png

You will need the signing secret for confirming that requests come from Slack. Create a .env file in the root of your project, and add the following:

// .env

SLACK_SIGNING_SECRET=whateverSlackGaveYou

To be able to use this env variable in your server.js, you should run npm install dotenv and add these lines to your server file:

const dotenv = require('dotenv');

dotenv.config();

You will then be able to access your env variables in a secure way by using process.env.ENV_VARIABLE. For example, you could declare this variable in your server file like this:

const slackSigningSecret = process.env.SLACK_SIGNING_SECRET;

Set up interactive messages

Next, I needed to start interacting with my new Slack app. I followed some instructions from these Slack docs to set up interactive messages, i.e. text with buttons that users can click to send a response:

First, I ran npm install @slack/interactive-messages, and initialised the message adapter in my server file:

const { createMessageAdapter } = require('@slack/interactive-messages');

// Read the signing secret from the environment variables
const slackSigningSecret = process.env.SLACK_SIGNING_SECRET;

// Initialize
const slackInteractions = createMessageAdapter(slackSigningSecret);

Next I went back to the Slack settings, scrolled to the "Interactivity & Shortcuts" tab, and flicked the switch to "On".

image.png

You need a public Request URL where Slack can send HTTP post requests (localhost won't work because it isn't public). To do this in development, you can setup a proxy using ngrok.

I invoked ngrok using ngrok http 3000, which mean it was then forwarding all requests to the ngrok URL it gave me, back to my localhost. I put the forwarding address it gave me (e.g. thisisafakestring.ngrok.io) into the Request URL field in the Interactivity section of the Slack settings, with /slack/actions at the end.

Next, I needed to set up a route for /slack/actions in my server and set up my middleware:

// server.js

const bodyParser = require("body-parser") // you need to run npm install body-parser too

server.use(bodyParser.urlencoded({ extended: true}));
server.use(bodyParser.json());

server.use("/slack/actions", slackInteractions.expressMiddleware()

We also need to do some setup for the interactive buttons, so we know what buttons there are and what response each one sends. Create a new file, called interactiveButtons.js and populate it with actions objects (one for each button). In my case, I have three buttons - one each for "available", "busy" and "maybe".

const interactiveButtons = {
    text: 'Gig details',
    response_type: 'in_channel',
    attachments: [{
      text: 'Are you available for this?',
      callback_id: 'availability',
      actions: [
        {
          name: 'availability',
          text: 'Yes',
          value: 'available',
          type: 'button',
          style: 'primary',
        },
        {
          name: 'availability',
          text: 'No',
          value: 'busy',
          type: 'button',
          style: 'danger',
        },
        {
            name: 'availability',
            text: 'Maybe',
            value: 'maybe',
            type: 'button',
          },
      ],
    }],
  };

  module.exports = {interactiveButtons}

Slash commands

Now we need to set up the slash command to invoke the interactive buttons. When you create the command in the Slack settings, you need to give it a Request URL, so let's first create the route for that. You will need to create another .env variable, using the Verification Token from the App Credentials section again. Don't forget to require the interactiveButtons in server.js too.

// server.js

const { interactiveButtons } = require("./interactiveButtons");
const slackVerificationToken = process.env.SLACK_VERIFICATION_TOKEN;

server.post("slack/command", (request, response, next) => {
   if (request.body.token === process.env.SLACK_VERIFICATION_TOKEN &&
        request.body.command === "/availability"
    ) {
       response.json({...interactiveButtons, text: request.body.text });
   else {
      next();
   }
}

The last thing we need to do is handle the interactions from messages with a callback_id of "availability".


// server.js

slackInteractions.action("availability", (payload, respond) => {
   // `payload` contains information about the action
  // see: https://api.slack.com/docs/interactive-message-field-guide#action_url_invocation_payload

  console.log(payload); // lots of useful info here to use later 

  return "Message received!";
});

Testing

Assuming you have your server running (you will need to stop it and re-run it to pick up all the latest code changes), and you have your Slack app installed in your workspace, and ngrok running, with the ngrok URLs populated in the right places in your Slack settings, then you should be able to test this Slack command out!

I made a #bot private channel in my Slack workspace for testing, so that's where I ran the first /availability command, with a test string:

image.png

Woohoo!!

When we click any of the buttons we should get the generic "Message received!" text back. Let's write the code to handle the different buttons now:


slackInteractions.action("availability", (payload, respond) => {
  switch (payload.actions[0].value) {
    case 'available':
      return "Excellent, see you there!"
      break;
    case 'busy':
      return "Ok, thanks for letting me know."
      break;
    case 'maybe':
      return "Ok, I'll ask you again next week."
      break;
    default:
      console.log(`Went to default option`); // no idea when it will go to this but best to handle just in case
  }
});

Now the app responds differently when different buttons are pressed:

image.png

Storing the responses

At the moment the buttons only change the text for the user who interacts with them. I want to be able to see the responses, so now we need to make the app send me a direct message with the user's name, response and what gig they were referring to, so what the original message from the slack command was.

I wrote a function tellMeTheirSlackResponse():

async function tellMeTheirSlackResponse(myUserId, person, gig, answer) {

  // This is the same as:
  //   POST https://slack.com/api/chat.postMessage
  // Content-type: application/json
  // Authorization: Bearer xoxb-your-token
  // {
  //   "channel": "YOUR_CHANNEL_ID",
  //   "text": "Hello world :tada:"
  // }

  try {
    const result = await client.chat.postMessage({
      token: slackAccessToken,
      channel: `${myUserId}`, // E.g. U0XXXXXXXX
      text: `For ${gig}, ${person}'s answer is ${answer}`
    });
  }
  catch (error) {
    console.error(error);
  }
}

I added this function above where my switch statement was, so that the user who answers the slackbot gets the response, and I also get a copy of their answer to my DMs:

// Handle interactions from messages with a `callback_id` of `availability`
slackInteractions.action("availability", (payload, respond) => {

  console.log("payload", payload); // this is how I figured out what to set the variables below

  const user = payload.user.name;
  const gig = payload.original_message.text;
  const availability = payload.actions[0].value;

  tellMeTheirSlackResponse(myUserId, user, gig, availability);

  switch (availability) {
    case 'available':
      return "Excellent, see you there!"
      ...
    default:
      console.log(`Went to default option`);
  }
});

Ask for availability via DM

At the moment, the slash command just pops up the question in the channel for all to see, exactly where the command was evoked. What we want is for me to run the slash command and the question to pop up in the DMs of the person I specify.

So I need to change my slash command code to be something like this:


server.post("slack/command", (request, response, next) => {
   if (request.body.token === process.env.SLACK_VERIFICATION_TOKEN &&
        request.body.command === "/availability"
    ) {
       askQuestion("Fred", request)
   else {
      next();
   }
}

We need to get the code to take a user that we specify, and route the request to the right inbox.

Stay tuned for this in the next instalment!

slack.dev/node-slack-sdk/interactive-messages slack.dev/node-slack-sdk/tutorials/local-de..