Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
van-ibm committed Oct 16, 2017
0 parents commit aab3182
Show file tree
Hide file tree
Showing 9 changed files with 363 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .cfignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
.DS_Store
.env
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
public/
.settings
.project
manifest.yml
*.log
.npmrc
.env
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# IBM Watson Workspace Bot Framework

This project is a framework for chatbot development on Watson Workspace. It is written in Javascript and [Node.js](https://nodejs.org).

Developers can contribute chatbot behavior by simply listening to and respond to specific Workspace events.

```javascript
bot.webhooks.on('message-focus:ActionRequest:Schedule', (body, annotation) => {
logger.info(`Checking calendars based on scheduling event phrase '${annotation.phrase}'`)
})
```

Chatbot setup and event listening (webhooks) are handled by the bot framework. Developers choose which events and at what level to listen. In the code above, a developer could listen for the `message-focus` or `message-focus:ActionRequest` or `message-focus:ActionRequest:Schedule` event. For more information on the available events, see the Annotations, Focus, and Action Fulfillment [documentation](https://workspace.ibm.com/developer/docs).

To build your bot, create a separate project. Then add the necessary require statements and begin listening to events to add your own behavior.

```javascript
const bot = require('watsonworkspace-bot')
const ww = require('watsonworkspace-sdk')

bot.webhooks.on('message-annotation-added', (message, annotation) => {
// do something awesome using watsonworkspace-sdk
})

bot.start()
```

## Local Development
### nodemon

[Nodemon](https://github.com/remy/nodemon) is used for development. As you make changes to Javascript code, nodemon will automatically reload the bot with the latest changes. The ngrok tunnel is loaded separately. You do not need to restart the tunnel. Simply make changes to your source code and allow nodemon to reload the chatbot automatically.

### dotenv
[.Env](https://www.npmjs.com/package/dotenv) is used to store environment variables used by the bot: application IDs, secrets, etc. When doing local development, create a .env file in your project's folder with the following:

```
NODE_ENV=development
APP_ID=<your appId>
APP_SECRET=<your appSecret>
WEBHOOK_SECRET=<your webhookSecret>
BOT_NAME=<your botName>
```

Later when using Bluemix or similar PaaS solutions, you can edit the runtime variables to create the same property-value pairs.

### ngrok

Watson Workspace uses webhooks as an event-driven means to exchange information with your chatbot. This requires your chatbot to be listening on a public server. Rather than writing code and deploying to a public server during development, this starter uses [ngrok](https://ngrok.com/) automatically.

Simply execute the `npm run-script dev` command. This will programmatically create a connection to a public domain using ngrok. A message will appear that indicates the URL you should use in your webhook.

```
Use 'https://cdf9d82f.ngrok.io' as your webhook URL in Watson Workspace
```

### winston

[Winston](https://github.com/winstonjs/winston) is the preferred logger.

## Production Deployment

When moving your chatbot into production, you will need to edit your Webhook URL.

1. Re-visit the [Listen to events](https://workspace.ibm.com/developer/apps/dashboard/webhooks) page.
2. Select the more icon (three vertical dots) and then `Edit`.
3. Update your Webhook URL to the productions server.
136 changes: 136 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const botName = process.env.BOT_NAME || 'workspace-bot'
const EventEmitter = require('events').EventEmitter
const logger = require('winston')
const webhooks = require('./webhooks')
const ww = require('watsonworkspace-sdk')

module.exports = new EventEmitter()
module.exports.webhooks = webhooks

if (process.env.NODE_ENV !== 'production') {
require('dotenv').config()
logger.level = 'verbose'
}

// watson work configuration use Bluemix user vars or edit .env file
// these are provided when you register your appliction
var webhookSecret = process.env.WEBHOOK_SECRET
var appId = process.env.APP_ID
var appSecret = process.env.APP_SECRET

// dependencies
var express = require('express')
var http = require('http')
var crypto = require('crypto')
var bodyParser = require('body-parser')
var methodOverride = require('method-override')

// set up express
var app = express()

// all environments
app.set('port', process.env.PORT || 3000)
app.use(bodyParser.urlencoded({
extended: false
}))
app.use(bodyParser.json({limit: '5mb'}))
app.use(methodOverride())

// watson work services middleware
app.use(verifier)
app.use(ignorer)
app.use(webhook)

module.exports.start = () => {
http.createServer(app).listen(app.get('port'), '0.0.0.0', () => {
logger.info(botName + ' bot listening on ' + app.get('port'))
ww.authenticate(appId, appSecret, (token) => {
module.exports.emit('authenticated', token)
})
})
}

/**
* Middleware function to handle the Watson Work challenge
*/
function verifier (req, res, next) {
if (req.body.type === 'verification') {
logger.verbose('Received webhook verification challenge ' + req.body.challenge)

var bodyToSend = {
response: req.body.challenge
}

var hashToSend = crypto.createHmac('sha256', webhookSecret)
.update(JSON.stringify(bodyToSend))
.digest('hex')

res.set('X-OUTBOUND-TOKEN', hashToSend)
res.send(bodyToSend)
} else {
next()
}
}

/**
* Middleware function to ignore messages from this bot
*/
function ignorer (req, res, next) {
// Ignore our own messages
if (req.body.userId === appId) {
res.status(201).send().end()
} else {
// console.log('Sending body to next middleware ' + JSON.stringify(req.body))
next()
}
}

var marks = []

function mark (messageId) {
logger.verbose(`marking ${messageId} [${marks.length}]`)
marks.push(messageId)
}

/**
* Checks if a message ID has been read/sent previously from this application
*/
function marked (messageId) {
var p = false
for (var i in marks) {
if (marks[i] === messageId) {
p = true
break
}
}

if (marks.length > 200) {
marks = [] // housekeeping clear the array
}

// console.log(`${messageId} mark=${p}`)

return p
}

/**
* Middleware function to handle the webhook event
*/
function webhook (req, res, next) {
const body = req.body

if (body.type) {
logger.verbose(`Webhook event '${body.type}' for messageId ${body.messageId} with body`)
logger.debug(body)

// only handle messages that this bot has not seen before
// if (!marked(body.messageId)) {
webhooks.emitWebhook(body)
// }
}

// you can acknowledge here or later
// but you MUST respond or watson work will keep sending the message
res.status(200).send().end()
next()
}
15 changes: 15 additions & 0 deletions ngrok.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
require('dotenv').config()

const ngrok = require('ngrok')

ngrok.connect({
proto: 'http',
addr: process.env.PORT,
region: 'us'
}, (err, url) => {
if (err) {
console.log(`Error creating ngrok ${err}`)
} else {
console.log(`Use '${url}' as your webhook URL in Watson Workspace`)
}
})
41 changes: 41 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "watsonworkspace-bot",
"version": "0.0.1",
"description": "An unofficial IBM Watson Workspace bot kit",
"scripts": {
"start": "node index.js",
"test": "jasmine",
"dev": "concurrently \"node ngrok.js\" \"nodemon ./index.js\""
},
"keywords": [
"watsonworkspace",
"ibm",
"workspace"
],
"repository" : {
"type" : "git",
"url" : "https://github.com/van-ibm/watsonworkspace-bot.git"
},
"author": "[email protected]",
"license": "Apache-2.0",
"dependencies": {
"body-parser": "1.14.x",
"dotenv": "^4.0.0",
"express": "4.13.x",
"jsonwebtoken": "^7.1.9",
"method-override": "^2.3.6",
"qs": "^6.3.0",
"request": "^2.76.0",
"winston": "^2.4.0"
},
"repository": {},
"engines": {
"node": "4.x"
},
"devDependencies": {
"concurrently": "^3.5.0",
"jasmine": "^2.8.0",
"ngrok": "^2.2.23",
"nodemon": "^1.12.1"
}
}
34 changes: 34 additions & 0 deletions spec/appSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
describe('workspace-bot', function () {
// set a much longer timeout to allow interaction with Workspace UI
// or long running webhook events
jasmine.DEFAULT_TIMEOUT_INTERVAL = 300000

const spaceId = '57cf270ee4b06c8b753629e6'

const bot = require('../index')
const webhooks = bot.webhooks
const ww = require('watsonworkspace-sdk')


// open the localtunnel to allow webhook tests
require('../localtunnel')

it('start', function (done) {
// listen for the token event to signal the app is ready to use
bot.on('authenticated', (token) => {
done()
})

// start the app to begin setting up the server and webhooks
bot.start()
})

it('webhook-message-created', function (done) {
ww.sendMessage(spaceId, 'Type any message into Workspace')

webhooks.on('message-created', message => {
expect(message).not.toBe(null)
done()
})
})
})
11 changes: 11 additions & 0 deletions spec/support/jasmine.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"spec_dir": "spec",
"spec_files": [
"**/*[sS]pec.js"
],
"helpers": [
"helpers/**/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": false
}
49 changes: 49 additions & 0 deletions webhooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict'

const EventEmitter = require('events').EventEmitter
const logger = require('winston')

module.exports = new EventEmitter()

module.exports.emitWebhook = (message) => {
const type = message.type
const annotationType = message.annotationType
let annotationPayload = {}

// the annotationPayload is a string that must be parsed to an object
if (message.annotationPayload) {
annotationPayload = JSON.parse(message.annotationPayload)

// since we now have the payload, remove it from the message
// and send it as a param in the emit event
delete message.annotationPayload
}

logger.verbose(`Emiting '${type}' with message`)
logger.debug(message)
logger.verbose(`Emiting '${type}' with payload`)
logger.debug(annotationPayload)

// call the node event emitters
// message-created or message-annotation-removed
module.exports.emit(type, message, annotationPayload)

// more granular annotation related events
// 'message-focus' or 'actionSelected'
module.exports.emit(annotationType, message, annotationPayload)

// 'message-focus:ActionRequest' or 'message-focus:Question'
if (annotationPayload.lens) {
module.exports.emit(`${annotationType}:${annotationPayload.lens}`, message, annotationPayload)
}

// 'message-focus:ActionRequest:Schedule'
if (annotationPayload.category) {
module.exports.emit(`${annotationType}:${annotationPayload.lens}:${annotationPayload.category}`, message, annotationPayload)
}

// 'actionSelected:sample_button'
if (annotationPayload.actionId) {
module.exports.emit(`${annotationType}:${annotationPayload.actionId}`, message, annotationPayload)
}
}

0 comments on commit aab3182

Please sign in to comment.