/ tutorials

Replacing Myself with a Bot

I quit my job a few weeks back, and I have an unusually long notice period. As I thought about what I wanted to get done as part of moving on, I spent some serious time considering what value I did and didn't provide to the organization. I know that I could write a long detailed document charting the path from where we are now to a more modern platform in the future. It does feel like that will provide great comfort to the management team, especially when they print it out and burn it as kindling in the lean years to come.

I'll probably do that, but in the meantime there is one thing that I knew I did almost every day that I could leave behind. So I spent a little time and I solved a single tiny problem by building a slack bot. Sometimes a small solution is all you can offer. Good teams realize that small solutions can sometimes lead to some nice momentum. Here is how I did it.

First off, people who have worked with me in the last say five years already know I'm a fan of Slack. This isn't an ad, so I'll skip my reasons why, but they do a good job in general on a pretty big problem, and it fits my lead from the front personality.

Six months ago I read this article which is kind of scary sounding, but is in fact kind of no big deal unless you work at a company where changing a minor customer service application is a task of hurculean difficulty. Suffice it to say that we closed self-registration and a small group of slack admins just managed sending out invites. Every morning I'd get to work and send out a few invites. One of the other admins would typically pick up some in the afternoon, and all was well. When that guy quit, it became mostly me.

Quitting your job makes one feel particularly lazy sometimes, especially when your boss won't answer your emails about little details like last day, and final deliverables.

I've built more complete, from scratch integrations for Slack before, but this time I decided to put in the minimum effort. Really minimum, so I started with botkit. They are pretty interested in getting you hooked on the botkit studio thing, but you can chop all that out later. I'm also looking to do something that isn't really handled exactly in the slack API. So we are going to be using an undocumented API, which requires access to a legacy token. Invites are really admin functionality in this case so that makes sense.

Following the setup instructions gets you quite quickly to a platform specific bot boilerplate.

$ npm i -g botkit
$ botkit new
$ cd yourbotname

This gets you a node based bot, ready to serve. Before you get too deep into the bot specific stuff, you need to get a couple of things set up in Slack. Before you can do that easily you need to have an externally accessible set of URLs, because Slack is going to bug you a bunch about it.

I use ngrok which is one of those tools that I just sort of missed in the world until after everyone else already knew about it. Just get it, it's great.

Open up the bot directory in your prefered text editor/IDE and find the .env file put a port in there that makes you happy. (no spaces after equals)

Once you have that in there save it and launch your newly installed ngrok...

$ ./ngrok http 3000

This is going to keep running and proxy traffic from that funny looking ngrok url to the localhost:3000 you set up earlier.

Keep that open and running for your mental health, and cut and paste that https forwarding address somewhere handy.

Here is also where I mention that you should absolutely be doing this in some sort of source control, it's just a good habit. Plus, it will provide a bonus shortcut later if you use github to store your code. So create a repo, and git remote set-url origin your heart out.

You will also want to git rm .env --cached and commit to get .gitignore to propoerly stop tracking changes there because you are about to put some tokens and secrets in there.

I'm assuming that you already have a Slack account, but if you don't you should sign up for one. Would probably also not hurt to create a new free tier slack workspace. That way you are able to install and test your bot during development without stressing anyone out.

Once you get that going head over to Slack's API page and click on Your Apps.

Create a new app, and answer their questions. I called mine Invite-bot, your creativity may vary.

Go to basic information, and grab your application client ID and client secret. Put them into the .env file in the appropriate spot. Remember, no spaces. Also grab that legacy token and put it into the .env as a new entry named token.

Next you should get your bot running, Botkit instructions have you just run

$ node .

Which is perfectly fine but this is 2018 and we aren't animals, so grab
nodemon and just use that. You are going to be changing a lot of stuff quickly because this whole thing isn't going to take you less time than it took me to write it up.

Once your bot is running, just go to that ngrok url and you should get a bot setup page. You can follow the instructions there, and it will take you to the slack app setup page, there are a bunch of settings you have to match between your bot, and slack.

Basically you have to walk through it, and then you are going to take some of the details from slack and put them into your .env file locally. A couple of points to make sure you match:

Jump down to OAuth & Permissions and hit the add a new redirect URL button.

![redirect]({{"/images/slack-redir.png" | absolute-url}})

