DEV Community

loading...
Cover image for How to build a simple Slack bot

How to build a simple Slack bot

Sascha
Originally published at rethinkdb.cloud ・10 min read

Slack is a wonderfully simple communication tool. Everybody is within reach of your fingertips. You can grab anybodies attention with a few key strokes. Distract them with a question whenever you are too bored to google the answer yourself ;-)

It does not take many workspaces you are coerced into joining before you turn off notification for most of the channels you are part of. However, some people have a very high signal to noise ratio and you would not mind being notified of their messages.

Fortunately, this conundrum can be easily be solved with a simple bot. So let's learn how to create such a Slack bot.

If you don't care about a step by step guide you can also just check out the final code.

Building the Slack bot

We will build our bot in Node.js, so you need to have node and npm installed. If you want to deploy your app to Heroku, you will also need a Heroku account, as well having their CLI installed. To run your app locally, you also need to install and run a RethinkDB instance.

To create the application, run the following in a terminal.

$ mkdir stalker-bot && cd stalker-bot
$ npm init -y
$ npm install @slack/events-api @slack/web-api rethinkdb
Enter fullscreen mode Exit fullscreen mode

This will initialize a Node.js app and install all required dependencies.

Listening to Slack events

We will create a Node.js server to listen to Slack events. Create an index.js file and add the following server code.

// index.js

// Initialize Slack event listener
const { createEventAdapter } = require("@slack/events-api");
const slackSigningSecret = process.env.SLACK_SIGNING_SECRET;
const slackEvents = createEventAdapter(slackSigningSecret);

const { handleCommand, handleMessage } = require("./handler.js");

// Listen to message event (message.im, message.channel)
slackEvents.on("message", (event) => {
  // Ignore messages from bots
  if (event.bot_id != null) {
    return;
  }
  if (event.channel_type == "im") {
    handleCommand(event);
  } else if (event.channel_type == "channel") {
    handleMessage(event);
  }
});

// Catch and log errors
slackEvents.on("error", (error) => {
  console.log(error);
});

// Run server
const port = process.env.PORT || 5000;
(async () => {
  const server = await slackEvents.start(port);
  console.log(`Listening for events on ${server.address().port}`);
})();
Enter fullscreen mode Exit fullscreen mode

We first configure the slack libraries, namely the event listener server and the web client. We then listen to message events. Direct messages are interpreted as commands and messages in channels are listened to in case we need to notify a stalker.

Bot commands

We can chat directly with the bot to issue commands. The stalker bot knows about three commands:

  • subscribe to a user in a channel
  • unsubscribe from a user in a channel
  • list all current subscriptions

To save all subscriptions, we will use my favorite document database as of late, RethinkDB. It is similar to MongoDB but additionally has reactivity built into it and it is still open source. We will need two tables, one to save all users and one to save the subscriptions they have. We will deal with managing database connections and running migrations later.

Create a handler.js file and start with the following code. We first configure the Slack web client in order to be able to respond to events and add some database boilerplate before we handle the actual commands.

// handler.js

// Initialize Slack client
const { WebClient } = require("@slack/web-api");
const slackToken = process.env.SLACK_TOKEN;
const slackWeb = new WebClient(slackToken);

// Lazy RethinkDB connection
const r = require("rethinkdb");
const { getRethinkDB } = require("./reql.js");

// Tables
const subTable = "subscriptions";
const userTable = "users";

// matches commands of type "(un)subscribe to/from <@U01C9PRR6TA> in <#C01BHNSMGKT|general>"
const regexUserChannel = /\<\@(?<user_id>\w+)\>.+\<\#(?<channel_id>\w+)\|(?<channel_label>\w+)\>/;

// Handle commands send directly to the bot
exports.handleCommand = async function (event) {
  // Note: since unsubscribe contains subscribe it must come first
  if (event.text.includes("unsubscribe")) {
    unsubscribe(event);
  } else if (event.text.includes("subscribe")) {
    subscribe(event);
  } else if (event.text.includes("list")) {
    list(event);
  } else {
    slackWeb.chat
      .postMessage({
        text:
          "I don't understand. Available commands:\n* subscribe to @user in #channel\n* unsubscribe from @user in #channel\n* list subscriptions",
        channel: event.channel,
      })
      .catch((err) => {
        console.log("Error helping with unknown cmd:", err);
      });
  }
};

// ...
Enter fullscreen mode Exit fullscreen mode

