Missing water-cooler chats? Automate a flow on Slack!

Girish Ramloul
make it heady
15 min readMay 15, 2020

--

As our teams work remotely to protect ourselves and others, we’ve found ourselves missing one element of in-office culture in particular. Namely, the serendipitous meetups that take place in office hallways, kitchens, and corners—and the lighthearted sharing of ideas and plans that takes place therein. But wait, we thought! There’s an app for that.

Recreating water-cooler chats through an automated flow on Slack: a comprehensive guide, from ideation to development to deployment.

Proof of Concept

So, you want to build an app on Slack to request that your workspace’s members share their plans for the day (show-and-tell style). After gathering requirements and exploring the Slack APIs, you plan your app around your favorite tech stack.

  1. The app will randomly select a user from the workspace to share their plan for the day at a specified time of the day.
  2. A bot packaged as a Slack app prompts the selected user to share.
  3. The selected user has two options: share their plan or kindly decline.
  4. If the user declines, select another user to volunteer and repeat steps 1–4.
  5. If the user accepts, they are prompted to enter their plan for the day.
  6. The app posts the plan to a private channel.
  7. The app posts a summary on Friday at the end of the day.
+--------------+----------------------------------------------+
| Requirements | Action Plan |
+--------------+----------------------------------------------+
| 1 | - Method to randomly select User Ids |
| | - Set up a Task Scheduler |
| 2 | - Bot with access to User Ids |
| | - Slack Chat API Post Message |
| 3 | - Slack Interactive Components |
| 4 | - Queue up randomly selected user ids (FIFO) |
| 5 | - Slack Modal |
| 6 | - Slack API and Webhook |
| 7 | - Database to save weekly plans |
+--------------+----------------------------------------------+
Sequence Diagram for our app

Initial Setup (Development Environment)

  • Create a Slack App.
  1. Head to your Slack’s workspace. Create an app. Enable the Bot and Interactive Components features. Add the following Bot Token Scopes: channels:join, channels:read, chat:write and incoming-webhook.
  2. Create a private channel and add a webhook URL to dispatch messages to the channel. Invite your team to the channel.
  3. Install the app (in our example, we’ll call it show_tell) to your workspace. A Bot User OAuth Access Token will be generated upon successful completion.
  • Install Node using a package installer and, if not installed with Node, Node Package Manager (npm).
  • Create a Node project in your working directory.
$ npm init

A package.json file will be created by default. Add the node modules (express, axios, body-parser, forever, node-cron and firebase-admin) by using the npm install -- save command, which will automatically add the dependencies.

  • Create an Express server in an index.js file. Mount the body-parser middleware via the app.use() function.
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
const PORT = 9647;
app.listen(PORT, () => {
console.log(`Slack bot server has started on port ${PORT}`);
});
  • Install ngrok (Use it to establish a secure tunnel from a publicly accessible URL to your local machine).

The public URL will be used as the request URL for interactivity between the app and Slack. Start a HTTP tunnel on the same port using your CLI.

$ ./ngrok http 9647

Copy the public URL and paste it in the Request URL field box. Slack will send an HTTP POST request to this URL when your users interact with the Interactive Components and Modal.

  • Set up Firebase

Login to Firebase with your Google account. Create a new project (we’ll name ours showtellplans). You’ll need to set up the Firebase Admin SDK to use the Realtime Database service. (We’ll cover how to do this later in the tutorial, in the database section.)

  • Set up a config.js file.

Store the bot auth token, webhook URL, Firebase private key and an array of User Ids in this file, and export those values.

module.exports = {
botToken: 'xoxb-',
webhookURL: 'https://hooks.slack.com/services/',
userIds: [],
firebasePrivateKey: ''
}

Wondering where to find the User Ids?
Go to the private channel on the Slack App. Click on the Channel Details button and select Members. Under the View Full Profile, the user Id of that user will be displayed as member ID. You’ll need the user Ids to post messages to each and every member of the channel.

  • Set up version control on GitHub

Make sure you have Git on your system. Create a new repo on Github with a README.md file. Initialize a Git repository in your Node project, git clone your fresh repo, git add, make a first commit and git push your changes after adding the index.js and config.js files. Add a .gitignore file and add config.js and node_modules to that file (you don’t want to push these files to a public repo).