This is where you are going to paste in the url you saved from ngrok, appended with /oauth something like: https://blah.ngrok.io/oauth

Save it, Slack will check that you have something there, all should be fine.

Then go to Bot Users, and give your new bot a display name and username.

Hop over to event subscriptions and flip the switch for enable events, and do basically the same thing in your request URL except instead of /oauth add /slack/receive.

Next you want to subscribe to some bot events, I went with:
app_mention, im_created, message.channels, message.groups, message.im, message.mpim. If you don't need channels and groups without a special @invite-bot type mention to kick off the bot, you can leave some of them out.

Once you get through the setup, save everything and restart your server. Once you do that you should be able to go back to the server url and see a single button to install your app to Slack. If all goes well you should be able click that button, log in/authorize with slack, and now are ready to get into the bot specifics.

In a lot of use cases you can just move into writing your scripts. Botkit exposes the Slack API via bot.api.method. We are going to use an undocumented endpoint users.admin.invite which I found here so you will need that legacy token, and to use it for sending the invite. Since effectively you are going to be administering the workspace, it's probably healthy to make sure a user knows that by requesting the 'admin' scope in the bot.js file.

Botkit stores the scripts in the skills directory, so let's make a new file under skills called invite_user.js

We are patterning this off of other skills so you can grab the sample_hears.js file if you like and start from there.

module.exports = function(controller) {
  controller.hears(
    ["invite", "Invite"],
    "direct_message, direct_mention",
    function(bot, message) {
      // Your Bot script here
    }
  );
};

Will be the main entry point. You can replace the first trigger words with whatever you want.

Effectively this script is going to fire when a slack direct message or mention event matches a word in the array.

Once triggered, your callback function will be called with the bot and message.

  function(bot, message) {
    bot.createConversation(message, function(err, conv){
      if(!err) {
        conv.say("Hi, I'm here to help you invite a new user to Slack.");
        conv.ask("What is the email of the employee you want to invite?",
        function(response, conv){
          // handle response to your first question.
        });
      }
    });
  }

In this case we are looking to invite a user so I added some prompts to get people to think about what they want to do, we are looking to make sure only employees with a company email address are added. In our case this covers some contractors and vendors as well, but guests are kind of a murky problem to off load outside of the day to day checks we need to do as admins.

Basically what's hapening here is that we first have the bot say, and then ask. This let's botkit monitor and get a response you can validate and act on.

By the way, if you are saving as you go, your server should be restarting, and you should actually be able to have slack open in another window, and be talking with your bot as you build.

Slack is one of those tools that has grown organically in the company, so we have about 25% of the company using it, but as the audience has expanded out beyond the software team, it's been a little bit confusing to some people. So we are going to do some checks to:

  1. Validate that the email is actually a company email.
  2. Check if the user is already on Slack, and give feedback to the inviter if so.
  3. Check the new user already has been invited, and resend the invite if so.
conv.ask("What is the email of the employee you want to invite?"),
  function(response, conv) {
    conv.say(
      "Let me check if " + response.text + " is already a Slack user..."
    );
    /* Logging the full response to console, effectively an invite request log though slack keeps tabs of invites as well. */
    console.log("User Invite Request: ", response);
    // Assemble a set of data from the response text.
    var start = response.text.indexOf(":") + 1;
    var end = response.text.indexOf("|");
    var email = response.text.slice(start, end);
    // Email Address Pattern Specific
    var firstName = email.slice(0, email.indexOf("."));
    var lastName = email.slice(email.indexOf(".") + 1, email.indexOf("@"));
  };

Slack somewhat helpfully auto links email addresses, so the response text is going to come to you in the format:
<mailto:name@domain.com|name@domain.com>
Our company uses a email address pattern of first.last@company.com so you will want to tweak the recognition pattern to match your situation.

Now that we have a first name, last name and an email, we can start to do a bit more validation.

We only want to invite people who already have company email addresses so create a new Regex to match your valid email pattern:

var emailMatchRegex = new RegExp("^[A-Za-z0-9._%+-]+@company.com$");

