/ chatbot

Giving Facebook Chatbot intelligence with RiveScript, NodeJS server running in AWS Lambda serverless architecture

Overview

This is a long Blog describes how I build my Chatbot (named I'm Simon Chatbot) to demonstrate below features:

  • The Chatbot talks with human via Facebook Messenger
  • Chatbot server app running NodeJS (with ExpressJS framework)
  • Giving an A.I. brain to the Chatbot with RiveScript
  • The Chatbot server App hosts in AWS Lambda (comply with serverless architecture)
  • User state mangement persistent via AWS DynamoDB
  • Use Serverless framework for easy App deployment

1. Create a Facebook Page

I want my Chatbot could be interfaced to my Facebook friends via Facebook Messenger. First of all I need to create a Facebook page which required by Messenger Platform. I make a funny banner picture and avatar to the Facebook Page as below:
fbpage-full

2. Create an Facebook App and configure the Messenger Platform

To accomplish chatting in Messenger, Facebook requires an Facebook App to define the configuration of the Chatbot web service. I create the App from Facebook Developer website. As Facebook developer documentation mentioned, I add "Messenger" product as below screenshot:
add messenger product

Obtain the "Page Access Token" and record it:
messenger-token

Select the messenger events which will be handled:
messenger-events

Now the setup is done. I can start programming the Chatbot...

3. Build the Chatbot in NodeJS

The Chatbot server App is developed in NodeJS along with ExpressJS. Those versions I'm using are:

$ node -v && express --version
v6.10.3
4.15.0

Below is the initial Node-express server for local development (it depends on external modules. The full scource-codes in this Github repo:

/**
 * Local express server for local testing only
 */

const bodyParser = require('body-parser')
const express = require('express')
const app = express()
const fbWebhook = require('./webhookfb.js')
const RS = require('./rivescript.js')

app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: true}))

/**
 * Webhook to handle GET event sent by Facebook App (developers.facebook.com)
 * when subscribing webhook 
 */
app.get('/webhookfb', function(req, res) {
  // make serverless similar event object
  let event = {
    queryStringParameters: req.query
  }
  console.log(event) // dump the event for debugging
  fbWebhook.webhook(event, null, (err, response) => {
    if (err) {
      console.error('Error: ' + err.body)
    } else {
      res.end(response.body.toString())
    }
  })
})

/**
 * Webhook to handle POST event sent by Messenger when user
 * input message.
 */
app.post('/webhookfb', function(req, res) {
  // make serverless similar event object
  let event = {
    body: JSON.stringify(req.body)
  }
  fbWebhook.webhook(event, null, (err, response) => {
    if (err) {
      console.error(err)
    } else {
      res.end(response.body)
    }
  })
})

/**
 * Directly process the message to get reply. The reply will be
 * send to mine Messenger to see the result.
 */
app.get('/test_rs/:msg', (req, res) => {
  let msg = req.params.msg
  console.log('msg', msg)

  RS.init().then(() => {
    let obj = {
      sender: {
        id: '1434188906628402'
      },
      recipient: {
        id: '114248985967236'
      },
      timestamp: 114248985967236,
      message: {
        text: msg
      }
    }
    fbWebhook.receivedMessage(obj)
    return 'okay'
  }).then((answer) => {
    res.send(answer)
  }).catch((err) => {
    console.error(err)
    res.send('err!')
  })
})
  
app.listen(3000, function() {
  console.log('Server listening on port 3000')
})

You can download & install the full source codes as follows:

$ git clone https://github.com/simonho288/rivescript-chatbot-lambda
$ cd rivescript-chatbot-lambda
$ npm install
$ touch aws.config.json

IMPORTANT: You'll need to edit the aws.config.json file and put your AWS ACCESS KEY ID & ACCESS KEY SECRET in the JSON properties, like:

{
  "accessKeyId": "<YOUR ACCESS_KEY_ID>",
  "secretAccessKey": "<YOUR ACCESS_KEY_SECRET>",
  "region": "<YOUR REGION>"
}

4. Setup the Facebook App Webhook

Webhook is the entry point of my Chatbot server App when Messenger receives message from users. To setup the Facebook App webhook. Follow below steps:

  1. Run the local express server in the project root directory:
