botkit-middleware-watson

A middleware for using Watson Assistant in a Botkit-powered bot.

Usage no npm install needed!

<script type="module">
  import botkitMiddlewareWatson from 'https://cdn.skypack.dev/botkit-middleware-watson';
</script>

README

Use IBM Watson's Assistant service to chat with your Botkit-powered Bot! Build Status Greenkeeper badge

This middleware plugin for Botkit allows developers to easily integrate a Watson Assistant workspace with multiple social channels like Slack, Facebook, and Twilio. Customers can have simultaneous, independent conversations with a single workspace through different channels.

Table of Contents

Middleware Overview

  • Automatically manages context in multi-turn conversations to keep track of where the user left off in the conversation.
  • Allows greater flexibility in message handling.
  • Handles external databases for context storage.
  • Easily integrates with third-party services.
  • Exposes the following functions to developers:

Function Overview

Installation

$ npm install botkit-middleware-watson

Prerequisites

Create an instance of Watson Assistant

💡 You can skip this step if you have credentials to access an existing instance of Watson Assistant. This would be the case for Cloud Pak for Data.

  1. Sign up for an IBM Cloud account.
  2. Create an instance of the Watson Assistant service and get your credentials:
    • Go to the Watson Assistant page in the IBM Cloud Catalog.
    • Log in to your IBM Cloud account.
    • Click Create.
    • Copy the apikey value, or copy the username and password values if your service instance doesn't provide an apikey.
    • Copy the url value.

Configure your Assistant

  1. Create a workspace using the Watson Assistant service and copy the workspace_id. If you don't know how to create a workspace follow the Getting Started tutorial.

Acquire channel credentials

This document shows code snippets for using a Slack bot with the middleware. You need a Slack token for your Slack bot to talk to Watson Assistant. If you have an existing Slack bot, then copy the Slack token from your Slack settings page.

Otherwise, follow Botkit's instructions to create your Slack bot from scratch. When your bot is ready, you are provided with a Slack token.

Bot setup

This section walks you through code snippets to set up your Slack bot. If you want, you can jump straight to the full example.

In your app, add the following lines to create your Slack controller using Botkit:

import { WatsonMiddleware } from 'botkit-middleware-watson';
import Botkit = require('botkit');
const { SlackAdapter } = require('botbuilder-adapter-slack');

const adapter = new SlackAdapter({
    clientSigningSecret: process.env.SLACK_SECRET,
    botToken: process.env.SLACK_TOKEN
});

const controller = new Botkit({
    adapter,
    // ...other options
});

Create the middleware object which you'll use to connect to the Watson Assistant service.

If your credentials are username and password use:

const watsonMiddleware = new WatsonMiddleware({
  username: YOUR_ASSISTANT_USERNAME,
  password: YOUR_ASSISTANT_PASSWORD,
  url: YOUR_ASSISTANT_URL,
  workspace_id: YOUR_WORKSPACE_ID,
  version: '2018-07-10',
  minimum_confidence: 0.5, // (Optional) Default is 0.75
});

If your credentials is apikey use:

const watsonMiddleware = new WatsonMiddleware({
  iam_apikey: YOUR_API_KEY,
  url: YOUR_ASSISTANT_URL,
  workspace_id: YOUR_WORKSPACE_ID,
  version: '2018-07-10',
  minimum_confidence: 0.5, // (Optional) Default is 0.75
});

If your service is running in the IBM Cloud Pak for Data use:

const watsonMiddleware = new WatsonMiddleware({
  icp4d_url: YOUR_CLOUD_PAK_ASSISTANT_URL,
  icp4d_access_token: YOUR_CLOUD_PAK_ACCESS_TOKEN,
  disable_ssl_verification: true,
  workspace_id: YOUR_WORKSPACE_ID,
  version: '2018-07-10',
  minimum_confidence: 0.5, // (Optional) Default is 0.75
});

Tell your Slackbot to use the watsonMiddleware for incoming messages:

controller.middleware.receive.use(
  watsonMiddleware.receive.bind(watsonMiddleware),
);

