/ chatbot

Develop an intelligent Slack bot using Block UI for website administration

Introduction

In conventional web site administration, web developers build an admin site to perform daily tasks such as query how many members register daily and block a user. The admin site development includes frontend and backend codes.

Slack developer can do the same thing without any frontend code. Starting from Feb 2019, Slack provides a very powerful and beautiful UI components (Block UI):

0603_a293-1

In this blog, we will develop a Slack bot to tell you how many users register today, or block a user. Take a look to below video:

What is Slack?

Slack is a collaboration hub where you and your team can work together. Like Whatsapp group, our team 50% of workers are working remotely and all communicate in Slack workspace.

Why Slack?

Slack provides SDK for developers to develop applications. Below sections will show you how to develop a Slack Bot (Chatbot application). It works like a colleague to perform backend tasks. The bot can recognize below messages:

  • How many users registered today?
  • Block user NNN

Flow Diagrams

The programming logic to handle "How many users register today":

diagram1

The programming logic to handle "Block user NNN":

diagram2

Prerequisite

To develop the App, we will use below languages, frameworks and libraries:

  • NodeJS v8.10 or above
  • ExpressJS v4 or above
  • MongoDB v3 or above
  • Slack SDK for Nodejs
  • RiveScriptJS v2

Let's start to develop the source codes step-by-step.

Create an Express app

Initialise the project

$ npm init -y
$ npm install express --save

Modify the package.json as below:

{
  "name": "slack-app-backend-admin",
  "version": "1.0.0",
  "description": "An intelligent Slack bot to simplify website administration",
  "main": "app.js",
  "scripts": {
    "start": "node app",
    "initdb": "node initdb.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/simonho288/slack-app-backend-admin.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/simonho288/slack-app-backend-admin/issues"
  },
  "homepage": "https://github.com/simonho288/slack-app-backend-admin#readme",
  "dependencies": {
    "@slack/web-api": "^5.0.1",
    "body-parser": "^1.19.0",
    "express": "^4.17.0",
    "mongodb": "^3.2.6",
    "rivescript": "^2.0.0"
  }
}

Create the application file app.js:

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
const { WebClient } = require('@slack/web-api');
const MongoClient = require('mongodb').MongoClient;
const mongoUrl = 'mongodb://localhost:27017/mydb';

const CONFIG = require('./config.json')
const RsHandler = require('./rivescripthandlers');
const slackWeb = new WebClient(CONFIG.Slack.BotUserOAuthToken);

// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({ extended: false }))
// parse application/json
app.use(bodyParser.json())

// app.get('/', (req, res) => res.send('Hello World!'));

async function HandleSlackEvents(req, res) {
  let payload = req.body
  // console.log('******* payload *******')
  // console.log(payload)
  let text = payload.event.text
  let user = '<@' + payload.event.user + '>'
  text = text.replace(/ *\<[^)]*\> */g, ''); // remove <???>

  let rs = new RsHandler();
  await rs.init();
  let reply = await rs.getReply(payload.event.user, text);
  // Examine the reply is Block UI or normal text
  if (reply.substring(0, 9) === '{"blocks"') {
    let json = JSON.parse(reply)
    // Send the blocks to the channel
    slackWeb.chat.postMessage({
      channel: payload.event.channel,
      blocks: json.blocks
    })
  } else {
    // Send reply message to the channel
    slackWeb.chat.postMessage({
      channel: payload.event.channel,
      text: user + ' ' + reply
    })
  }
}

async function HandleSlackAction(req, res, payload, action) {
  let returnMsg = null
  if (action.type === 'button' && action.action_id === 'block_user') {
    let userId = parseInt(action.value);

    let dbc = await MongoClient.connect(mongoUrl, { useNewUrlParser: true });
    let db = dbc.db('demo'); // Create a demo database
    let colUsers = await db.collection('users');

    // Update the record to set the is_active value to false
    let r = await colUsers.updateOne({ _id: userId }, {
      $set: {
        is_active: false
      }
    });

    if (r.result.n === 1) {
      returnMsg = `user ${userId} blocked successfully`
    } else {
      returnMsg = `user ${userId} not found!`
    }
  } else {
    returnMsg = 'Unknown action';
  }

  // Build the response message and send back to Slack
  let user = '<@' + payload.user.id + '>'
  slackWeb.chat.postMessage({
    channel: payload.channel.id,
    type: 'mrkdwn',
    text: user + ' ' + returnMsg
  });
}