$ node server
Server listening on port 3000
  1. Make a secure HTTP tunnel (HTTPS) between the local server and external Internet to allow Messenger RESTful calling. I'm using amazing Ngrok to setup the tunnel. You can download it for free & run it:
$ ngrok http 3000

You'll see the below similar screen:
ngrok-https

Login to Facebook developer and create a new App. After the App has been created, go to webhook section:
fbapp-webhook

If anything setup properly, it should no error and the subscription dialog disappears. That's mean verification success.

5. Install RiveScript with brain files

RiveScript is a simple scripting language for giving intelligence to software for handling Human natural languages. After I used it in several projects, I feel it is not only simple but also powerful. Especially it supports asynchronous programming model that perfectly matches with NodeJS's asynchronize non-blocking model. NodeJS non-blocking programming model leads up PHP at least two times faster, 20% faster than Java and 10% faster than ASP.Net

Another powerful feature of RiveScript has ability call sub-routines are programming in Javascript Promise as async object macros. I will demo this feature later in this blog for querying real-time stock price.

To install RiveScript to our server App (locate in project root):

$ npm install -S rivescript

I create a module rivescript.js to handle RiveScript:

/**
 * This module mainly handles RiveScript tasks
 */

const RiveScript = require('rivescript')
const path = require('path')
const external = require('./libs/external.js')
const util = require('./libs/util.js')

let rivescript = new RiveScript({
  utf8: true
})

/**
 * Initialise the RiveScript. It loads all brain files from rsBrainFiles directory
 */
function init() {
  return new Promise((resolve, reject) => {
    let dir = path.join(__dirname, '/rsBrainFiles')
    rivescript.loadDirectory(dir, (batchNum) => {
      console.log('Batch #' + batchNum + ' loaded successfully')
      // RiveScript brain files loaded
      setupSubroutines(rivescript)
      rivescript.sortReplies()
      return resolve()
    }, () => {
      console.log('error', err)
      return reject(err)
    })
  })
}

/**
 * Register rivescript subroutines
 * @param {object} RiveScript object 
 */
function setupSubroutines(rs) {
  // getStockPrice subroutine
  rivescript.setSubroutine('getStockPrice', (rs, args) => {
    console.log(args)
    let stockName = args[0]
    let userId = rs.currentUser()
    return new rs.Promise((resolve, reject) => {
      external.getStockSymbolsByNameFromYahoo(rs, stockName).then((symbols) => {
        // console.log('symbols:')
        // console.log(symbols)
        if (symbols.length === 0) {
          // no symbol found
          resolve('Unkown company name:' + stockName)
        } else if (symbols.length === 1) {
          // only one symbol, so get & display the stock price directly
          external.getStockPriceBySimbolFromYahoo(symbols[0].symbol).then((result) => {
            console.log('result', result)
            let parts = result.split(',')
            resolve(stockName + ' stock price is ' + parts[2])
          })
        } else {
          // multi symbols return, make buttons list
          let buttons = []
          symbols.forEach((symb) => {
            buttons.push({
              text: symb.exchDisp,
              payload: 'GETSTOCKPRICE_' + symb.symbol + '_' + encodeURI(stockName)
            })
          })
          const fbWebook = require('./webhookfb.js')
          fbWebook.sendButtonMessage(userId, 'Please specify which market of ' + stockName, buttons)
        }
      })
    })
  })
}

/**
 * Process the message by using RiveScript and return the reply
 * @param {string} userID 
 * @param {string} msg 
 * @param {object} state 
 */
function getReply(userID, msg, state) {
  console.assert(userID)
  console.assert(msg)
  if (state) {
    rivescript.setUservars(userID, state)
  } else {
    rivescript.setUservars(userID, null)
  }
  return new Promise((resolve, reject) => {
    rivescript.replyAsync(userID, msg).then((reply) => {
      return resolve(reply)
    }).catch((err) => {
      return reject(err)
    })
  })
}

/**
 * Assign a user state to Rivescript object.
 * @param {string} userID Rivescript user ID
 * @param {object} state Reivescript user state
 */
function setUserState(userID, state) {
  rivescript.setUservars(userID, state)
}

/**
 * Get current user state from Rivescript object.
 * @param {string} userID Rivescript user ID
 */
function getUserState(userID) {
  return rivescript.getUservars(userID)
}

/**
 * Get a user variable from Rivescript
 * @param {string} userID Rivescript user ID
 * @param {string} name Variable name
 */