$ git init
$ git clone <urlToGitHubRepo>
$ git add .
$ git commit -m "commit-message"
$ git push

Requirement 1(a): Randomly pick Users Ids

You need to define a method to achieve the following:

  • Randomly pick a user id from your array of user Ids.
  • Keep track of user Ids already selected: you want to pick a new user Id every day.

Watch the method unfold in this Repl. The array listOfIds will be the const array we defined in our config.js file and the selectedIds array will be a global array we’ll add to the root folder (index.js).

Then, add this function to a utilities.js file.

Requirement 2: Post Message function

Next, you’ll need to set up your file directory. Here’s our example:

|
+-- index.js
+-- api.js
+-- routes.js
+-- utilities.js
+-- config.js
+-- payloads.js
+-- .gitignore
+-- package.json
|

Use Slack’s chat.postMessage method to send a message to the selected user. The method has a method URL to which you’ll have to make a HTTP POST request. The required arguments are the authentication token, channel (the selected user ID represents the IM channel we want to send to) and a text. You can also make use of the optional attachments argument.
Use the Axios node module to perform the HTTP requests in the api.js file.

const axios = require('axios');
const config = require('./config');
const payloads = require('./payloads');
const sendDM = async (userId, payload) => {
try {
const postResponse = await axios.post(
'https://slack.com/api/chat.postMessage',
{
channel: `${userId}`,
text: payload.message,
attachments: JSON.stringify(payload.attachments)
},
{
headers: {
Authorization: `Bearer ${config.botToken}`
}
}
);
return postResponse.data;
} catch (error) {
throw error;
}
};
module.exports = {
sendDM
}

In our example, we’ll call this function in the index.js file our Task Scheduler.

Requirement 3: Allow interactivity

Next, pass the randomly selected user Id and an Interactive Message to the sendDM function you have built.
First, define the payload in your payloads.js file.