When handling commands we basically search for one of the three commands in the message. We also use a regular expression to be able to extract the user and the channel from the (un)subscribe commands.

Subscribe to a user

To subscribe to a user in a channel we first need to parse said user and channel from the subscription command. The parsed user and channel are saved in a subscription object which can have listeners. The listener, i.e., the command issuer is saved in the user table.

// handler.js
// ...

let subscribe = async function (event) {
  // Try to understand the subscription command
  const match = event.text.match(regexUserChannel);
  if (!match) {
    slackWeb.chat
      .postMessage({
        text:
          'Who do you want to subscribe to? Use "subscribe to @user in #channel".',
        channel: event.channel,
      })
      .catch((err) => {
        console.log("Error helping with sub cmd:", err);
      });

    return;
  }
  let listener = { id: event.user, im: event.channel };
  let user = match.groups.user_id;
  let channel = match.groups.channel_id;

  const conn = await getRethinkDB();
  const subIndex = channel + "-" + user;

  // Create user
  let lis = await r.table(userTable).get(listener.id).run(conn);
  if (lis == null) {
    await r.table(userTable).insert(listener).run(conn);
  }

  let sub = await r.table(subTable).get(subIndex).run(conn);
  if (sub != null) {
    // Subscription exists -> add listener
    sub.listeners.push(listener.id);
    await r
      .table(subTable)
      .get(subIndex)
      .update({ listeners: sub.listeners })
      .run(conn);
    return;
  }

  // Create subscription (incl. listener)
  sub = {
    id: subIndex,
    channel: channel,
    user: user,
    listeners: [listener.id],
  };
  await r.table(subTable).insert(sub).run(conn);

  // Join channel (if already joined we will get a warning)
  slackWeb.conversations
    .join({
      channel: channel,
    })
    .catch((err) => {
      console.log("Error joining conversation:", err);
    });
};

// ...
Enter fullscreen mode Exit fullscreen mode

When a subscription is created, the bot also needs to join the respective channel in order to be able to listen to messages from the desired user.

Unsubscribe from a user

To unsubscribe from a user in a channel we also need to parse the command first and then revert the actions done in the subscription command. We remove the listener, i.e., the command issuer from the subscription or delete the subscription if there are no listeners.

// handler.js
// ...

let unsubscribe = async function (event) {
  const match = event.text.match(regexUserChannel);
  if (!match) {
    slackWeb.chat
      .postMessage({
        text:
          'Who do you want to unsubscribe from? Use "unsubscribe from @user in #channel".',
        channel: event.channel,
      })
      .catch((err) => {
        console.log("Error helping with unsub cmd:", err);
      });
    return;
  }
  let listener = { id: event.user, im: event.channel };
  let user = match.groups.user_id;
  let channel = match.groups.channel_id;

  const conn = await getRethinkDB();
  const subIndex = channel + "-" + user;

  let sub = await r.table(subTable).get(subIndex).run(conn);
  if (sub == null) {
    // No subscription --> do nothing
    return;
  }
  const lisIndex = sub.listeners.indexOf(listener.id);
  if (lisIndex < 0) {
    // Not listening --> do nothing
    return;
  }

  // Remove listener
  sub.listeners.splice(lisIndex, 1);
  if (sub.listeners.length > 0) {
    // There are still other listeners
    await r
      .table(subTable)
      .get(subIndex)
      .update({ listeners: sub.listeners })
      .run(conn);
    return;
  }

  // No more listeners -> remove subscription
  await r.table(subTable).get(subIndex).delete().run(conn);

  let chanSubs_cursor = await r
    .table(subTable)
    .getAll(channel, { index: "channel" })
    .run(conn);
  let chanSubs = await chanSubs_cursor.toArray();
  if (chanSubs.length > 0) {
    // There are still subscriptions
    return;
  }

  // No more subscriptions -> leave channel
  slackWeb.conversations
    .leave({
      channel: channel,
    })
    .catch((err) => {
      console.log("Error leaving conversation:", err);
    });
};

// ...
Enter fullscreen mode Exit fullscreen mode

When there are no more subscriptions to a channel we also have the bot leave it. This will lessen the messages the bot has to react through.

List subscriptions

Listing the subscriptions is a convenience command to see what users we are currently stalking.

// handler.js
// ...

