How to build a simple Slack bot
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 notifications 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
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}`);
})();
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 channelunsubscribe
from a user in a channellist
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);
});
}
};
// ...
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);
});
};
// ...
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);
});
};
// ...
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);
});
};
// ...
Now that we have implemented all commands, let’s 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 listener
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);
});
});
};
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();
};
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 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();
},
);
This migration checks if the required tables exists, and if 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
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
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
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
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).
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.
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-...
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.
Test it out
Go to your workspace and add the Stalker bot to your Apps (if it is not already there).
Test it out and subscribe to your favorite person in a busy channel full of noise. Do
this by sending a direct message to the Stalker bot in the form of subscribe to @user in #channel
.
Each time the stalked person writes in the given channel you will get a direct message
to notify you.