module.exports = {
request_message: () => {
return {
message: 'Hi there! :waving: We appreciate your work.',
attachments: [
{
"text": "Feel like sharing your plan for the day.",
"callback_id": "show_tell",
"color": "#a157c9",
"attachment_type": "default",
"actions": [
{
"name": "positive",
"text": "Yes!",
"type": "button",
"value": "postive"
},
{
"name": "negative",
"text": "Not today",
"type": "button",
"value": "negative"
}
]
}]
}
}

Per the earlier plan, only request a maximum of 3 users to volunteer per day. You’ll use your predefined method to randomly generate 3 Ids, which you’ll add to a queue.

Next, put everything together in your index.js file.

const { userIds } = require(./config);
const { sendDM } = require(./api);
const { payloads } = require(./payloads);
const Deque = require('double-ended-queue');
global.selectedIdsSoFar = []; // global array to keep track of
// selected user Ids so far
global.queue = new Deque(); // Set the queue to the global namespace
// to make it globally accessible within the running process
// Call our method to randomly select 3 user Ids and queue them up
for (let i=0; i<3; i++) {
let userId = selectId(userIds, selectedIdsSoFar);
global.queue.push(userId);
}
let userId = global.queue.shift(); // try with first IdsendDM(userId, payloads.request_message());

Later, you’ll trigger this function using a Task Scheduler. To verify the right message is sent, run this file.

node index.js
Expected Output on the Slack App

Any interaction with the Interactive Components will be sent to the URL you specify in the Slack API page. Copy the public URL from the ngrok tunnel you started and add the extension /action. So, Slack will send an HTTP POST request to this url: https://{publicURL_from_ngrok}/action.

Let’s look at a typical response when your user interacts with the buttons.

{ type: 'interactive_message',
actions: [ { name: 'positive',type: 'button',value:'postive' } ],
callback_id: 'show_tell',
team: { id: '', domain: '' },
channel: { id: '', name: '' },
user: { id: '', name: '' },
action_ts: '',
message_ts: '',
attachment_id: '1',
token: '',
is_app_unfurl: false,
original_message: {},
response_url:'',
trigger_id: ''
}

Pay attention to the type of the payload, the actions section and later the trigger_id (to open the modal). The actions tell you what option the user selected. You’ll handle both options in the next sections: requirements.

Requirement 4: Handling negative response

Your user can kindly reject to volunteer to share their plan. In that case, you want to modify the original message to confirm you received their response, and give another user the opportunity to share.

const { queue } = require(./index);
const { sendDM } = require(./api);
app.post('/action', (req, res) => {
const interactiveMessage = JSON.parse(req.body.payload);
let originalTextMessage;
switch (interactiveMessage.type) {
case 'interactive_message':
originalTextMessage = interactiveMessage.original_message.text;
if (interactiveMessage.actions[0].name === 'positive') {
// handle positive case
}
else { // negative case
res.json({
text: originalTextMessage,
attachments: [
{ text: 'Have a good day! :smiley:' }
]
});
if (global.queue.length !== 0) {
let nextId = global.queue.shift(); // get next Id in queue
sendDM(nextId, payloads.request_message());
}
)

Requirement 5: Handling positive response — Modal

If your user clicks on Yes!, you want to present a Modal. You’ll accomplish that by using Slack’s views.open method. This method requires three arguments: the authentication token, a trigger_id (you received from the payload of the interactive message) and a view payload.

First, add this method to your api.js file.

const openModal = async(trigger, payload) => {
try {
const postModal = await axios.post(
'https://slack.com/api/chat.postMessage',
{
trigger_id: `${trigger}`,
view: JSON.stringify(payload)
},
{
headers: {
Authorization: `Bearer ${config.botToken}`
}
}
);
return postModal.data;
} catch (error) {
throw error;
}
};

Next, define the payload.

modal: () => {
return {
type: 'modal',
callback_id: 'modal-identifier',
title: {
"type": "plain_text",
"text": "Show & Tell"
},
submit: {
"type": "plain_text",
"text": "Submit",
"emoji": true
},
close: {
"type": "plain_text",
"text": "Cancel",
"emoji": true
},
blocks: [{
"type": "input",
"block_id": "multiline",
"element": {
"type": "plain_text_input",
"multiline": true,
"action_id": "mlvalue",
"placeholder": {
"type": "plain_text",
"text": "Share your plan for the day."
}
},
"label": {
"type": "plain_text",
"text": "Today's plan"
}
}]
}
}

Finally, you can complete your app.post() function to handle the positive case.

if (interactiveMessage.actions[0].name === 'positive') {
// handle positive case
openModal(interactiveMessage.trigger_id, payloads.modal());
res.json({
text: originalTextMessage,
attachments: [{ text: 'Thanks for sharing :smiley:' }]
});
// Remove the rest of ids from our global array
global.selectedIdsSoFar = global.selectedIdsSoFar.filter((el) => !(global.queue).includes(el));
}

Now, when the user clicks on Yes, a Modal like this will pop up:

Modal View of our app

After your user is done typing in the text box and clicks Submit, Slack will send an HTTP POST request to the same Redirect URL you previously linked. But the type of the payload will be ‘view_submission’. In that case, you want to grab the text and send it to your private channel via the webhook generated during the Initial Setup.

Requirement 6: Posting plan to private channel using Webhook URL

In your config.js file, you’ve already copied the webhook URL you added for our private channel. To dispatch messages with the URL, you’ll need to send them in JSON as the body of an application/json POST request.

Add the method to your api.js file.

const sendSlackMessage = async(webhookURL, payload) => {
try {
const postMessage = await axis.post(webhookURl, payload,
{ headers: {'Content-Type':'application/json'}
});
return postMesssage;
} catch (error) {
throw error;
}
};

Now, the payload.

post_announcement: (message) => {
return {
blocks: [
{
"type": "section",
"text": {
"type": "plain_text",
"emoji": true,
"text": `Hi team! ${message.user} wants to share their plan for the day with us. :clap:`
}
},
{ "type": "divider" },
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `${message.plan}`
}
}]
}
}

You can now add the case to handle the ‘view_submission’ case. Your payload for the sendSlackMessage function expects the username and plan submitted by user. You can grab those from the payload received when the view was submitted.

app.post('/action', (req, res) => {
const interactiveMessage = JSON.parse(req.body.payload);
let originalTextMessage;
switch (interactiveMessage.type) {
case 'interactive_message':
// We already implemented this
case 'view_submission':
// Grab the username and plan from the payload
let message = {
user: interactiveMessage.user.username,
plan: interactiveMessage.view.state.values.multiline.mlvalue.value
}
let response = sendSlackMessage(config.webhookURL, payloads.post_announcement(message)); res.send(response.state); /* Slack expects a 200 response to
close the view */

)

By the end of this section, you will be able to request a random user to share their plan for the day, which they’ll input in a modal. Your app will post that plan to the private channel.

Requirement 7: Store plans in Firebase

Your app will post a summary of all the weekly plans. So, you’ll need to store them in a database.
Define the database functionality in a new file, i.e. database.js. But first, finish setting up the Firebase Admin SDK to authenticate your real time database.

  1. Head to the project in Firebase, then the Service Account page under the Settings tab. Generate a new private key. Save the json file in your app’s directory in a new folder, Keys. Add this folder to your .gitignore file.
  2. Copy the configuration snippet from Firebase into your database.js file.
  3. It’s probably not a good idea to hardcode the path to your service account key JSON file. Instead add the path to your config.js file and call it an extension of the relative path .
const admin = require('firebase-admin');
const config = require('../config');
const serviceAccount = require(`../keys/${config.firebasePrivateKey}`);

admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: "https://showtellplans.firebaseio.com"
});

First, you need to set a database reference at which your data will be stored.

const db = admin.database();
const ref = db.ref('/');

Use the push() method to write data to the Firebase Real Time Database. This function allows you to save the plans without worrying about write conflicts since it generates a unique key for each new child. To retrieve the data, attach an asynchronous listener to our database reference. You need to call the callback only once, so use the “once” helper function.

const savePlan = (userName, plan) => {
const response = ref.push({ userName, plan });
return response.key;
}
const readPlans = async key => {
const snapshot = await ref.child(key).once('value');
return snapshot.val();
}
module.exports = {
savePlan, readPlans
}

Call the savePlan function inside our ‘view_submission’ block. Only grab the first sentence of the user’s plan by splitting the string by the newline character using regex.

app.post('/action', (req, res) => {
const interactiveMessage = JSON.parse(req.body.payload);
let originalTextMessage;
switch (interactiveMessage.type) {
case 'interactive_message':
// We already implemented this
case 'view_submission':
// Grab the username and plan from the payload
let user = interactiveMessage.user.username;
let plan = interactiveMessage.view.state.values.multiline.mlvalue.value;
let message = {
user: user,
plan: plan
}

await savePlan(user, plan.split(/\r?\n/)[0]);
let response = sendSlackMessage(config.webhookURL, payloads.post_announcement(message)); res.send(response.state); /* Slack expects a 200 response to
close the view */
)

Finally, you’ll need to read the data at a scheduled time, which brings us back to the first requirement: setting up a task scheduler.

Requirement 1(b): Setting up the Cron Jobs

You need to set up two cron jobs: one that will run daily at 10 am to request the selected user to share their plan, and the other on Fridays only at 5 pm.

The illustration below represents the typical format of a cron job.

 # ┌────────────── second (optional)
# │ ┌──────────── minute
# │ │ ┌────────── hour
# │ │ │ ┌──────── day of month
# │ │ │ │ ┌────── month
# │ │ │ │ │ ┌──── day of week
# │ │ │ │ │ │
# │ │ │ │ │ │
# * * * * * *

Call your predefined functions in the jobs.

  • At 10 am on weekdays only, dispatch the sendDM function to request input from the selected user. The rest of flow will work as we defined in the previous sections.
cron.schedule('00 10 * * 1-5', () => { // Run at 10 00 on weekdays
// only

var queue = new Deque();
for (let i=0; i<2; i++) {
let [userId, selectedIds] = selectId(userIds, selectedIdsSoFar);
queue.push(userId);
}
let userId = queue.unshift();
sendDM(userId, payloads.request_message());
});
  • At 5 pm on Fridays, use the readPlans() function you implemented in your database.js file to get every plan shared by your users and post the summary to the private channel using your predefined sendSlackMessage function but with a different payload.
    First, build the new payload.
post_summary: (message) => {
return {
blocks: [
{
"type": "section",
"text": {
"type": "plain_text",
"emoji": true,
"text": `It's a wrap for this week. :tada:\nThank you to ${message.users} for keeping us in the loop. Here are a few things they've accomplished:`
}
},
{ "type": "divider" },
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `${message.plans}`
}
},
{ "type": "divider" },
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `${message.plans}`
}
}]
}
}