function getUserVariable(userID, name) {
  return rivescript.getUservar(userID, name)
}

// expose the functions for other modules
module.exports = {
  init,
  getReply,
  setUserState,
  getUserState,
  getUserVariable
}

Futhermore, to leverage the benefits of RiveScript NLP, I program several RiveScript brain files in /rsBrainFiles directory that are to make the Chatbot more human like:
rivescript-brainfiles

6. First talking with my Chatbot

I can't wait to try chatting with my Chatbot. I open the Facebook Page and click the "send message button":
test-button

Facebook Messenger provides a full screen view which has better experience, I open it as below screen:
open-in-messenger

Type below messages to test the Chatbot:
chatting-chatbot

Above answers are programmed in the one of brain files /rsBrainFiles/myself.rive. Full scripts are:

! version = 2.0

+ what are you doing [now]
- I'm a software developer. My linkedin is https://www.linkedin.com/in/simonho288/

+ * you have (web|website)
- yes, my website: <bot website>
- yes, feel free to visit <bot website>

+ (what|where) is your (web|website)
- my website is <bot website>

+ * you have blog
- yes, here is my blog: <bot blog>
- yes, <bot blog>

+ (what|where) is your blog
- <bot blog>
- here: <bot blog>
- my blog is here: <bot blog>

+ what is your (home|office|work|cell) phone number
- You can call me at my <star> number, <bot phone>.

+ [how] can i contact you
- You can have my phone number: <bot phone>.

+ [do] you have [an] email [address]
- You can email me at <bot email>.

+ what is your email [address]
- My email is <bot email>.

+ tell me your [home|office|work|cell] [phone] number
- My phone number is <bot phone>.

+ what is your [phone] number
- My phone number is <bot phone>.

+ what have you done
- I have a linkedin profile here: https://www.linkedin.com/in/simonho288/

+ what have you done in past
- It's long ago. But you can download my past profile in PDF file (about 7MB size): http://www.simonho.net/Profile-scr-en.pdf

+ what [function] can do [in] (here|chatbot)
- You can try asking stock price. Such as "apple stock price", "hsbc stock price"

To demonstrate the RiveScript asynchronous operation, I add a command for real-time stock price querying. It is sub-routine getStockPrice in /rsBrainFiles/stockprice.rive file:

! version = 2.0

+ [*] stock price [of] *
- stock price of <star1> is <call>getStockPrice <star1></call>

+ * stock price
- <call>getStockPrice <star1></call>

The sub-routine getStockPrice is programmed in Javascript rivescript.js:

...
  rivescript.setSubroutine('getStockPrice', (rs, args) => {
    console.log(args)
    let stockName = args[0]
    let userId = rs.currentUser()
    return new rs.Promise((resolve, reject) => {
      external.getStockSymbolsByNameFromYahoo(rs, stockName).then((symbols) => {
        // console.log('symbols:')
        // console.log(symbols)
        if (symbols.length === 0) {
          // no symbol found
          resolve('Unkown company name:' + stockName)
        } else if (symbols.length === 1) {
          // only one symbol, so get & display the stock price directly
          external.getStockPriceBySimbolFromYahoo(symbols[0].symbol).then((result) => {
            console.log('result', result)
            let parts = result.split(',')
            resolve(stockName + ' stock price is ' + parts[2])
          })
        } else {
          // multi symbols return, make buttons list
          let buttons = []
          symbols.forEach((symb) => {
            buttons.push({
              text: symb.exchDisp,
              payload: 'GETSTOCKPRICE_' + symb.symbol + '_' + encodeURI(stockName)
            })
          })
          const fbWebook = require('./webhookfb.js')
          fbWebook.sendButtonMessage(userId, 'Please specify which market of ' + stockName, buttons)
        }
      })
    })
  })
...

There has two processes inside the sub-routine getStockPrice when receiving stock query message: 1. Lookup the global stock symbol by company name, 2. Get the current stock price by stock symbol. All rely on external Yahoo Finance APIs. Which is programmed in /libs/external.js module:

const request = require('request')
const util = require('./util')