let list = async function (event) {
  const conn = await getRethinkDB();
  let subs_cursor = await r
    .table(subTable)
    .getAll(event.user, { index: "listeners" })
    .run(conn);
  let subs = await subs_cursor.toArray();
  let subList = subs.map(
    (sub) => "* <@" + sub.user + "> in <#" + sub.channel + ">",
  );
  // Respond with subs list
  slackWeb.chat
    .postMessage({
      text: "You are currently subscribed to:\n" + subList.join("\n"),
      channel: event.channel,
    })
    .catch((err) => {
      console.log("Error with list cmd:", err);
    });
};

// ...
Enter fullscreen mode Exit fullscreen mode

Now that we have implemented all commands, lets do the actual stalking.

Do the actual stalking

When we subscribe to a user in a channel, the bot joins said channel. It handles each message and reacts accordingly if the message author is of interest. If there is a listener for said author, the bot sends a direct message to the listener.

// handler.js
// ...

// Handle message overheard in channels
exports.handleMessage = async function (event) {
  const conn = await getRethinkDB();
  const subIndex = event.channel + "-" + event.user;
  let sub = await r.table(subTable).get(subIndex).run(conn);
  if (sub == null) {
    // No subscription, ignore
    return;
  }

  let lis_cursor = await r
    .table(userTable)
    .getAll(r.args(sub.listeners))
    .run(conn);
  lis_cursor.each((err, lis) => {
    // Send IM to lisener
    slackWeb.chat
      .postMessage({
        text:
          "<@" +
          sub.user +
          "> wrote a message in <#" +
          sub.channel +
          ">: " +
          event.text,
        channel: lis.im,
      })
      .catch((err) => {
        console.log("Error notifying about subscribed message:", err);
      });
  });
};
Enter fullscreen mode Exit fullscreen mode

Note: In order for our bot to serve its purpose, we obviously cannot disable notifications for direct messages.

Database management

Until now we have conveniently just gotten a database connection and assumed the required tables already exist. Now, the time has come to manage the actual RethinkDB connection and take care of the required migrations.

RethinkDB connection

We manage our RethinkDB connection lazily, i.e., we only create the (re-)connection when it is actually needed. The connection parameters are parsed from environment variables, or the defaults are used.

// reql.js

const r = require("rethinkdb");

let rdbConn = null;
const rdbConnect = async function () {
  try {
    const conn = await r.connect({
      host: process.env.RETHINKDB_HOST || "localhost",
      port: process.env.RETHINKDB_PORT || 28015,
      username: process.env.RETHINKDB_USERNAME || "admin",
      password: process.env.RETHINKDB_PASSWORD || "",
      db: process.env.RETHINKDB_NAME || "test",
    });

    // Handle close
    conn.on("close", function (e) {
      console.log("RDB connection closed: ", e);
      rdbConn = null;
    });
    // Handle error
    conn.on("error", function (e) {
      console.log("RDB connection error occurred: ", e);
      conn.close();
    });
    // Handle timeout
    conn.on("timeout", function (e) {
      console.log("RDB connection timed out: ", e);
      conn.close();
    });

    console.log("Connected to RethinkDB");
    rdbConn = conn;
    return conn;
  } catch (err) {
    throw err;
  }
};
exports.getRethinkDB = async function () {
  if (rdbConn != null) {
    return rdbConn;
  }
  return await rdbConnect();
};
Enter fullscreen mode Exit fullscreen mode

On Heroku, the RethinkDB Cloud add-on will set the environment variables. For a locally running instance of RethinkDB, the defaults should work.

Migration

The app does not work without a users and subscriptions tables. We thus need a database migration that adds these tables.

// migrate.js

var r = require("rethinkdb");

// Tables
const subTable = "subscriptions";
const userTable = "users";

r.connect(
  {
    host: process.env.RETHINKDB_HOST || "localhost",
    port: process.env.RETHINKDB_PORT || 28015,
    username: process.env.RETHINKDB_USERNAME || "admin",
    password: process.env.RETHINKDB_PASSWORD || "",
    db: process.env.RETHINKDB_NAME || "test",
  },
  async function (err, conn) {
    if (err) throw err;
    console.log("Get table list");
    let cursor = await r.tableList().run(conn);
    let tables = await cursor.toArray();

    // Check if user table exists
    if (!tables.includes(userTable)) {
      // Table missing --> create
      console.log("Creating user table");
      await r.tableCreate(userTable).run(conn);
      console.log("Creating user table -- done");
    }

    // Check if sub table exists
    if (!tables.includes(subTable)) {
      // Table missing --> create
      console.log("Creating sub table");
      await r.tableCreate(subTable).run(conn);
      console.log("Creating sub table -- done");
      // Create index
      await r.table(subTable).indexCreate("channel").run(conn);
      console.log("Creating channel secondary index -- done");
      await r
        .table(subTable)
        .indexCreate("listeners", { multi: true })
        .run(conn);
      console.log("Creating listeners secondary multi index -- done");
    }

    await conn.close();
  },
);
Enter fullscreen mode Exit fullscreen mode