When you query your database, you will get each plan per user. The payload expects a string of all the users and plans. For that, add another function in your utilities.js file. That function will build the strings from an array of user names or plans. Here’s what it will look like (watch it in action in this Repl.):

function buildString(arr, type) {
let finalStr = '';
if (type === 'names') {
for (let i=0; i<arr.length-1; i++) {
finalStr += arr[i].concat(' ').concat(',');
}
return finalStr + 'and ' + arr[arr.length-1];
}
else if (type === 'plans') {
for (let i=0; i<arr.length-1; i++) {
finalStr += '• ' + arr[i] + '\n';
}
return finalStr;
}
}

And now the cron job.

cron.schedule('00 17 * * 5', () => { // Run at 5 pm on Fridays only
const allPlans = await readPlans();
let usersArr = [];
let plansArr = [];
for (let key of Object.keys(allPlans)) {
const { userName, plan } = allPlans[key];
usersArr.push(userName);
plansArr.push(plan)
}
let usernames = buildString(usersArr, 'names');
let plans = buildString(plansArr, 'plans');
let message = {users:usernames, plans: plans}
sendSlackMessage(config.webhookURL,
payloads.post_summary(message))
});

If all goes according to plan, here’s what you can expect to be posted at 5 pm on Fridays to your private channel.