module.exports = {
  /**
   * Call Yahoo Finance to get stock symbol from company name
   * @param {object} rs Rivescript object 
   * @param {string} companyName Company name looking for
   * Notes: http://www.jarloo.com/yahoo-stock-symbol-lookup/
   */
  getStockSymbolsByNameFromYahoo(rs, companyName) {
    return new Promise((resolve, reject) => {
      // Prepare to call Yahoo Finance
      let nameEnc = encodeURI(companyName)
      let yurl = 'http://autoc.finance.yahoo.com/autoc?query=' + nameEnc + '&region=any&lang=en'
      request(yurl, (err, response, body) => {
        if (err) {
          console.error(err)
          return reject(err)
        } else {
          let yres = JSON.parse(body)
          // console.log(yres)
          // Consolidate the useful result(s)
          const USEFUL = ['NYSE', 'NASDAQ', 'London', 'Hong Kong']
          let exchAdded = []
          let symbols = []
          yres.ResultSet.Result.forEach((symRec) => {
            // console.log(symRec)
            // ignore some unhandled markets
            if (USEFUL.indexOf(symRec.exchDisp) >= 0 && exchAdded.indexOf(symRec.exchDisp) < 0) {
                symbols.push(symRec)
                exchAdded.push(symRec.exchDisp)
            }
          })
          return resolve(symbols)
        }
      })
    })
  },

  /**
   * Call Yahoo Finance API to get stock price 
   * @param {string} symb Stock symbol. e.g. APPL
   * Note: http://wern-ancheta.com/blog/2015/04/05/getting-started-with-the-yahoo-finance-api/
   */
  getStockPriceBySimbolFromYahoo(symb) {
    return new Promise((resolve, reject) => {
      let yurl = 'http://finance.yahoo.com/d/quotes.csv?s=' + symb + '&f=abo'
      request(yurl, (err, response, body) => {
        if (err) {
          console.error(err)
          return reject(err)
        } else {
          return resolve(body)
        }
      })
    })
  }
}

Consequently the Chatbot performs below stock messages and answers:
querying-stockprice

You can also chatting with my Chatbot. Go to this Messenger Page and select "send message test button", or search "Im Simon Chatbot" from Messenger mobile App. Feel free to chat with it. It does NOT store either your contact info nor the messages you entered. (The privacy statement is declared to Facebook here)

7. AWS Lambda prepartion and Install Serverless Framework for deployment

After the Chatbot test successfully, I need a server to host the NodeJS server app. I have three options:

  1. Server infrastructure (IaaS) like AWS EC2
  2. NodeJS Application platform (PaaS) like Heroku
  3. Serverless micro-services (FaaS) like Lambda

My final decision is Lambda due to below reasons:

  • Low maintenance (zero server to administrate)
  • Really cheap (no charges if the code not running)
  • Auto scale and high availability
  • Easy to deploy provided via serverless framework

Serverless is a hot topic in software architecture world. I've been using Serverless framework several months, I am so agree with that:
serverless

To install the serverless framework, in project root directory, type:

$ npm install serverless -g
$ aws configure
$ sls create --template aws-nodejs --path hello

Above commands install serverless framework, setup AWS user access key ID & access key secret, create a serverless function and store the configuration to file serverless.yml.

I modify the serverless.yml as below:

# Welcome to Serverless!
...

service: iamsimonchatbot

provider:
  name: aws
  runtime: nodejs6.10
  stage: prod
  region: ap-southeast-1

# you can add statements to the Lambda function's IAM Role here
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - dynamodb:DescribeTable
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: "arn:aws:dynamodb:ap-southeast-1:*:*"
functions:
  webhookfb:
    name: webhookfb
    description: Facebook Messenger Platform webhook for I am Simon Chatbot
    handler: webhookfb.webhook
    memorySize: 128
    events:
      - http:
          path: webhookfb
          method: get
          cors: true
      - http:
          path: webhookfb
          method: post
          cors: true

8. Handling user state with AWS DynamoDB

User state persistence is another important feature of RiveScript. It provides best user experience and makes chatbot more intelligent. For example:
rs-userstate

Due to serverless micro functions are stateless. To achieve user-state persistent, we needs a NoSQL server. AWS DynamoDB is best partner with AWS Lambda. It's easy, powerful and lower price. NoSQL database handling Javascript object persistent perfectly. Below dynamodb.js codes shown how easy is DynamoDB handling user-state database I/O:

