@lostfictions/chatter
v0.5.0
Published
A collection of useful primitives for creating interactive chat bots.
Downloads
3
Readme
chatter
A collection of useful primitives for creating interactive chat bots.
Usage
npm install --save chatter
Tested in Node 4.x and 6.x.
Note: If the following code example syntax looks unfamiliar, don't worry, it's just JavaScript! Read a detailed overview of ECMAScript 2015 features to learn more.
What is a bot?
For the purposes of this documentation, a bot is an automated system that selectively responds to text messages with text responses. The bot may simply respond to messages, statelessly, or the bot may do more complex things, like keep track of ongoing conversations with users.
The most basic bot looks something like this (note, this is pseudocode):
// Import the chat service's library.
const ChatService = require('chat-service');
// Create a chat service instance with the relevant options.
const myChatService = new ChatService(options);
// When the chat service receives a message that it cares about, respond to it.
myChatService.on('message', message => {
// This is the code you'll spend most of your time writing. Ie, the bot:
if (doICareAbout(message)) {
myChatService.send(response);
}
});
// Actually connect to the chat service.
myChatService.connect();
Usually, all the behind-the-scenes work of connecting the bot to the remote service, handling reconnections, keeping track of state (what users the bot is currently talking to, what channels the bot is currently in) is done by a service-specific library.
So, what's left to do? Well, as shown in the previous example, you'll need to write the code that determines if a given message warrants a response, and to then deliver that response to the user.
This project aims to help with all that.
Message handlers
Since most bot code is centered around these two steps:
- Testing an incoming message to see if it should be handled
- Sending a response based on the incoming message
It makes sense to introduce a primitive that does these things. That primitive is called a "message handler."
The simplest message handler is a function that accepts a message argument and
returns a response if it should respond, or false
if it doesn't care about
that message:
const lolHandler = message => {
if (/lol/i.test(message)) {
const newMessage = message.replace(/lol/ig, 'laugh out loud');
return `More like "${newMessage}" amirite`;
}
return false;
};
lolHandler('what') // false
lolHandler('lol what') // 'More like "laugh out loud what" amirite'
But what if a message handler needs to yield a response asynchronously, instead of returning a value immediately? It can, by returning a Promise:
const stuffHandler = message => {
if (message === 'get stuff') {
return db.query('SELECT * FROM STUFF').then(results => {
const stuff = results.join(', ');
return `Look at all the stuff: ${stuff}`;
});
}
return false;
};
stuffHandler('huh') // false
stuffHandler('get stuff') // Promise -> 'Look at all the stuff! ...'
But what do you do if you want to pass all messages through both lolHandler
and stuffHandler
? What if your bot needs to be able to respond to a dozen
types of message in a dozen different ways?
Just create an array of message handlers:
const messageHandlers = [
lolHandler,
stuffHandler,
];
Now, you've undoubtedly realized that functions that return values or promises and arrays behave quite differently. Without a helper function that knows how to iterate over that array, or wait for a promise to resolve, or both of those things, this won't make your job any easier.
Fortunately, chatter gives you that helper function.
Processing messages
The processMessage
function (that chatter exports) takes two arguments:
- A message handler
- A message
This function understands that a message handler might be a function or an array of functions like the ones described above (or a few other possible things, which will be explained later).
It takes the message handler and message you give it and intelligently processes them to produce a response (if any), and it returns a Promise that will be resolved with that response:
const processMessage = require('chatter').processMessage;
// (See the previous examples for the definition of "messageHandlers")
processMessage(messageHandlers, message).then(response => {
// do something with response
});
// An example:
function simulate(message) {
processMessage(messageHandlers, message).then(response => {
if (response === false) {
response = `Sorry, I don't understand "${message}".`;
}
console.log(response);
});
}
simulate('lol what') // Logs: More like "laugh out loud what" amirite
simulate('get stuff') // Logs: Look at all the stuff! ...
simulate('huh') // Logs: Sorry, I don't understand "huh".
Of course, instead of logging the response, your bot would be sending it back to the user who said the message or the channel it was said in, using the chat service library. But you get the idea.
Message handlers, more specifically
As far as the processMessage
function is concerned, a message handler is
a function, an object with a handleMessage
method, or an array of any
combination of those things. That array may contain other arrays.
const functionMessageHandler = message => {
if (condition) {
return response;
}
return false;
};
const objectMessageHandler = {
handleMessage(message) {
if (condition) {
return response;
}
return false;
},
};
const arrayMessageHandler = [
functionMessageHandler,
objectMessageHandler,
];
A message handler function (or method) may return false
or return a Promise
that yields false
to indicate that the message handler doesn't care about the
message, and the next message handler (if any) should process the message.
const returnsFalseMessageHandler = function(message) {
return false;
};
const yieldsFalseMessageHandler = function(message) {
return Promise.resolve(false);
};
A message handler may return or yield any other value, and if so, iteration will be stopped immediately and that value will be yielded.
const returnsValueMessageHandler = function(message) {
return 'hello';
};
const yieldsValueMessageHandler = function(message) {
return Promise.resolve('world');
};
Also, a message handler may return another message handler (function, object or array) and those new message handlers will be processed in-line.
As you can see, message handlers may be very simple, but may be composed in very creative ways.
(See message-handlers for more examples)
A naive bot using message handlers and processMessage
Like the earlier What is a bot? example, this bot is
pseudocode, but this time it uses message handlers and the processMessage
helper function:
// Import the chat service's library.
const ChatService = require('chat-service');
// Import the chatter processMessage helper function.
const processMessage = require('chatter').processMessage;
// Define your message handlers.
const messageHandlers = [...];
// Create a chat service instance with the relevant options.
const myChatService = new ChatService(options);
// When the chat service receives a message that it cares about, respond to it.
myChatService.on('message', message => {
// The bot just became a whole lot more flexible:
processMessage(messageHandlers, message).then(response => {
if (response === false) {
response = `Sorry, I don't understand "${message}".`;
}
myChatService.send(response);
});
});
// Actually connect to the chat service.
myChatService.connect();
Additional included message handlers
Because there are a number of common things message handlers need to do, a few message handler "creator" functions have been included to make creating common message handlers easier.
createMatcher
The createMatcher
function creates a new message handler that only calls the
specified message handler if the message matches. It accepts a match
option,
which is a string, regex or function, to match against the message. If a string
is specified, it matches the beginning of the message. If a message is matched,
the remainder of the message will be passed into the specified message handler:
const createMatcher = require('chatter').createMatcher;
// Matches "add" prefix, then splits the message into an array and adds the
// array items into a sum.
const addMatcher = createMatcher({match: 'add'}, message => {
const numbers = message.split(' ');
const result = numbers.reduce((sum, n) => sum + Number(n), 0);
return `${numbers.join(' + ')} = ${result}`;
});
// Matches "multiply" prefix, then splits the message into an array and
// multiplies the array items into a product.
const multiplyMatcher = createMatcher({match: 'mult'}, message => {
const numbers = message.split(' ');
const result = numbers.reduce((product, n) => product * Number(n), 1);
return `${numbers.join(' x ')} = ${result}`;
});
// Parent message handler that "namespaces" its sub-handlers and provides a
// fallback message if a sub-handler isn't matched.
const mathMatcher = createMatcher({match: 'math'}, [
addMatcher,
multiplyMatcher,
message => `Sorry, I don't understand "${message}".`,
]);
processMessage(mathMatcher, 'add 3 4 5') // Promise -> false
processMessage(mathMatcher, 'math add 3 4 5') // Promise -> 3 + 4 + 5 = 12
processMessage(mathMatcher, 'math mult 3 4 5') // Promise -> 3 x 4 x 5 = 60
processMessage(mathMatcher, 'math sub 3 4 5') // Promise -> Sorry, I don't understand "sub 3 4 5".
See the create-matcher example.
createParser
The createParser
function creates a new message handler that calls the
specified message handler, not with a message string, but with an object
representing the "parsed" message. This is especially useful if you want to
work with an array of words from the message, instead of just a string message.
const createParser = require('chatter').createParser;
// Reduce the array of parsed args into a sum.
const addHandler = createParser(parsed => {
const args = parsed.args;
const result = args.reduce((sum, num) => sum + Number(num), 0);
return `${args.join(' + ')} = ${result}`;
});
processMessage(addHandler, '1 2 3') // Promise -> 1 + 2 + 3 = 6
processMessage(addHandler, '4 five 6') // Promise -> 4 + five + 6 = NaN
When parseOptions
is defined, any options in the message specified like
option=value
will be parsed and processed via the defined function, and made
available in parsed.options
(any non-options args will still be available in
parsed.args
). As you can see, options may be abbreviated!
const parsingHandler = createParser({
parseOptions: {
alpha: String,
beta: Number,
},
}, parsed => JSON.stringify(parsed.options));
processMessage(parsingHandler, 'a=1 b=2') // Promise -> {"alpha":"1","beta":2}
See the create-parser example.
createCommand
The createCommand
function is meant to be used to create a nested tree of
message handlers that each have a name, description and usage information,
with an automatically-created help
command and a fallback message handler.
Like with the createMatcher
match
option, the name
will be used to match
the message, with the remainder of the message being passed into the specified
message handler. The name
, description
and usage
options will be used to
display contextual help and usage information.
Note that because the response from message handlers created with
createCommand
may return arrays, they should be normalized into a
newline-joined string with the included normalizeMessage helper function.
const createCommand = require('chatter').createCommand;
// Command that adds args into a sum.
const addCommand = createCommand({
name: 'add',
description: 'Adds some numbers.',
usage: 'number [ number [ number ... ] ]',
}, createParser(parsed => {
const args = parsed.args;
const result = args.reduce((sum, n) => sum + Number(n), 0);
return `${args.join(' + ')} = ${result}`;
}));
// Command that multiplies args into a product.
const multiplyCommand = createCommand({
name: 'multiply',
description: 'Multiplies some numbers.',
usage: 'number [ number [ number ... ] ]',
}, createParser(parsed => {
const args = parsed.args;
const result = args.reduce((product, n) => product * Number(n), 1);
return `${args.join(' x ')} = ${result}`;
}));
// Parent command that provides a "help" command and fallback usage information.
const rootCommand = createCommand({
isParent: true,
description: 'Some example math commands.',
}, [
addCommand,
multiplyCommand,
]);
processMessage(rootCommand, 'hello').then(normalizeMessage);
// Unknown command *hello*.
// Try *help* for more information.
processMessage(rootCommand, 'help').then(normalizeMessage);
// Some example math commands.
// *Commands:*
// > *add* - Adds some numbers.
// > *multiply* - Multiplies some numbers.
// > *help* - Get help for the specified command.
processMessage(rootCommand, 'help add').then(normalizeMessage);
// Adds some numbers.
// Usage: `add number [ number [ number ... ] ]`
processMessage(rootCommand, 'add 3 4 5').then(normalizeMessage);
// 3 + 4 + 5 = 12
processMessage(rootCommand, 'multiply').then(normalizeMessage);
// Usage: `multiply number [ number [ number ... ] ]`
// Or try *help multiply* for more information.
processMessage(rootCommand, 'multiply 3 4 5').then(normalizeMessage);
// 3 x 4 x 5 = 60
See the create-command and create-command-namespaced examples.
createConversation
The createConversation
function creates a new message handler that calls the
specified message handler, doing nothing of note until that message handler
returns an object with a dialog
property, which should be a new
message handler. At that point, the new message handler is stored and used
instead of the originally-specified message handler to handle the next
message. After that message, the message handler is reverted to the original,
unless another dialog
is specified, in which case that is used instead.
Conversations can be used to create an interactive sequence of message handlers, and must be be cached on a per-conversation basis (usually per-channel or per-direct message), because of the need to keep track of the current dialog.
const createConversation = require('chatter').createConversation;
const helloHandler = message => {
return message.indexOf('hello') !== -1 ? 'Hello to you too!' : false;
};
const askHandler = createMatcher({match: 'ask'}, () => {
return {
message: 'Why do you want me to ask you a question?',
dialog(message) {
return `I'm not sure "${message}" is a good reason.`;
},
};
});
const chooseHandler = createMatcher({match: 'choose'}, () => {
return {
message: `Choose one of the following: a, b or c.`,
dialog: handleChoices,
};
});
const handleChoices = choice => {
if (choice === 'a' || choice === 'b' || choice === 'c') {
return `Thank you for choosing "${choice}".`;
}
return {
message: `I'm sorry, but "${choice}" is not a valid choice. Try again.`,
dialog: handleChoices,
};
};
const conversationHandler = createConversation([
helloHandler,
askHandler,
chooseHandler,
]);
function handleResponse(response) {
if (response !== false) {
console.log(response.message || response);
}
}
processMessage(conversationHandler, 'ask').then(handleResponse);
// Why do you want me to ask you a question?
processMessage(conversationHandler, 'hello').then(handleResponse);
// I'm not sure "hello" is a good reason.
processMessage(conversationHandler, 'hello').then(handleResponse);
// Hello to you too!
processMessage(conversationHandler, 'choose').then(handleResponse);
// Choose one of the following: a, b or c.
processMessage(conversationHandler, 'hello').then(handleResponse);
// I'm sorry, but "hello" is not a valid choice. Try again.
processMessage(conversationHandler, 'b').then(handleResponse);
// Thank you for choosing "b".
processMessage(conversationHandler, 'hello').then(handleResponse);
// Hello to you too!
See the bot-conversation example.
createArgsAdjuster
The createArgsAdjuster
function creates a new message handler that calls the
specified message handler with a different set of arguments than the message
handler received. This is especially useful when you need to pass state from
where a parent message handler is created into a child message handler.
const createArgsAdjuster = require('chatter').createArgsAdjuster;
// Increments the counter and returns a string decribing the new state.
const incrementCommand = createCommand({
name: 'increment',
description: 'Increment the counter and show it.',
}, (message, state) => {
state.counter++;
return `The counter is now at ${state.counter}.`;
});
// Returns a message handler that encapsualates some state, and passes that
// state into child commands as an argument.
function getStatefulMessageHandler() {
const state = {counter: 0};
return createArgsAdjuster({
adjustArgs(message) {
return [message, state];
},
}, createCommand({
isParent: true,
description: 'An exciting command, for sure.',
}, [
incrementCommand,
]));
}
const firstStatefulHandler = getStatefulMessageHandler();
processMessage(firstStatefulHandler, 'increment') // Promise -> The counter is now at 1.
processMessage(firstStatefulHandler, 'increment') // Promise -> The counter is now at 2.
const secondStatefulHandler = getStatefulMessageHandler();
processMessage(secondStatefulHandler, 'increment') // Promise -> The counter is now at 1.
processMessage(firstStatefulHandler, 'increment') // Promise -> The counter is now at 3.
processMessage(secondStatefulHandler, 'increment') // Promise -> The counter is now at 2.
See the create-args-adjuster and bot-stateful examples.
Creating a more robust bot
As with message handlers, bot behaviors can get a little complex. As shown in the preceding createConversation and createArgsAdjuster examples, message handlers may have state. Additionally, the previous bot examples don't handle errors in a useful way or do anything to normalize responses, which means your message handlers may need extra code to help format multi-line responses.
To that end, this pseudocode example bot is implemented using createBot
:
// Import the chat service's library.
const ChatService = require('chat-service');
// Import the chatter createBot function.
const createBot = require('chatter').createBot;
// Create a chat service instance with the relevant options.
const myChatService = new ChatService(options);
// Create the chatter bot.
const myBot = createBot({
// Return message handler for this message. See the "createArgsAdjuster"
// example for the definition of getStatefulMessageHandler.
createMessageHandler(id) {
const messageHandler = getStatefulMessageHandler();
// Let the bot know that the message handler has state, so it'll be cached.
messageHandler.hasState = true;
return messageHandler;
},
// Get a cache id from the "message" object passed into onMessage. In this
// example, each user gets their own stateful message handler, with its own
// unique counter.
getMessageHandlerCacheId(message) {
return message.user;
},
// If a message handler responded to a message, send the normalized text
// response back to the user.
sendResponse(message, text) {
myChatService.getUser(message.user).send(text);
},
});
// Whenever a chat service message is received, pass its user and text values
// into the chatter bot.
myChatService.on('message', message => {
const user = message.userName;
const text = message.messageText;
return myBot.onMessage({user, text});
});
// Actually connect to the chat service.
myChatService.connect();
// Simulated chat log:
// <cowboy> test
// (nothing happens)
// <cowboy> help
// <bot> An exciting command, for sure.
// *Commands:*
// > *increment* - Increment the counter and show it.
// <cowboy> increment
// <bot> The counter is now at 1.
// <cowboy> increment
// <bot> The counter is now at 2.
// <tyler> increment
// <bot> The counter is now at 1.
// <cowboy> increment
// <bot> The counter is now at 3.
// <tyler> increment
// <bot> The counter is now at 2.
See the bot-stateful and bot-conversation examples.
Creating a Slack bot
While the aforementioned Bot is generally useful, it's really meant to be a starting point from which other more specific bots may be derived. Which brings us to SlackBot.
SlackBot contains all of the functionality of Bot, with some useful additional features like passing channel, user and slack information into message handlers, alowing message handlers to be chosen dynamically based on channel, group or dm, and automatically connecting the bot to the slack rtm & web clients.
When you create a SlackBot, you pass in an instance of the Slack RtmClient
and
WebClient
, and the connections between the bot and service are handled for you
automatically. All you have to do is specify your message handlers (which can be
done programmatically, as in the example below) and tell the bot to login:
// Import the official Slack client.
const slack = require('@slack/client');
const RtmClient = slack.RtmClient;
const WebClient = slack.WebClient;
const MemoryDataStore = slack.MemoryDataStore;
// Import the chatter createBot function.
const createSlackBot = require('chatter').createSlackBot;
const bot = createSlackBot({
// The bot name.
name: 'Chatter Bot',
// The getSlack function should return instances of the slack rtm and web
// clients, like so. See https://github.com/slackhq/node-slack-sdk
getSlack() {
return {
rtmClient: new RtmClient(process.env.SLACK_API_TOKEN, {
dataStore: new MemoryDataStore(),
autoReconnect: true,
logLevel: 'error',
}),
webClient: new WebClient(process.env.SLACK_API_TOKEN),
};
},
// Return message handler for this message. See the "createArgsAdjuster"
// example for the definition of getStatefulMessageHandler.
createMessageHandler(id, meta) {
const channel = meta.channel;
// In this example, the bot will only handle DMs and ignore public channels.
if (channel.is_im) {
const messageHandler = getStatefulMessageHandler();
// Let the bot know that the message handler has state, so it'll be cached.
messageHandler.hasState = true;
return messageHandler;
}
},
});
// Connect!
bot.login();
See the slack-bot example.
API
Bot
Bot
- The base bot class.createBot
- function that returns an instance ofBot
.SlackBot
- Subclass ofBot
that contains Slack-specific functionality.createSlackBot
- function that returns an instance ofSlackBot
.
Message handlers
DelegatingMessageHandler
-createDelegate
- function that returns an instance ofDelegatingMessageHandler
.MatchingMessageHandler
-createMatcher
- function that returns an instance ofMatchingMessageHandler
.ArgsAdjustingMessageHandler
-createArgsAdjuster
- function that returns an instance ofArgsAdjustingMessageHandler
.ParsingMessageHandler
-createParser
- function that returns an instance ofParsingMessageHandler
.ConversingMessageHandler
-createConversation
- function that returns an instance ofConversingMessageHandler
.CommandMessageHandler
-createCommand
- function that returns an instance ofCommandMessageHandler
.
Util
handleMessage
- Pass specified arguments through a message handler or array of message handlers.isMessageHandlerOrHandlers
- Facilitate message handler result parsing.parseArgs
- Parse args from an array or string. Suitable for use with lines of chat.isMessage
- Is the argument a message? It's a message if it's an Array, nested Arrays, or a value comprised solely of String, Number, null, undefined or false values.normalizeMessage
- Flatten message array and remove null, undefined or false items, then join on newline.composeCreators
- Compose creators that accept a signature like getHandlers() into a single creator. All creators receive the same options object.
Developing and Contributing
npm scripts
This project and all examples are written for nodejs in ES2015, using babel.
Ensure you have Node 4.x or 6.x and Npm installed and run npm install
before running any of the following commands:
npm test
- Lints project code and runs tests.npm run build
- Builds project code fromsrc
intodist
for publishing.npm run start
- Watches project files for changes, linting, testing and building as-necessary.npm run babel
- Run ES2015 javascript via the babel cli.npm run prepublish
- Automatically runsnpm run build
before publishing.
Contributing
TBD