// API to handle Slack events such as app.mentioned
app.post('/slack/events', async (req, res) => {
  let payload = req.body;

  if (payload.challenge) { // Called by Slack to verify this webhook    
    res.setHeader('content-type', 'application/json')
    res.status(200).json({ challenge: payload.challenge })
  } else if (payload.event && payload.event.type === 'app_mention') {
    console.log(payload.event);
    if (payload.event.subtype !== 'bot_message') {
      await HandleSlackEvents(req, res);
    }
    res.status(200).end();
  }
});

// API to handle Slack interactive component actions such as button
app.post('/slack/actions', async (req, res) => {
  let payload = req.body;

  if (payload.challenge) { // Called by Slack to verify this webhook    
    res.setHeader('content-type', 'application/json')
    res.status(200).json({ challenge: payload.challenge })
  } else if (payload.payload && typeof payload.payload === 'string') {
    payload = JSON.parse(payload.payload)
    if (payload.type && payload.type === 'block_actions' ) {
      for (let i = 0; i < payload.actions.length; ++i) {
        let action = payload.actions[i];
        HandleSlackAction(req, res, payload, action);
      }
    }
    res.status(200).end();
  }
});

app.listen(port, () => console.log(`Server App listening on port ${port}`));

Load some dummy data

Install MongoDb driver for NodeJS:

$ npm install mongodb --save

Create an initdb.js file to insert some demo data:

const MongoClient = require('mongodb').MongoClient;
const mongoUrl = 'mongodb://localhost:27017/demo';

async function main() {
  let dbc = await MongoClient.connect(mongoUrl, { useNewUrlParser: true });
  let db = dbc.db('demo'); // Create a demo database

  let colUsers = await db.createCollection('users');

  // Delete all records at the beginning
  colUsers.deleteMany({});

  // Insert 4 demo user records
  colUsers.insertMany([
    {
      _id: 1,
      name: 'Mary',
      registered_at: new Date(),
      is_active: true
    },
    {
      _id: 2,
      name: 'John',
      registered_at: new Date(),
      is_active: true
    },
    {
      _id: 3,
      name: 'Peter',
      registered_at: new Date(),
      is_active: true
    },
    {
      _id: 4,
      name: 'Betty',
      registered_at: new Date(),
      is_active: true
    },
  ]);

  dbc.close();
}

main();

Run the initdb to create the demo records:

$ npm run initdb

Define the conversations

We'll use RiveScript for NLP. With rivescript, we can easily to develop a chatbot. Rivescript takes care of natural language processing. We just need to define the conversations as below brain/app.rive file:

! version = 2.0

// Substitution
! sub users = user
! sub registered = register

// Triggers
+ how many user register today
- <call>subUserRegisterToday</call>

+ block user #
- <call>subBlockUser <star></call>

+ *
- Sorry, I don't know what you say!

Module for handling RiveScript

Install the RiveScript package:

$ npm install rivescript --save

Create a rivescripthandlers.js:

const assert = require('assert');
const MongoClient = require('mongodb').MongoClient;
const mongoUrl = 'mongodb://localhost:27017/mydb';
const RiveScript = require('rivescript');

class RsHandler {
  constructor() {
    this._rs = new RiveScript({ utf8: true });
  }

  async init() {
    await this._rs.loadDirectory(__dirname + '/brain');
    this._rs.setSubroutine('subUserRegisterToday', this.subUserRegisterToday);
    this._rs.setSubroutine('subBlockUser', this.subBlockUser);
    this._rs.sortReplies();
  }

  async getReply(user, msg) {
    return await this._rs.reply(user, msg);
  }