...
/**
 * Call AWS DynamoDB to save user state.
 * @param {string} senderID 
 * @param {string} recipientID 
 * @param {object} values 
 */
function saveUserRecord(senderID, recipientID, values) {
  console.assert(senderID)
  console.assert(recipientID)
  console.assert(values)
  // console.log('senderID', senderID)
  // console.log('recipientID', recipientID)
  // console.log('values', values)

  // Wrap it into Promise
  return new Promise((resolve, reject) => {
    var params = {
      TableName: DYNAMNO_TABLE.USERVARS,
      Item: {
        userId: senderID,
        appId: recipientID,
        vars: values
      }
    }
    ddb.put(params, (err, data) => {
      if (err) {
        return reject(err)
      } else {
        return resolve(data)
      }
    })
  })
}

/**
 * Call AWS DynamoDB to load user state.
 * @param {string} senderID 
 * @param {string} recipientID 
 */
function loadUserRecord(senderID, recipientID) {
  console.assert(senderID)
  console.assert(recipientID)
  // console.log('senderID', senderID)
  // console.log('recipientID', recipientID)

  // Wrap it into Promise
  return new Promise((resolve, reject) => {
    var params = {
      TableName: DYNAMNO_TABLE.USERVARS,
      Key: {
        userId: senderID,
        appId: recipientID
      }
    }
    ddb.get(params, (err, data) =>{
      if (err) {
        return reject(err)
      } else {
        return resolve(data)
      }
    })
  })
}
...

In webhook.js, the sequence of message processing when integrates state management becomes important:

/**
 * Load the user state, process message, send back the reply and then save the user state finally.
 * @param {string} senderID Messenger sender ID
 * @param {*} recipientID Messenger recipient ID
 * @param {*} message Message object sent from Messenger
 */
function handleMessageWithStateManagement(senderID, recipientID, message) {
  let messageId = message.mid
  let messageText = message.text
  let messageAttachments = message.attachments

  // Load user state from dynamodb
  SDB.loadUserRecord(senderID, recipientID).then((record) => {
    let state = null
    if (record && record.Item && record.Item.vars)
      state = record.Item.vars
    // Process the incoming message
    return RS.getReply(senderID, messageText, state)
  }).then((answer) => {
    // Send the processed message back to sender
    return sendTextMessage(senderID, answer)    
  }).then(() => {
    // Save user state
    let userState = RS.getUserState(senderID)
    return SDB.saveUserRecord(senderID, recipientID, userState)
  }).then((result) => {
    console.log('User state saved')
  }).catch((err) => {
    console.error(err.message)
    sendTextMessage(senderID, 'Internal error: ' + messageText)
  })
}

As you can see, it loads the user state (calls SDB.loadUserRecord()) before process the message. At the end of processing, it saves the user state (calls SDB.saveUserRecord()) after the reply send back to user.

9. Final Deployment

When everything done, I deploy it to Lamdba using serverless. In project root I type:

$ sls deploy
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (8.04 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............
Serverless: Stack update finished...
Service Information
service: iamsimonchatbot
stage: prod
region: ap-southeast-1
stack: iamsimonchatbot-prod
api keys:
  None
endpoints:
  GET - https://xw8oub6nsd.execute-api.ap-southeast-1.amazonaws.com/prod/webhookfb
  POST - https://xw8oub6nsd.execute-api.ap-southeast-1.amazonaws.com/prod/webhookfb
functions:
  webhookfb: iamsimonchatbot-prod-webhookfb

Just a single command, serverless do the rest until it returns the result URLs.

I copy the URL to Facebook App webhook subscription to change the server location from local to Lambda:
messenger-token-final

Now I can stop the local server, chat with the chatbot in my mobile Messenger App. The video captured as:

Conclusion

Just a quick recap, highlights of above procedures:

  1. Created a Facebook page.
  2. Created an Facebook App.
  3. Created an Chatbot server App.
  4. Created an AWS IAM user.
  5. Setup Messenger webhook.
  6. Tested in local server.
  7. Gave a brain to the Chatbot.
  8. Programmed RiveScript subroutine in Javascript.
  9. Non-block calling Yahoo Finance API to get real time stock price.
  10. User state persistent thru AWS DynamoDB.
  11. Deployed the Chatbot server App to AWS Lamba in serverless architecture.

The full source codes is saved in this Github repo.