Finally, make your bot listen to incoming messages and respond with Watson Assistant:

controller.hears(
  ['.*'],
  ['direct_message', 'direct_mention', 'mention'],
  async function(bot, message) {
    if (message.watsonError) {
      await bot.reply(
        message,
        "I'm sorry, but for technical reasons I can't respond to your message",
      );
    } else {
      await bot.reply(message, message.watsonData.output.text.join('\n'));
    }
  },
);

The middleware attaches the watsonData object to message. This contains the text response from Assistant. If any error happened in middleware, error is assigned to watsonError property of the message.

Then you're all set!

Features

Message filtering

When middleware is registered, the receive function is triggered on every message. If you would like to make your bot to only respond to direct messages using Assistant, you can achieve this in 2 ways:

Using interpret function instead of registering middleware

slackController.hears(['.*'], ['direct_message'], async (bot, message) => {
  await middleware.interpret(bot, message);
  if (message.watsonError) {
    bot.reply(
      message,
      "I'm sorry, but for technical reasons I can't respond to your message",
    );
  } else {
    bot.reply(message, message.watsonData.output.text.join('\n'));
  }
});

Using middleware wrapper

const receiveMiddleware = (bot, message, next) => {
  if (message.type === 'direct_message') {
    watsonMiddleware.receive(bot, message, next);
  } else {
    next();
  }
};

slackController.middleware.receive.use(receiveMiddleware);

Minimum Confidence

To use the setup parameter minimum_confidence, you have multiple options:

Use it manually in your self-defined controller.hears() function(s)

For example:

controller.hears(
  ['.*'],
  ['direct_message', 'direct_mention', 'mention', 'message_received'],
  async (bot, message) => {
    if (message.watsonError) {
      await bot.reply(message, 'Sorry, there are technical problems.'); // deal with watson error
    } else {
      if (message.watsonData.intents.length == 0) {
        await bot.reply(message, 'Sorry, I could not understand the message.'); // was any intent recognized?
      } else if (
        message.watsonData.intents[0].confidence <
        watsonMiddleware.minimum_confidence
      ) {
        await bot.reply(message, 'Sorry, I am not sure what you have said.'); // is the confidence high enough?
      } else {
        await bot.reply(message, message.watsonData.output.text.join('\n')); // reply with Watson response
      }
    }
  },
);

Use the middleware's hear() function

You can find the default implementation of this function here. If you want, you can redefine this function in the same way that watsonMiddleware.before and watsonMiddleware.after can be redefined. Refer to the Botkit Middleware documentation for an example. Then, to use this function instead of Botkit's default pattern matcher (that does not use minimum_confidence), plug it in using:

controller.changeEars(watsonMiddleware.hear);

Note: if you want your own hear() function to implement pattern matching like Botkit's default one, you will likely need to implement that yourself. Botkit's default set of 'ears' is the hears_regexp function which is implemented here.

Implementing app actions

Watson Assistant side of app action is documented in Developer Cloud A common scenario of processing actions is:

  • Send message to user "Please wait while I ..."
  • Perform action
  • Persist results in conversation context
  • Send message to Watson with updated context
  • Send result message(s) to user.

Using sendToWatson to update context

const checkBalance =  async (context) => {
  //do something real here
  const contextDelta = {
    validAccount: true,
    accountBalance: 95.33
  };
  return context;
});

const processWatsonResponse = async (bot, message) => {
  if (message.watsonError) {
    return await bot.reply(message, "I'm sorry, but for technical reasons I can't respond to your message");
  }
  if (typeof message.watsonData.output !== 'undefined') {
    //send "Please wait" to users
    await bot.reply(message, message.watsonData.output.text.join('\n'));

    if (message.watsonData.output.action === 'check_balance') {
      const newMessage = clone(message);
      newMessage.text = 'balance result';

      try {
        const contextDelta = await checkBalance(message.watsonData.context);
        await watsonMiddleware.sendToWatson(bot, newMessage, contextDelta);
      } catch(error) {
        newMessage.watsonError = error;
      }
      return await processWatsonResponse(bot, newMessage);
    }
  }
};