  /*
   * Rivescript subroutine to handle 'subUserRegisterToday'
   */
  async subUserRegisterToday(rs, args) {
    let dbc = await MongoClient.connect(mongoUrl, { useNewUrlParser: true });
    let db = dbc.db('demo'); // Create a demo database

    // Mongodb criteria to find how many user register today
    let start = new Date()
    let end = new Date()
    start.setHours(0, 0, 0, 0);
    end.setHours(23, 59, 59, 999);
    let colUsers = await db.collection('users');
    let num = await colUsers.countDocuments({
      registered_at: {
        '$gte': start,
        '$lt': end
      }
    });

    dbc.close();

    return `There is ${num} user(s) registered today`;
  }

  /*
   * Rivescript subroutine to handle 'subBlockUser'
   */
  async subBlockUser(rs, args) {
    assert(args.length > 0);

    let userId = parseInt(args[0]);
    let dbc = await MongoClient.connect(mongoUrl, { useNewUrlParser: true });
    let db = dbc.db('demo'); // Create a demo database
    let colUsers = await db.collection('users');

    let user = await colUsers.findOne({ _id: userId });
    dbc.close();

    if (!user) {
      return 'User not found!'
    } else {
      return JSON.stringify({
        // Return the block UI created by Slack Block Kit Builder:
        // https://api.slack.com/tools/block-kit-builder
        "blocks": [
        	{
        		"type": "section",
        		"text": {
        			"type": "mrkdwn",
        			"text": `Are you sure to block user: ${user.name}?`
        		},
        		"accessory": {
        			"type": "button",
        			"text": {
        				"type": "plain_text",
        				"text": "Confirm",
        				"emoji": true
        			},
        			"value": `${user._id}`,
              "action_id": "block_user"
        		}
        	}
        ]
      });
    }
  }
}

module.exports = RsHandler;

Develop the webhook for Slack

Install the Slack SDK for NodeJS:

$ npm install --save body-parser @slack/web-api

Run and Debug the App at local

I use Ngrok to make a HTTPS tunnel for Slack to call the local server directly. Run below commands:

$ npm start

Then open another command line session, and run the Ngrok:

$ ngrok http 3000

Create and register the Slack App

We're going to register our App in Slack (you'll need a Slack account). Go to Slack API website https://api.slack.com/apps. Click Create New App:

scrcap01

Enter a new App name and select the workspace you're working on. Click Create App button. Then the basic information page appears. Click Bots, as below:

scrcap05

Then click Add a Bot user, enter the Display name and default user name, as below:

scrcap06

Next step is to enable event subscription, click Event Subscriptions at the left side as below:

scrcap07

Switch on the Enable Events. Copy & paste the https address which is generated by Ngrok and append /slack/events to the URL, see below:

scrcap03

Slack calls to your App to verify the App immediately. It should be verified successfully as below:

scrcap04

Scroll down to Subscribe to Bot Events section and click Add Bot User Event, select the app_mention:

scrcap08

Click Save Changes:

scrcap09

Next is to enable Interactive Components, click the Interactive Components at the left side and enable it. Copy & paste the URL again but append /slack/actions to the path as below:

scrcap10

The last step is to install the App in the Slack workspace. Click Install App at the left side, then install Install App to Workspace:

scrcap11

Click the Authorize:

scrcap12

After that, we'll need the token:

scrcap13

Paste the token into /config.json upper portion:

{
  "Slack": {
    "BotUserOAuthToken": "<Your Token>"
  }
}

Now the bot is ready to go!

Chat with the App

Open the Slack workspace, we'll see the App appears. Thus, we can chat with it:

scrcap14

At the first time, you'll need to invite the bot to the channel:

scrcap15

Say hello again to the App, it replies:

scrcap16

Conclusion

Slack announced Block UI in Feb 2019. It not only allows us to build robust frontend components (without any codes) such as button, combobox, dropdown, date picker... but also provides Block Kit builder online to build the UI visually. We just need to concentrate the backend logic in two webhooks to handle:

  • Messages from Slack
  • UI component actions (e.g. button clicked)

The source codes are hosting in below Github repo:

https://github.com/simonho288/slack-app-backend-admin

Enjoy!