if (email.match(emailMatchRegex)) {
  bot.api.users.lookupByEmail({ email: email }, function(err, response) {
    // using the botkit built in slack api to look up and see if this user already exists and invite later
  });
} else {
  // Someone tried to invite someone who's email didn't match the regex.
  conv.say(
    "That doesn't look like a company email address. I can only invite people with company.come accounts. :cry:"
  );
}

Here we verify the email matches the new Regex, and then fire off an API call to Slack to verify the user.

The Slack API responses all return with a structure like:

{
  "ok": true,
  "stuff": "Good things"
}
{
  "ok": false,
  "error": "Error information"
}

So you need to handle errors from the API call in case something fails on the request, and you also need to handle the cases (most of them) where you will get a response from Slack with a ok: false. A switch statement works well here.

switch (response.ok) {
  case false:
  // Slack lookup by email failed
  case true:
  // Slack lookup by email found
}

Since we are checking if the member is already in this workspace a response of true here comes with the user profile as the payload and we don't need to proceed with the invitation.

case true:
    if(response.user) {
      conv.say("Good news, " + response.user.real_name + " is already here in Slack." + " @" + response.user.name + " is their Slack username.");
      conv.say(response.user.profile.image_512);
    }
    break;

Our false case will be a bit more complex.

case false:
  if (response.error == "users_not_found") {
    conv.say("That user doesn't exist, let's invite them!");
    var inviteCall = {
      token: process.env.token,
      email: email,
      resend: true,
      first_name: firstName.charAt(0).toUpperCase() + firstName.slice(1),
      last_name: lastName.charAt(0).toUpperCase() + lastName.slice(1)
    };
    var url = "https://slack.com/api/users.admin.invite";
    request.post({ url, form: inviteCall }, function(
      error,
      response,
      body
    ) {
      if (error) {
        console.log("Error on invite call: ", error);
        conv.say("Something went wrong. :(");
      } else if (body) {
        // Response from in body
  ...

In the case where a user isn't in the Slack workspace you will recieve a response of:

{
  "ok": false,
  "error": "users_not_found"
}

There are lots of other errors that can come back from this API, most of them are pretty unlikely, but be sure to check and handle the right ones. Once we get the users_not_found response however, we know this is a new user to the workspace so we can build the information we are going to send to our invite call.

The only required arguments are the token and an email address. I've found however that a lot of people aren't great about filling out their slack profile, so we are going to do some pre-population of profile info based on the email address.

Also setting the resend flag to true will send another email in case it has been a while. Once that object is built we can do a standard post to the https://slack.com/api/users.admin.invite endpoint with that invite information.

If you get back ok:true you are pretty much good to go, there are some errors you want to handle however, sent_recently, user_disabled, already_invited in particular.

if (response_body.ok) {
  conv.say("Invite Sent! :email: :grinning_face_with_star_eyes: ");
} else {
  switch (response_body.error) {
    case "already_invited":
      conv.say(
        "A user with the email: " +
          email +
          " already has an invite pending. You might ask them to check their spam folder. " +
          "I will send them a reminder invite though."
      );
      break;
    case "sent_recently":
      conv.say(
        "I've recently invited that email, it's probably sitting in their inbox."
      );
      break;
    case "user_disabled":
      conv.say(
        "That user account has been disabled by an admin. You should ask them for help."
      );
      break;
  }
}

We had a lot of problems as we onboarded Slack with invites getting caught in spam folders, so I put some extra messaging in there to prompt users to look for that Slack email.

At the end of the conversation you want to include conv.next() and conv.activate() next tells botkit to continue processing the conversation. Activate lets the conversation send and collect responses.

After that it is really mainly about clean up and figuring out where you want to host it. There are a couple of good solutions for this. Heroku and Glitch both offer direct connections from github. You will want to add your .env file directly to wherever you host it. Once you have your hosting sorted, you will need to go back and replace your ngrok urls in the slack interface with the final urls.

One other note, Botkit in this configuration stores data in a little local database under a .data directory. This will store the data associated with the slack team authorization.

We created a Slack channel called #need_invite and invited @invite-bot to monitor the channel. This allows all existing users to come in and invite other company users without bothering an Admin. Since we will be down one admin in the coming weeks, this replaces one of the few things that only I currently do. I suspect that many of the tasks I lead will be difficult for my company to replace, but at least one part of what I do every day will automated and safely continue once I'm gone. It's just not that hard.