This migration checks if the required tables exists, and if it is missing, it creates them. It also creates the necessary secondary indices, one to find subscriptions by channel and one to find it by listeners.

Create a Heroku app

This step is optional. You can also run the app locally and use ngrok to receive the Slack events.

In order to deploy the application to Heroku we need to create a Heroku app:

$ git init
$ heroku create
Creating app... done, ⬢ fast-inlet-79371
https://fast-inlet-79371.herokuapp.com/ | https://git.heroku.com/fast-inlet-79371.git
Enter fullscreen mode Exit fullscreen mode

Creating a Heroku app will give you back the URL with a random name. Take note of this URL as it is required for the Slack callback URL later on.

We will also need a RethinkDB instance to store and subscribe to the chat messages sent between users. You can do this via the RethinkDB Cloud add-on as follows:

$ heroku addons:create rethinkdb
Enter fullscreen mode Exit fullscreen mode

The RethinkDB Cloud add-on is currently in alpha. Request an invite for your Heroku account email.

Deploy the application to Heroku

To deploy our slack bot to Heroku we need to create a Procfile. This file basically tells Heroku what processes to run.

// Procfile

release: node migrate.js
web: node index.js
Enter fullscreen mode Exit fullscreen mode

The release and web processes are recognized by Heroku as the command to run upon release and the main web app respectively.

Deploy the app to Heroku with

$ echo node_modules > .gitignore
$ git add .
$ git commit -m 'A stalker bot'
$ git push heroku master
Enter fullscreen mode Exit fullscreen mode

The app will not work yet because it is missing two environment variables, namely SLACK_SIGNING_SECRET and SLACK_TOKEN. We will get them when we create the actual Slack application.

Create the Slack application

To create a Slack app go to api.slack.com/apps (if you are not signed in, sign in and then come back to this URL). Click on "Create App" and fill in a name and a workspace to associate the app with.

Permissions

First we need to declare all permissions we need for our app. This can be done in the "OAuth & Permissions" tab. Scroll down to the "Scopes" card and add the following "Bot Token Scopes":

  • channels:history
  • channels:join
  • chat:write
  • im:history

The channels:history and im:history permission allows the bot to read messages in channels it belongs to as well as direct messages. The channels:join permission allows the bot to join new channels. Finally, the chat:write permission allows the bot to write direct messages (e.g., to you).

Set environment variables

We need two Slack keys in our bot. A signing secret to verify the message events we get from Slack and a token to authenticate our actions as a bot. The signing secret can be found in the "App Credentials" card in the "Basic Information" tab. The OAuth token is shown in the "OAuth & Permissions" tab. Add both keys to your Heroku app with

$ heroku config:set SLACK_SIGNING_SECRET=...
$ heroku config:set SLACK_TOKEN=xoxb-...
Enter fullscreen mode Exit fullscreen mode

This will automatically restart the Heroku app and allow for the event subscription we add next to verify your correctly running endpoint.

Event Subscription

Our app only works if we can react to events that happen in the Slack workplace. Go to the "Event Subscriptions" tab and enable events. For the request URL put in the app URL you got from Heroku and add the events route, e.g., https://fast-inlet-79371.herokuapp.com/events. Then subscribe to the following bot events:

  • message.channels
  • message.im

You will see that these two events require the channels:history and im:history permissions which we added in the previous step. Save the changes for them to take effect.

Install app

Now we are ready to install the app in our workspace. Go to the "Basic Information" tab and click on "Install App to Workspace". This will put you in the role of the app user and ask you to grant it the permissions the app requires.

Test it out

Go to your workspace and add the Stalker bot to your Apps. Test it out and subscribe to your favorite person in a busy channel full of noise. Each time the stalked person writes you will get a direct message to notify you.

Discussion (0)

Forem Open with the Forem app