A friendly little bot for simple use anywhere.
This tutorial assumes:
- You are familiar with node.js programming;
- You know what a conversational interface, or chatbot, is.
- core functions -
respondTo
andhello
; - error handling and exiting
- testing
- multiple users and state
- next steps
##core functions Ubibot is based around a single api:
function respondTo({ value: string }) => Promise<{ value: string }>
That's it really.
Some points to note:
- its implementation is asynchronous (it returns a Promise);
- but its usage is essentially synchronous - swapping strings in a request/response manner;
- to enable easy future extension, it uses the RORO pattern - essentially wrapping the
string
value in an object.
No delayed notifications, no fancy formats. Ubibot has a deliberately simple API. Well OK, there a couple of wrinkles:
- Actually, in addition to
respondTo
, the created bot object must have ahello
method. Its contract is also pretty simple:function hello() => Promise<{ value: string }>
- Your module must not export the functions direct, but instead a factory function that creates a bot object with a
respondTo
method:const hello = async() => 'Hello!'; const respondTo = async(request) => { const response = ...; return response; }; module.exports = () => ({ hello, respondTo });
So with that, you can write your first ubibot implementation...
Let's make an echobot that repeats whatever the user says:
- create a new node project with
npm init
; - add an
index.js
file which exports a factory function; the factory function should create the echobot implementation:const hello = async () => "Hello. I'm Echobot"; const respondTo = async request => request; module.exports = () => ({ hello, respondTo });
- Add
@numical/ubibot-cli
as a dev dependency:npm install -D @numical/ubibot-cli
- This will make available a
startCli
script in.node_modules/.bin
that you can reference in apackage.json
script; point it at your factory function module:... "scripts": { "cli": "startCli index.js", }, ...
- Run this and you should have your first Ubibot implementation:
npm run cli
- It should look something like this:
CTRL + C
to exit.- Is a CLI too old school for you? Let's make a webapp instead:
- Add
@numical/ubibot-webapp
as a dev dependency:npm install -D @numical/ubibot-webapp
- Add another
package.json
script:... "scripts": { "cli": "startCli index.js", "web": "startWeb index.js" }, ...
- Run this and you should have your first Ubibot web-app:
npm run web
- It should look something like this:
(If you do not see anything check out the bottom right of the screen).
##error handling and exiting
###no error rule
The API requirements are simple.
Error
's should never escape the respondTo
(or hello
) methods.
Your implementation should always capture them and convert to a friendly message to the user.
And you should expect that the conversation might continue, so make sure your internal state is consistent.
However how does a user gracefully exit a conversation?
Again, that is up to your implementation - but having worked out the user wishes to quit, how does the bot object let the hosting process know?
This is where the one exception to the 'No Error' rule comes in.
Your implementation can throw a UserExit
error (well strictly speaking, return a rejected Promise
).
The hosting process will then handle this by returning to the user the UserExit.message
and then closing the conversation.
There is no obligation to restart the conversation using a recorded state after a UserExit
.
So let's make our echobot a little more graceful.
Let's make it echo the user's input, unless that input is 'exit', in which case we close gracefully.
- Add
@numical/ubibot-util
as a dependency:npm install @numical/ubibot-util
- Edit the
respondTo
method inindex.js
to look like this:const { UserExit } = require('@numical/ubibot-util'); const respondTo = async request => { const { value } = request; if (value === "exit") { throw new UserExit("Bye!") } else { return request; } };
- Run the CLI app again and you should be able to exit gracefully.
- The web app simply resets as this is the least offensive behaviour.
##testing The framework's testing philosophy is:
- leave unit testing to the implementation - after all it is you, the implementor, who best knows the level of testing, you want;
- but offer powerful integration testing utilities.
One of the great advantages of conversational interfaces is that the user interaction is String
in / String
out.
This makes writing test scripts easy, and the @numical/ubibot-test
makes running them equally easy.
- Create a
scripts
directory in thr root of your project; - In this directory create a file called
my_first_test_script
(or any other name); - Add the following text content:
Note the prefixes
bot:Hello. I'm Echobot user:Hello bot:Hello user:exit bot:Bye!
bot:
anduser:
at the start of each line - important! - Now
@numical/ubibot-cli
already exposes@numical/ubibot-test
functionality so all you need do is add apackage.json
script:i.e.: run all the scripts found (recursively) in the... "scripts": { "cli": "startCli index.js", "web": "startWeb index.js", "test": "testCli index.js scripts" }, ...
scripts
directory against the ubibot implementation exposd byindex.js
; - Run it:
npm run test
- It passes! You have written a passing test first time! (Sorry, Kent Beck).
- The
@numical/ubibot-test
has various other formatting options to make your test scripts more useful.
##multiple users and state
The very far sighted amongst you might have been thinking about state.
It's all very well have a trivial API that does not care what the user said before.
However in most conversations you do; you need to hold state.
In a single user situation - simply hold it globally.
In fact for many Ubibot use cases, you can stop here. The single user case has no further API requirements.
But how about multi-user user cases?
When your bot is so successful that more than one user wants to talk to it at once?
Then you want to store state so that multiple conversations can be tracked.
Hence there is a third, optional function to the ubibot API:
function getState() => Promise<object>
The hosting process can call this is at any time and expect a non-null (but possibly empty) object.
By convention this should be readily serializable (JSON.stringify
-able) , but that's really up to you and your persistence choices.
This state object can then be passed to the factory function when creating a new bot object.
The overall effect is to make your exported factory function look like this:
module.exports = (state) => { hello, respondTo, getState };
In a multi-user situation, you create a new bot instance for each request/reply - not for each user conversation - using externally persisted state.
For a reference implementation see the ubibot-rest module.
So let's first add some state.
This is rather contrived, but let's report the number of times echobot replies.
As we want to store state, we will now need explicit instances.
This is why we export a factory function, rather than an instance.
Update index.js
to return instances of a Bot
class that implements the Ubibot API:
const { UserExit } = require("@numical/ubibot-util");
class Bot {
constructor() {
this.replyCount = 0;
this.respondTo = this.respondTo.bind(this);
}
async hello() {
return "Hello. I'm Echobot";
}
async respondTo(request) {
const { value } = request;
if (value === "exit") {
throw new UserExit("Bye!");
} else {
this.replyCount++;
return { value: `${value} (reply #${this.replyCount})` };
}
}
}
module.exports = () => new Bot();
Note the explicit bind
of respondTo
in the constructor.
It's generally a good idea to ensure stateful instance's methods are bound, especially when you have no idea how they will be called in external libraries you do not control.
Note also that your test script will now need changing!
OK - we now have state.
Feel free to run this up in one of the single user environments:
npm run cli
AND/OR
npm run web
However the real proof-of-the-pudding is using this is in a multi-user environment.
We can do this using two further Ubibot libraries.
First, we will run up echobot on a server behind a ReST interface:
- Add
@numical/ubibot-rest
as a dev dependency:npm install -D @numical/ubibot-rest
- Add another
package.json
script:... "scripts": { "cli": "startCli index.js", "web": "startWeb index.js", "test": "testCli index.js scripts" "rest": "startRest index.js" }, ...
- Run this and you should have your first Ubibot server:
npm run rest
- Not so interesting this time - just a server message reporting it is listening. Note the port!
Second, we need clients to talk to this server.
We will use Ubibot's @numical/webbot
library.
This is a minimal Ubibot implementation based on @numical/ubibot-webapp
which simply asks you for the url of another Ubibot implementation to run.
Perfect for our needs!
You will find it available at https://numical.com/webbot.
Go there and open the chat interface, bottom right.
Enter the URL for your echobot server - this will be http://localhost:{port}
taking the port value from the server message earlier.
Run up multiple tabs and wonder at the state being maintained on each.
If you have got this far you might be feeling a little cheated.
We have covered various boilerplate libraries that give us testable, conversational interfaces. But what about the actual conversations themselves? So far we have been limited to echoing the user - something that can be done in a lot less code.
This is deliberate. The Ubibot ecosystem is there to help anyone who wants to go off and develop their own implementation of the respondTo
API.
Please do!
Or... you can take a look at Ubibot's suggested implementation - a non-AI system of contextualised heuristics backed by natural language understanding.
Sounds cool? It is meant to...
If so, go sink your teeth into the real fun at @numical/ubibot-engine
.
Thanks for reading so far.
numical
June 2019