You are done with developing the app. Commit and push your working code to the GitHub repo you set earlier.

Deploying our app to production

So far, in our example steps above, we used our local machine as a server. But you’ll probably want to move your app to a live server. Here’s a step-by-step guide to one of the many ways you can achieve that:

  • Set up a Virtual Private Server.

For development, we used ngrok’s public URL which tunneled to our local machine to make the HTTP POST requests to our app.

Let’s say you’ll use the Linux-based VPS, Digital Ocean.

  1. Create a new Ubuntu server by following the configuration steps in this tutorial.
  2. Create a droplet on Digital Ocean. The Standard Droplet plan should suffice for your small app. Once fully set up, obtain an IP address which you’ll use to make the requests to your app.
  • Set up Reverse Proxy on a Web Server

Let’s say you’ll use the Nginx web server on Ubuntu.

  1. Install Nginx by following this tutorial.
  2. Configure Nginx.
    SSH into your droplet (replace with the IP of your droplet). Go to the ‘sites-available’ directory directory and create a new virtual host configuration file.
$ ssh root@203.0.113.0
$ cd /etc/nginx/sites-available
$ nano reverse-proxy.conf

Paste the following configuration.

server {
listen 80;
listen [::]:80;

access_log /var/log/nginx/reverse-access.log;
error_log /var/log/nginx/reverse-error.log;

location / {
proxy_pass http://127.0.0.1:8000;
}
}

Replace the port with 9647 and the IP with the IP of your droplet. Copy the configuration file to the sites-enabled folder.

$ ln -s /etc/nginx/sites-available/reverse-proxy.conf /etc/nginx/sites-enabled/reverse-proxy.conf

Check for syntax errors.

$ nginx -t

If the response is ok, start the server.

$ sudo systemctl start nginx

Paste the IP in your web browser which should display this page.

  • Install Node and NPM on your Ubuntu server.
$ sudo apt install nodejs
$ sudo apt install npm
  • Move your Node app to your live server.
$ git clone <urlToGitHubRepo>

Remember you added config.js, node modules and the keys folder to your .gitignore file. npm install will create the node modules on your server. To move the config file and keys folder, transfer them from the working directory to the server using the SCP command.

$ scp config.js root@...
$ scp keys root@...
  • Run your script forever.

Use PM2 to keep your application alive forever. Install it on your server and start the application.

$ npm install pm2
$ pm2 start index.js

If you found this tutorial interesting, consider buying me a coffee. It will keep me awake when I will work on a video tutorial of this article.

--

--