controller.on('message_received', processWatsonResponse);

Using updateContext to update context

sendToWatson should cover majority of use cases, but updateContext method can be useful when you want to update context from bot code, but there is no need to make a special request to Watson.

if (params.amount) {
  const context = message.watsonData.context;
  context.paymentAmount = params.amount;
  await watsonMiddleware.updateContext(message.user, context);
}

Implementing event handlers

Events are messages having type different than message.

Example of handler:

controller.on('facebook_postback', async (bot, message) => {
  await bot.reply(message, `Great Choice. (${message.payload})`);
});

Since they usually have no text, events aren't processed by middleware and have no watsonData attribute. If event handler wants to make use of some data from context, it has to read it first. Example:

controller.on('facebook_postback', async (bot, message) => {
  const context = watsonMiddleware.readContext(message.user);
  //do something useful here
  const result = await myFunction(context.field1, context.field2);
  const newMessage = { ...message, text: 'postback result' };
  await watsonMiddleware.sendToWatson(bot, newMessage, {
    postbackResult: 'success',
  });
});

Intent matching

The Watson middleware also includes a hear() function which provides a mechanism to developers to fire handler functions based on the most likely intent of the user. This allows a developer to create handler functions for specific intents in addition to using the data provided by Watson to power the conversation.

The hear() function can be used on individual handler functions, or can be used globally.

Used on an individual handler:

slackController.hears(
  ['hello'],
  ['direct_message', 'direct_mention', 'mention'],
  watsonMiddleware.hear,
  async function(bot, message) {
    await bot.reply(message, message.watsonData.output.text.join('\n'));
    // now do something special related to the hello intent
  },
);

Used globally:

slackController.changeEars(watsonMiddleware.hear.bind(watsonMiddleware));

slackController.hears(
  ['hello'],
  ['direct_message', 'direct_mention', 'mention'],
  async (bot, message) => {
    await bot.reply(message, message.watsonData.output.text.join('\n'));
    // now do something special related to the hello intent
  },
);

before and after

The before and after async calls can be used to perform some tasks before and after Assistant is called. One may use it to modify the request/response payloads, execute business logic like accessing a database or making calls to external services.

They can be customized as follows:

middleware.before = (message, assistantPayload) => async () => {
  // Code here gets executed before making the call to Assistant.
  return assistantPayload;
};
middleware.after = (message, assistantResponse) => async () => {
  // Code here gets executed after the call to Assistant.
  return assistantResponse;
});

Dynamic workspace

If you need to make use of multiple workspaces in a single bot, workspace_id can be changed dynamically by setting workspace_id property in context.

Example of setting workspace_id to id provided as a property of hello message:

async handleHelloEvent = (bot, message) => {
  message.type = 'welcome';
  const contextDelta = {};

  if (message.workspaceId) {
    contextDelta.workspace_id = message.workspaceId;
  }

  try {
    await watsonMiddleware.sendToWatson(bot, message, contextDelta);
  } catch(error) {
    message.watsonError = error;
  }
  await bot.reply(message, message.watsonData.output.text.join('\n'));
}

controller.on('hello', handleHelloEvent);

Information security

It may be necessary to be to able delete message logs associated with particular customer in order to comply with GDPR or HIPPA regulations

Labeling User Data

Messages can be labeled with customer id by adding x-watson-metadata header to request in before hook:

watsonMiddleware.before = async (message, payload) => {
  // it is up to you to implement calculateCustomerId function
  customerId = calculateCustomerId(payload.context);
  payload.headers['X-Watson-Metadata'] = 'customer_id=' + customerId;

  return payload;
};

Deleting User Data

try {
  await watsonMiddleware.deleteUserData(customerId);
  //Customer data was deleted successfully
} catch (e) {
  //Failed to delete
}

License

This library is licensed under Apache 2.0. Full license text is available in LICENSE.