├── .gitignore ├── .travis.yml ├── assets ├── chatter-icon.png ├── chatter-icon.svg └── chatter-robot.svg ├── .eslintrc-es5.yaml ├── .eslintrc-mocha.yaml ├── .babelrc ├── .eslintrc-es2015.yaml ├── .eslintrc-examples.yaml ├── Gruntfile.js ├── src ├── util │ ├── bot-helpers.js │ ├── bot-helpers.test.js │ ├── queue.js │ ├── response.js │ ├── process-message.js │ ├── args-parser.js │ ├── queue.test.js │ ├── response.test.js │ ├── args-parser.test.js │ └── process-message.test.js ├── message-handler │ ├── parser.js │ ├── args-adjuster.js │ ├── conversation.js │ ├── delegate.js │ ├── matcher.js │ ├── parser.test.js │ ├── args-adjuster.test.js │ ├── delegate.test.js │ ├── command.test.js │ ├── command.js │ ├── conversation.test.js │ └── matcher.test.js ├── slack │ ├── util │ │ ├── message-parser.js │ │ └── message-parser.test.js │ ├── slack-bot.test.js │ └── slack-bot.js ├── index.js ├── index.test.js ├── bot.test.js └── bot.js ├── tools └── test-globals.js ├── eslint ├── eslint-node-commonjs.yaml ├── eslint-es2015.yaml └── eslint-defaults.yaml ├── LICENSE ├── package.json ├── examples ├── create-parser.js ├── create-matcher.js ├── slack-naive.js ├── create-args-adjuster.js ├── create-command.js ├── create-command-namespaced.js ├── message-handlers.js ├── bot-stateful.js ├── slack-bot.js └── bot-conversation.js ├── Gruntfile.babel.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | dist 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "6" 5 | - "4" 6 | -------------------------------------------------------------------------------- /assets/chatter-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bocoup/chatter/HEAD/assets/chatter-icon.png -------------------------------------------------------------------------------- /.eslintrc-es5.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "./eslint/eslint-defaults.yaml" 4 | - "./eslint/eslint-node-commonjs.yaml" 5 | -------------------------------------------------------------------------------- /.eslintrc-mocha.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | mocha: true 4 | globals: 5 | assert: true 6 | expect: true 7 | extends: 8 | - "./.eslintrc-es2015.yaml" 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-runtime" 4 | ], 5 | "presets": [ 6 | "es2015" 7 | ], 8 | "sourceMaps": "inline", 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc-es2015.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "./eslint/eslint-defaults.yaml" 4 | - "./eslint/eslint-node-commonjs.yaml" 5 | - "./eslint/eslint-es2015.yaml" 6 | -------------------------------------------------------------------------------- /.eslintrc-examples.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "./.eslintrc-es2015.yaml" 4 | 5 | plugins: 6 | - node 7 | 8 | rules: 9 | node/no-unsupported-features: 10 | - error 11 | no-use-before-define: 12 | - 0 13 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // babel gruntfile bootstrapper 4 | 5 | require('babel-register'); 6 | 7 | module.exports = function(grunt) { 8 | module.exports.grunt = grunt; 9 | require('./Gruntfile.babel'); 10 | }; 11 | -------------------------------------------------------------------------------- /src/util/bot-helpers.js: -------------------------------------------------------------------------------- 1 | // Copy whitelisted source properties to target object. 2 | export function overrideProperties(target, source, overrides = []) { 3 | overrides.forEach(name => { 4 | if (source[name]) { 5 | target[name] = source[name]; 6 | } 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /tools/test-globals.js: -------------------------------------------------------------------------------- 1 | import chai, {assert, expect} from 'chai'; 2 | import dirtyChai from 'dirty-chai'; 3 | import chaiAsPromised from 'chai-as-promised'; 4 | 5 | chai.use(chaiAsPromised); 6 | chai.use(dirtyChai); 7 | 8 | global.assert = assert; 9 | global.expect = expect; 10 | -------------------------------------------------------------------------------- /eslint/eslint-node-commonjs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | node: true 4 | 5 | ecmaFeatures: 6 | blockBindings: true 7 | 8 | rules: 9 | 10 | # General but applicable here 11 | 12 | strict: 13 | - 2 14 | - global 15 | 16 | # Node.js and CommonJS 17 | 18 | callback-return: 19 | - 2 20 | - [ callback, cb, done, next ] 21 | handle-callback-err: 22 | - 2 23 | - "^err(?:or)?$" 24 | no-mixed-requires: 0 25 | no-new-require: 2 26 | no-path-concat: 2 27 | no-process-exit: 2 28 | no-restricted-modules: 0 29 | no-sync: 0 30 | -------------------------------------------------------------------------------- /src/message-handler/parser.js: -------------------------------------------------------------------------------- 1 | import {parseArgs} from '../util/args-parser'; 2 | import {DelegatingMessageHandler} from './delegate'; 3 | 4 | export class ParsingMessageHandler extends DelegatingMessageHandler { 5 | 6 | constructor(options = {}, children) { 7 | super(options, children); 8 | this.parseOptions = options.parseOptions || {}; 9 | } 10 | 11 | // Parse arguments and options from message and pass the resulting object 12 | // into the specified handleMessage function. 13 | handleMessage(message = '', ...args) { 14 | const parsed = parseArgs(message, this.parseOptions); 15 | parsed.text = message; 16 | return super.handleMessage(parsed, ...args); 17 | } 18 | 19 | } 20 | 21 | export default function createParser(...args) { 22 | return new ParsingMessageHandler(...args); 23 | } 24 | -------------------------------------------------------------------------------- /src/util/bot-helpers.test.js: -------------------------------------------------------------------------------- 1 | import {overrideProperties} from './bot-helpers'; 2 | 3 | const nop = () => {}; 4 | 5 | describe('util/bot-helpers', function() { 6 | 7 | it('should export the proper API', function() { 8 | expect(overrideProperties).to.be.a('function'); 9 | }); 10 | 11 | describe('overrideProperties', function() { 12 | 13 | it('should copy specific properties to the target', function() { 14 | const src = {a: 1, b: 'two', c: null, d: false, f: nop}; 15 | let target = {}; 16 | overrideProperties(target, src, ['a', 'b']); 17 | expect(target).to.deep.equal({a: 1, b: 'two'}); 18 | target = {c: 123}; 19 | overrideProperties(target, src, ['a', 'b', 'c', 'd', 'f']); 20 | expect(target).to.deep.equal({a: 1, b: 'two', c: 123, f: nop}); 21 | }); 22 | 23 | }); 24 | 25 | }); 26 | -------------------------------------------------------------------------------- /eslint/eslint-es2015.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | es6: true 4 | 5 | parserOptions: 6 | ecmaVersion: 6 7 | sourceType: "module" 8 | ecmaFeatures: 9 | experimentalObjectRestSpread: true 10 | 11 | rules: 12 | # General but applicable here 13 | 14 | no-inner-declarations: 0 15 | no-iterator: 0 16 | 17 | # ECMAScript 6 18 | 19 | arrow-parens: 20 | - 2 21 | - as-needed 22 | arrow-spacing: 23 | - 2 24 | - before: true 25 | after: true 26 | constructor-super: 2 27 | generator-star-spacing: 28 | - 2 29 | - before 30 | no-class-assign: 2 31 | no-const-assign: 2 32 | no-this-before-super: 2 33 | no-var: 2 34 | object-shorthand: 35 | - 2 36 | - always 37 | prefer-const: 2 38 | prefer-spread: 2 39 | prefer-reflect: 0 40 | quotes: 41 | - 2 42 | - single 43 | - avoidEscape: true 44 | allowTemplateLiterals: true 45 | require-yield: 2 46 | -------------------------------------------------------------------------------- /src/util/queue.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | 3 | export default class Queue { 4 | 5 | constructor(options = {}) { 6 | this.cache = {}; 7 | const { 8 | onDrain = data => console.log('onDrain', data), 9 | } = options; 10 | this.onDrain = onDrain; 11 | } 12 | 13 | enqueue(id, data) { 14 | if (!this.cache[id]) { 15 | this.cache[id] = { 16 | queue: [], 17 | }; 18 | } 19 | const obj = this.cache[id]; 20 | obj.queue.push(data); 21 | if (!obj.promise) { 22 | obj.promise = this.drain(id); 23 | } 24 | return obj.promise; 25 | } 26 | 27 | drain(id) { 28 | const obj = this.cache[id]; 29 | const next = () => { 30 | if (obj.queue.length === 0) { 31 | obj.promise = null; 32 | return null; 33 | } 34 | return Promise.try(() => this.onDrain(id, obj.queue.shift())).then(next); 35 | }; 36 | return next(); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/message-handler/args-adjuster.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import {DelegatingMessageHandler} from './delegate'; 3 | 4 | export class ArgsAdjustingMessageHandler extends DelegatingMessageHandler { 5 | 6 | constructor(options = {}, children) { 7 | super(options, children); 8 | if (!options.adjustArgs) { 9 | throw new TypeError('Missing required "adjustArgs" option.'); 10 | } 11 | this.adjustArgs = options.adjustArgs || {}; 12 | } 13 | 14 | // Adjust args, and pass the new args into the message handler. 15 | handleMessage(...args) { 16 | const newArgs = this.adjustArgs(...args); 17 | if (!Array.isArray(newArgs)) { 18 | return Promise.reject(new Error('adjustArgs must return an array (of arguments).')); 19 | } 20 | return super.handleMessage(...newArgs); 21 | } 22 | 23 | } 24 | 25 | export default function createArgsAdjuster(...args) { 26 | return new ArgsAdjustingMessageHandler(...args); 27 | } 28 | -------------------------------------------------------------------------------- /src/slack/util/message-parser.js: -------------------------------------------------------------------------------- 1 | // Parse message strings per https://api.slack.com/docs/formatting 2 | // Turns a string like this: 3 | // Hello <@U03BS5P65>, channel is <#C025GMFDX> and URL is 4 | // Into this: 5 | // Hello @cowboy, channel is #general and URL is http://foo.com/bar 6 | export function parseMessage(slack, message = '') { 7 | const handlers = { 8 | '#': id => { 9 | const {name} = slack.rtmClient.dataStore.getChannelById(id) || {}; 10 | return name && `#${name}`; 11 | }, 12 | '@': id => { 13 | const {name} = slack.rtmClient.dataStore.getUserById(id) || {}; 14 | return name && `@${name}`; 15 | }, 16 | }; 17 | const firstPart = s => s.split('|')[0]; 18 | return message.replace(/<([^\s>]+)>/g, (all, text) => { 19 | const prefix = text[0]; 20 | const handler = handlers[prefix]; 21 | if (handler) { 22 | return handler(firstPart(text.slice(1))); 23 | } 24 | return firstPart(text); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 "Cowboy" Ben Alman 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Bot 2 | export {Bot, default as createBot} from './bot'; 3 | export {SlackBot, default as createSlackBot} from './slack/slack-bot'; 4 | 5 | // Message handlers 6 | export {DelegatingMessageHandler, default as createDelegate} from './message-handler/delegate'; 7 | export {MatchingMessageHandler, default as createMatcher} from './message-handler/matcher'; 8 | export {ArgsAdjustingMessageHandler, default as createArgsAdjuster} from './message-handler/args-adjuster'; 9 | export {ParsingMessageHandler, default as createParser} from './message-handler/parser'; 10 | export {ConversingMessageHandler, default as createConversation} from './message-handler/conversation'; 11 | export {CommandMessageHandler, default as createCommand} from './message-handler/command'; 12 | 13 | // Util 14 | export {processMessage, isMessageHandlerOrHandlers} from './util/process-message'; 15 | export {parseArgs} from './util/args-parser'; 16 | export {isMessage, isArrayOfMessages, normalizeMessage, normalizeMessages, normalizeResponse} from './util/response'; 17 | export {default as Queue} from './util/queue'; 18 | export {composeCreators} from './message-handler/delegate'; 19 | -------------------------------------------------------------------------------- /src/message-handler/conversation.js: -------------------------------------------------------------------------------- 1 | import {processMessage} from '../util/process-message'; 2 | import {DelegatingMessageHandler} from './delegate'; 3 | 4 | export class ConversingMessageHandler extends DelegatingMessageHandler { 5 | 6 | constructor(options = {}, children) { 7 | super(options, children); 8 | this.dialog = null; 9 | this.hasState = true; 10 | } 11 | 12 | // If a child handler yields an object with a "dialog" property, that 13 | // handler will be stored and will receive the next message. Upon receiving 14 | // that next message, the dialog will then be cleared. 15 | // 16 | // A stored dialog may yield another dialog (ad infinitum). If no dialog is 17 | // stored, child handlers will be called. A copy of the object yielded by the 18 | // child handler / dialog (minus the dialog property) will be yielded. 19 | handleMessage(message, ...args) { 20 | return processMessage(this.dialog || this.children, message, ...args) 21 | .finally(() => this.clearDialog()) 22 | .then(data => { 23 | if (data && data.dialog) { 24 | this.dialog = data.dialog; 25 | data = Object.assign({}, data); 26 | delete data.dialog; 27 | } 28 | return data; 29 | }); 30 | } 31 | 32 | // Forceably clear any current stored dialog. 33 | clearDialog() { 34 | this.dialog = null; 35 | } 36 | 37 | } 38 | 39 | export default function createConversation(...args) { 40 | return new ConversingMessageHandler(...args); 41 | } 42 | -------------------------------------------------------------------------------- /src/slack/slack-bot.test.js: -------------------------------------------------------------------------------- 1 | import createSlackBot, {SlackBot} from './slack-bot'; 2 | 3 | const nop = () => {}; 4 | 5 | function getSlack() { 6 | return { 7 | rtmClient: { 8 | on: nop, 9 | dataStore: {}, 10 | }, 11 | webClient: {}, 12 | }; 13 | } 14 | 15 | const slack = getSlack(); 16 | 17 | describe('slack/bot', function() { 18 | 19 | it('should export the proper API', function() { 20 | expect(createSlackBot).to.be.a('function'); 21 | expect(SlackBot).to.be.a('function'); 22 | }); 23 | 24 | describe('createSlackBot', function() { 25 | 26 | it('should return an instance of Bot', function() { 27 | const bot = createSlackBot({getSlack, createMessageHandler: nop}); 28 | expect(bot).to.be.an.instanceof(SlackBot); 29 | }); 30 | 31 | }); 32 | 33 | describe('SlackBot', function() { 34 | 35 | describe('constructor', function() { 36 | 37 | it('should behave like Bot', function() { 38 | expect(() => createSlackBot({getSlack, createMessageHandler: nop})).to.not.throw(); 39 | expect(() => createSlackBot()).to.throw(/missing.*createMessageHandler/i); 40 | }); 41 | 42 | it('should throw if no slack or getSlack option was specified', function() { 43 | expect(() => createSlackBot({createMessageHandler: nop})).to.throw(/missing.*slack/i); 44 | expect(() => createSlackBot({createMessageHandler: nop, slack})).to.not.throw(); 45 | expect(() => createSlackBot({createMessageHandler: nop, getSlack})).to.not.throw(); 46 | }); 47 | 48 | }); 49 | 50 | }); 51 | 52 | }); 53 | 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatter", 3 | "version": "0.5.1", 4 | "description": "A collection of useful primitives for creating interactive chat bots.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "grunt test", 8 | "build": "grunt build", 9 | "start": "grunt watch", 10 | "babel": "babel-node --", 11 | "prepublish": "npm run build" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/bocoup/chatter.git" 16 | }, 17 | "author": "\"Cowboy\" Ben Alman (http://benalman.com/)", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/bocoup/chatter/issues" 21 | }, 22 | "homepage": "https://github.com/bocoup/chatter#readme", 23 | "engines": { 24 | "node": ">=4.0" 25 | }, 26 | "files": [ 27 | "dist" 28 | ], 29 | "dependencies": { 30 | "babel-runtime": "^6.9.0", 31 | "bluebird": "^3.3.5", 32 | "ramda": "^0.21.0" 33 | }, 34 | "devDependencies": { 35 | "@slack/client": "^3.1.0", 36 | "babel-cli": "^6.8.0", 37 | "babel-plugin-transform-runtime": "^6.7.5", 38 | "babel-preset-es2015": "^6.6.0", 39 | "babel-register": "^6.7.2", 40 | "chai": "^3.5.0", 41 | "chai-as-promised": "^5.3.0", 42 | "chalk": "^1.1.3", 43 | "dirty-chai": "^1.2.2", 44 | "eslint-plugin-node": "^1.4.0", 45 | "grunt": "^1.0.1", 46 | "grunt-babel": "^6.0.0", 47 | "grunt-cli": "^1.2.0", 48 | "grunt-contrib-clean": "^1.0.0", 49 | "grunt-contrib-watch": "^1.0.0", 50 | "grunt-eslint": "^18.1.0", 51 | "grunt-mocha-test": "^0.12.7", 52 | "mocha": "^2.4.5", 53 | "source-map-support": "^0.4.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/util/response.js: -------------------------------------------------------------------------------- 1 | import R from 'ramda'; 2 | 3 | // Is the argument a message? It's a message if it's an Array, nested Arrays, or 4 | // a value comprised solely of String, Number, null, undefined or false values. 5 | export const isMessage = R.pipe( 6 | arg => [arg], 7 | R.flatten, 8 | R.reject(s => R.isNil(s) || s === false || typeof s === 'string' || typeof s === 'number'), 9 | R.length, 10 | R.equals(0) 11 | ); 12 | 13 | // Is the argument an array of messages? 14 | export function isArrayOfMessages(messages) { 15 | return Array.isArray(messages) && messages.every(isMessage); 16 | } 17 | 18 | // Flatten message array and remove null, undefined or false items, then join 19 | // on newline. 20 | export const normalizeMessage = R.pipe( 21 | arg => [arg], 22 | R.flatten, 23 | R.reject(s => R.isNil(s) || s === false), 24 | R.join('\n') 25 | ); 26 | 27 | // Normalize an array of messages, removing null, undefined or false items. 28 | export const normalizeMessages = R.pipe( 29 | R.reject(s => R.isNil(s) || s === false), 30 | R.map(normalizeMessage) 31 | ); 32 | 33 | // Normalize response into an array of 0 or more text messages. For each 34 | // "message", flatten all arrays, remove any false, null or undefined values, 35 | // and join the resulting flattened and filtered array on newline. 36 | export function normalizeResponse(response = {}) { 37 | if (isMessage(response)) { 38 | return [normalizeMessage(response)]; 39 | } 40 | else if (isArrayOfMessages(response.messages)) { 41 | return normalizeMessages(response.messages); 42 | } 43 | else if ('message' in response && isMessage(response.message)) { 44 | return [normalizeMessage(response.message)]; 45 | } 46 | return false; 47 | } 48 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import * as api from './index'; 2 | 3 | describe('npm module', function() { 4 | 5 | it('should export Bot', function() { 6 | expect(api.Bot).to.be.a('function'); 7 | expect(api.createBot).to.be.a('function'); 8 | }); 9 | 10 | it('should export SlackBot', function() { 11 | expect(api.SlackBot).to.be.a('function'); 12 | expect(api.createSlackBot).to.be.a('function'); 13 | }); 14 | 15 | it('should export DelegatingMessageHandler', function() { 16 | expect(api.DelegatingMessageHandler).to.be.a('function'); 17 | expect(api.createDelegate).to.be.a('function'); 18 | }); 19 | 20 | it('should export MatchingMessageHandler', function() { 21 | expect(api.MatchingMessageHandler).to.be.a('function'); 22 | expect(api.createMatcher).to.be.a('function'); 23 | }); 24 | 25 | it('should export ArgsAdjustingMessageHandler', function() { 26 | expect(api.ArgsAdjustingMessageHandler).to.be.a('function'); 27 | expect(api.createArgsAdjuster).to.be.a('function'); 28 | }); 29 | 30 | it('should export ParsingMessageHandler', function() { 31 | expect(api.ParsingMessageHandler).to.be.a('function'); 32 | expect(api.createParser).to.be.a('function'); 33 | }); 34 | 35 | it('should export ConversingMessageHandler', function() { 36 | expect(api.ConversingMessageHandler).to.be.a('function'); 37 | expect(api.createConversation).to.be.a('function'); 38 | }); 39 | 40 | it('should export utils', function() { 41 | expect(api.processMessage).to.be.a('function'); 42 | expect(api.parseArgs).to.be.a('function'); 43 | expect(api.isMessage).to.be.a('function'); 44 | expect(api.isArrayOfMessages).to.be.a('function'); 45 | expect(api.normalizeMessage).to.be.a('function'); 46 | expect(api.normalizeMessages).to.be.a('function'); 47 | expect(api.normalizeResponse).to.be.a('function'); 48 | expect(api.Queue).to.be.a('function'); 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /src/slack/util/message-parser.test.js: -------------------------------------------------------------------------------- 1 | import {parseMessage} from './message-parser'; 2 | 3 | const slack = { 4 | rtmClient: { 5 | dataStore: { 6 | getUserById(id) { 7 | const map = ['aaa', 'bbb', 'ccc']; 8 | return {name: map[id]}; 9 | }, 10 | getChannelById(id) { 11 | const map = ['ddd', 'eee', 'fff']; 12 | return {name: map[id]}; 13 | }, 14 | }, 15 | }, 16 | }; 17 | 18 | describe('slack/message-handler/slack', function() { 19 | 20 | it('should export the proper API', function() { 21 | expect(parseMessage).to.be.a('function'); 22 | }); 23 | 24 | describe('parseMessage', function() { 25 | 26 | it('should parse names, returning the first pipe-delimited part', function() { 27 | expect(parseMessage(slack, '<@0>')).to.equal('@aaa'); 28 | expect(parseMessage(slack, '<@0|foo>')).to.equal('@aaa'); 29 | expect(parseMessage(slack, '<@1> <@2>')).to.equal('@bbb @ccc'); 30 | expect(parseMessage(slack, 'x <@0> y <@1> z <@2>')).to.equal('x @aaa y @bbb z @ccc'); 31 | }); 32 | 33 | it('should parse channels, returning the first pipe-delimited part', function() { 34 | expect(parseMessage(slack, '<#0>')).to.equal('#ddd'); 35 | expect(parseMessage(slack, '<#0|foo>')).to.equal('#ddd'); 36 | expect(parseMessage(slack, '<#1> <#2>')).to.equal('#eee #fff'); 37 | expect(parseMessage(slack, 'x <#0> y <#1> z <#2>')).to.equal('x #ddd y #eee z #fff'); 38 | }); 39 | 40 | it('should otherwise just strip <> brackets and return the first pipe-delimited part', function() { 41 | expect(parseMessage(slack, '')).to.equal('a'); 42 | expect(parseMessage(slack, '')).to.equal('foo'); 43 | expect(parseMessage(slack, '')).to.equal('http://foo.com/bar'); 44 | expect(parseMessage(slack, '')).to.equal('http://foo.com/bar'); 45 | }); 46 | 47 | it('should support any combination of the above', function() { 48 | expect( 49 | parseMessage(slack, ' <@0> <#1> ddd <@1|e> <#2|h>') 50 | ).to.equal('a @aaa b #eee ddd @bbb f #fff'); 51 | }); 52 | 53 | }); 54 | 55 | }); 56 | -------------------------------------------------------------------------------- /src/message-handler/delegate.js: -------------------------------------------------------------------------------- 1 | import {processMessage, isMessageHandlerOrHandlers} from '../util/process-message'; 2 | 3 | // Validate these signatures. 4 | // Only options: 5 | // getHandlers({handleMessage: fn, ...}) 6 | // Only children: 7 | // getHandlers(fn) 8 | // getHandlers({handleMessage: fn}) 9 | // getHandlers([...]) 10 | // Both options and children: 11 | // getHandlers({...}, fn) 12 | // getHandlers({...}, {handleMessage: fn}) 13 | // getHandlers({...}, [...]) 14 | export function getHandlers(options = {}, handlers) { 15 | if (!handlers) { 16 | handlers = options.handleMessage || options; 17 | } 18 | if (!isMessageHandlerOrHandlers(handlers)) { 19 | throw new TypeError('Missing required message handler(s).'); 20 | } 21 | return handlers; 22 | } 23 | 24 | export class DelegatingMessageHandler { 25 | 26 | constructor(options, children) { 27 | this.children = getHandlers(options, children); 28 | } 29 | 30 | // Iterate over all child handlers, yielding the first non-false result. 31 | handleMessage(message, ...args) { 32 | return processMessage(this.children, message, ...args); 33 | } 34 | 35 | } 36 | 37 | export default function createDelegate(...args) { 38 | return new DelegatingMessageHandler(...args); 39 | } 40 | 41 | // Compose creators that accept a signature like getHandlers() into a single 42 | // creator. All creators receive the same options object. 43 | // Eg: 44 | // const createMatcherParser = composeCreators(createMatcher, createParser); 45 | // const fooHandler = createMatcherParser({match: 'foo', parseOptions: {}}, fn); 46 | // Is equivalent to: 47 | // const fooHandler = createMatcher({match: 'foo'}, createParser({parseOptions: {}}, fn)); 48 | export function composeCreators(...creators) { 49 | if (Array.isArray(creators[0])) { 50 | creators = creators[0]; 51 | } 52 | return function composed(options, children) { 53 | children = getHandlers(options, children); 54 | function recurse([currentHandler, ...remain]) { 55 | const nextHandler = remain.length > 0 ? recurse(remain) : createDelegate(children); 56 | return currentHandler(options, nextHandler); 57 | } 58 | return recurse(creators); 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/util/process-message.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | 3 | // Pass in a message handler and arguments, and the message handler will be 4 | // called with those arguments. 5 | // 6 | // A message handler may be a function or an object with a handleMessage method. 7 | // A message handler may return a value or a promise that yields a value. 8 | export function callMessageHandler(handler, ...args) { 9 | if (typeof handler === 'function') { 10 | return handler(...args); 11 | } 12 | else if (handler && handler.handleMessage) { 13 | return handler.handleMessage(...args); 14 | } 15 | throw new TypeError('Message handler must be a function or object with a handleMessage method.'); 16 | } 17 | 18 | // Facilitate message handler result parsing. 19 | export function isMessageHandlerOrHandlers(val) { 20 | // Ensure arrays consist of only functions or message handler objects. 21 | if (Array.isArray(val)) { 22 | return val.every(item => isMessageHandlerOrHandlers(item)); 23 | } 24 | // Return true if val is a function or message handler object. 25 | return typeof val === 'function' || (val && typeof val.handleMessage === 'function') || false; 26 | } 27 | 28 | // Pass specified arguments through a message handler or array of message 29 | // handlers. 30 | // 31 | // If a returned/yielded value is: 32 | // * a message handler or array of message handlers: unroll it/them inline 33 | // * false: skip to the the next message handler 34 | // * anything else: stop iteration and yield that value 35 | // 36 | // If iteration completes and no non-false value was returned/yielded, yield 37 | // false. 38 | export function processMessage(handlers, ...args) { 39 | if (!Array.isArray(handlers)) { 40 | return Promise.try(() => callMessageHandler(handlers, ...args)); 41 | } 42 | const {length} = handlers; 43 | let i = 0; 44 | const next = f => Promise.try(f).then(result => { 45 | if (isMessageHandlerOrHandlers(result)) { 46 | return next(() => processMessage(result, ...args)); 47 | } 48 | else if (result !== false) { 49 | return result; 50 | } 51 | else if (i === length) { 52 | return false; 53 | } 54 | return next(() => processMessage(handlers[i++], ...args)); 55 | }); 56 | return next(() => false); 57 | } 58 | -------------------------------------------------------------------------------- /src/message-handler/matcher.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import {DelegatingMessageHandler} from './delegate'; 3 | 4 | // Escape special characters that would cause errors if we interpolate them 5 | // into a regex. 6 | function regexEscape(expr) { 7 | return expr.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); 8 | } 9 | 10 | // If "match" is a String and matches the entire message or matches a space- 11 | // delimited word at the beginning of the message, success. If any text 12 | // remains after the match, return it (with leading spaces stripped) as the 13 | // remainder. 14 | // 15 | // If match is a RegExp and matches the message, return the value of the 16 | // first non-undefined (ie. captured) capture group. 17 | export function matchStringOrRegex(match, message = '') { 18 | const re = typeof match === 'string' ? new RegExp(`^${regexEscape(match)}(?:$|\\s+(.*))`, 'i') : match; 19 | const [fullMatch, ...captures] = message.match(re) || []; 20 | if (typeof fullMatch !== 'string') { 21 | return false; 22 | } 23 | return captures.find(c => typeof c === 'string') || ''; 24 | } 25 | 26 | export class MatchingMessageHandler extends DelegatingMessageHandler { 27 | 28 | constructor(options = {}, children) { 29 | super(options, children); 30 | if (!('match' in options)) { 31 | throw new TypeError('Missing required "match" option.'); 32 | } 33 | this.match = options.match; 34 | } 35 | 36 | // If match succeeds, pass remainder into child handlers, yielding their 37 | // result. If no match, yield false. 38 | handleMessage(message, ...args) { 39 | return Promise.try(() => this.doMatch(message, ...args)) 40 | .then(remainder => { 41 | if (remainder === false) { 42 | return false; 43 | } 44 | return super.handleMessage(remainder, ...args); 45 | }); 46 | } 47 | 48 | // Attempt to match the message, given the "match" option. 49 | doMatch(message, ...args) { 50 | const {match} = this; 51 | if (typeof match === 'function') { 52 | return match(message, ...args); 53 | } 54 | else if (typeof match === 'string' || match instanceof RegExp) { 55 | return matchStringOrRegex(match, message); 56 | } 57 | throw new TypeError('Invalid "match" option format.'); 58 | } 59 | 60 | } 61 | 62 | export default function createMatcher(...args) { 63 | return new MatchingMessageHandler(...args); 64 | } 65 | -------------------------------------------------------------------------------- /assets/chatter-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/message-handler/parser.test.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import createParser, {ParsingMessageHandler} from './parser'; 3 | 4 | const nop = () => {}; 5 | 6 | describe('message-handler/parser', function() { 7 | 8 | it('should export the proper API', function() { 9 | expect(createParser).to.be.a('function'); 10 | expect(ParsingMessageHandler).to.be.a('function'); 11 | }); 12 | 13 | describe('createParser', function() { 14 | 15 | it('should return an instance of ParsingMessageHandler', function() { 16 | const parser = createParser({handleMessage() {}}); 17 | expect(parser).to.be.an.instanceof(ParsingMessageHandler); 18 | }); 19 | 20 | }); 21 | 22 | describe('ParsingMessageHandler', function() { 23 | 24 | describe('constructor', function() { 25 | 26 | it('should behave like DelegatingMessageHandler', function() { 27 | expect(() => createParser(nop)).to.not.throw(); 28 | expect(() => createParser()).to.throw(/missing.*message.*handler/i); 29 | }); 30 | 31 | }); 32 | 33 | describe('handleMessage', function() { 34 | 35 | it('should return a promise that gets fulfilled', function() { 36 | const parser = createParser({handleMessage() {}}); 37 | return expect(parser.handleMessage()).to.be.fulfilled(); 38 | }); 39 | 40 | it('should pass parsed args and additional arguments into child handlers', function() { 41 | const parser = createParser({ 42 | handleMessage(args, a, b) { 43 | return {args, a, b}; 44 | }, 45 | parseOptions: { 46 | xxx: String, 47 | yyy: Number, 48 | zzz: Boolean, 49 | }, 50 | }); 51 | return Promise.all([ 52 | expect(parser.handleMessage('foo bar', 1, 2)).to.become({ 53 | args: { 54 | text: 'foo bar', 55 | errors: [], 56 | options: {}, 57 | args: ['foo', 'bar'], 58 | }, 59 | a: 1, 60 | b: 2, 61 | }), 62 | expect(parser.handleMessage('foo bar x=1 y=2 z=3 baz', 1, 2)).to.become({ 63 | args: { 64 | text: 'foo bar x=1 y=2 z=3 baz', 65 | errors: [], 66 | options: {xxx: '1', yyy: 2, zzz: true}, 67 | args: ['foo', 'bar', 'baz'], 68 | }, 69 | a: 1, 70 | b: 2, 71 | }), 72 | ]); 73 | }); 74 | 75 | }); 76 | 77 | }); 78 | 79 | }); 80 | 81 | -------------------------------------------------------------------------------- /examples/create-parser.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/create-parser.js 6 | 7 | const Promise = require('bluebird'); 8 | const chalk = require('chalk'); 9 | 10 | // ES2015 syntax: 11 | // import {processMessage, createParser, createMatcher} from 'chatter'; 12 | // ES5 syntax: 13 | // const chatter = require('chatter'); 14 | const chatter = require('..'); 15 | const processMessage = chatter.processMessage; 16 | const createParser = chatter.createParser; 17 | const createMatcher = chatter.createMatcher; 18 | 19 | // ================ 20 | // message handlers 21 | // ================ 22 | 23 | // Returns a JSON-formatted string representing the parsed object. 24 | const parsingHandler = createParser(parsed => JSON.stringify(parsed, null, 2)); 25 | 26 | // When parseOptions is defined, any options in the message specified like 27 | // option=value will be parsed and processed via the defined function. 28 | const parsingHandlerWithOptions = createParser({ 29 | parseOptions: { 30 | alpha: String, 31 | beta: Number, 32 | bravo: Boolean, 33 | }, 34 | }, parsed => JSON.stringify(parsed, null, 2)); 35 | 36 | // Matches "add" prefix, then adds the remaining args into a sum. 37 | const addHandler = createMatcher({match: 'add'}, createParser(parsed => { 38 | const args = parsed.args; 39 | const result = args.reduce((sum, num) => sum + Number(num), 0); 40 | return `${args.join(' + ')} = ${result}`; 41 | })); 42 | 43 | // ==================================== 44 | // process messages with processMessage 45 | // ==================================== 46 | 47 | function log(color, prefix, message) { 48 | message = message.replace(/(\n)/g, `$1${' '.repeat(prefix.length + 1)}`); 49 | console.log(chalk[color](`${prefix} ${message}`)); 50 | } 51 | 52 | function simulate(messageHandler, message) { 53 | log('magenta', '\n[In] ', message); 54 | return processMessage(messageHandler, message) 55 | .then(response => { 56 | const text = response !== false ? response : '-'; 57 | log('green', '[Out]', text); 58 | }) 59 | .then(() => Promise.delay(100)); 60 | } 61 | 62 | Promise.mapSeries([ 63 | () => simulate(parsingHandler, 'foo bar baz'), 64 | () => simulate(parsingHandlerWithOptions, 'foo bar a=12 baz b=34 c=56'), 65 | () => simulate(parsingHandlerWithOptions, 'foo bar al=12 baz be=34 br=56'), 66 | () => simulate(addHandler, 'add 1 2 3'), 67 | () => simulate(addHandler, 'add 4 five 6'), 68 | ], f => f()); 69 | -------------------------------------------------------------------------------- /src/message-handler/args-adjuster.test.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import createArgsAdjuster, {ArgsAdjustingMessageHandler} from './args-adjuster'; 3 | 4 | const nop = () => {}; 5 | 6 | describe('message-handler/args-adjuster', function() { 7 | 8 | it('should export the proper API', function() { 9 | expect(createArgsAdjuster).to.be.a('function'); 10 | expect(ArgsAdjustingMessageHandler).to.be.a('function'); 11 | }); 12 | 13 | describe('createArgsAdjuster', function() { 14 | 15 | it('should return an instance of ArgsAdjustingMessageHandler', function() { 16 | const adjuster = createArgsAdjuster({adjustArgs: nop}, nop); 17 | expect(adjuster).to.be.an.instanceof(ArgsAdjustingMessageHandler); 18 | }); 19 | 20 | }); 21 | 22 | describe('ArgsAdjustingMessageHandler', function() { 23 | 24 | describe('constructor', function() { 25 | 26 | it('should behave like DelegatingMessageHandler', function() { 27 | expect(() => createArgsAdjuster({adjustArgs: nop}, nop)).to.not.throw(); 28 | expect(() => createArgsAdjuster({adjustArgs: nop})).to.throw(/missing.*message.*handler/i); 29 | }); 30 | 31 | it('should throw if adjustArgs option was specified', function() { 32 | // Valid 33 | expect(() => createArgsAdjuster({adjustArgs: nop}, nop)).to.not.throw(); 34 | // Invalid 35 | expect(() => createArgsAdjuster(nop)).to.throw(/missing.*adjustArgs/i); 36 | expect(() => createArgsAdjuster({}, nop)).to.throw(/missing.*adjustArgs/i); 37 | }); 38 | 39 | }); 40 | 41 | describe('handleMessage', function() { 42 | 43 | it('should return a promise that gets fulfilled', function() { 44 | const adjuster = createArgsAdjuster({adjustArgs: () => []}, nop); 45 | return expect(adjuster.handleMessage()).to.be.fulfilled(); 46 | }); 47 | 48 | it('should reject if adjustArgs returns a non-array', function() { 49 | const adjuster = createArgsAdjuster({adjustArgs: nop}, nop); 50 | expect(adjuster.handleMessage('foo')).to.be.rejectedWith(/adjustArgs.*array/i); 51 | }); 52 | 53 | it('should pass adjusted args into child handlers', function() { 54 | const adjuster = createArgsAdjuster({ 55 | adjustArgs(message, ...args) { 56 | return [message, 'a', ...args, 'b']; 57 | }, 58 | }, (...args) => args.join('-')); 59 | return Promise.all([ 60 | expect(adjuster.handleMessage('foo')).to.eventually.equal('foo-a-b'), 61 | expect(adjuster.handleMessage('foo', 1, 2)).to.eventually.equal('foo-a-1-2-b'), 62 | ]); 63 | }); 64 | 65 | }); 66 | 67 | }); 68 | 69 | }); 70 | -------------------------------------------------------------------------------- /examples/create-matcher.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/create-matcher.js 6 | 7 | const Promise = require('bluebird'); 8 | const chalk = require('chalk'); 9 | 10 | // ES2015 syntax: 11 | // import {processMessage, createMatcher} from 'chatter'; 12 | // ES5 syntax: 13 | // const chatter = require('chatter'); 14 | const chatter = require('..'); 15 | const processMessage = chatter.processMessage; 16 | const createMatcher = chatter.createMatcher; 17 | 18 | // ================ 19 | // message handlers 20 | // ================ 21 | 22 | // Matches "add" prefix, then splits the message into an array and adds the 23 | // array items into a sum. 24 | const addMatcher = createMatcher({match: 'add'}, message => { 25 | const numbers = message.split(' '); 26 | const result = numbers.reduce((sum, n) => sum + Number(n), 0); 27 | return `${numbers.join(' + ')} = ${result}`; 28 | }); 29 | 30 | // Matches "multiply" prefix, then splits the message into an array and 31 | // multiplies the array items into a product. 32 | const multiplyMatcher = createMatcher({match: 'multiply'}, message => { 33 | const numbers = message.split(' '); 34 | const result = numbers.reduce((product, n) => product * Number(n), 1); 35 | return `${numbers.join(' x ')} = ${result}`; 36 | }); 37 | 38 | // Parent message handler that "namespaces" its sub-handlers and provides a 39 | // fallback message if a sub-handler isn't matched. 40 | const mathMatcher = createMatcher({match: 'math'}, [ 41 | addMatcher, 42 | multiplyMatcher, 43 | message => `Sorry, I don't understand "${message}".`, 44 | ]); 45 | 46 | // ==================================== 47 | // process messages with processMessage 48 | // ==================================== 49 | 50 | function log(color, prefix, message) { 51 | message = message.replace(/(\n)/g, `$1${' '.repeat(prefix.length + 1)}`); 52 | console.log(chalk[color](`${prefix} ${message}`)); 53 | } 54 | 55 | function simulate(messageHandler, message) { 56 | log('magenta', '\n[In] ', message); 57 | return processMessage(messageHandler, message) 58 | .then(response => { 59 | const text = response !== false ? response : '-'; 60 | log('green', '[Out]', text); 61 | }) 62 | .then(() => Promise.delay(100)); 63 | } 64 | 65 | Promise.mapSeries([ 66 | () => simulate(mathMatcher, 'add 3 4 5'), 67 | () => simulate(mathMatcher, 'multiply 3 4 5'), 68 | () => simulate(mathMatcher, 'math add 3 4 5'), 69 | () => simulate(mathMatcher, 'math multiply 3 4 5'), 70 | () => simulate(mathMatcher, 'math subtract 3 4 5'), 71 | ], f => f()); 72 | -------------------------------------------------------------------------------- /src/bot.test.js: -------------------------------------------------------------------------------- 1 | import createBot, {Bot} from './bot'; 2 | 3 | const nop = () => {}; 4 | 5 | describe('bot', function() { 6 | 7 | it('should export the proper API', function() { 8 | expect(createBot).to.be.a('function'); 9 | expect(Bot).to.be.a('function'); 10 | }); 11 | 12 | describe('createBot', function() { 13 | 14 | it('should return an instance of Bot', function() { 15 | const bot = createBot({createMessageHandler: nop}); 16 | expect(bot).to.be.an.instanceof(Bot); 17 | }); 18 | 19 | }); 20 | 21 | describe('Bot', function() { 22 | 23 | describe('constructor', function() { 24 | 25 | it('should throw if no createMessageHandler option was specified', function() { 26 | expect(() => createBot()).to.throw(/missing.*createMessageHandler/i); 27 | expect(() => createBot({})).to.throw(/missing.*createMessageHandler/i); 28 | expect(() => createBot({createMessageHandler: nop})).to.not.throw(); 29 | }); 30 | 31 | }); 32 | 33 | describe('createMessageHandler', function() { 34 | 35 | it('should create and return stateless message handlers', function() { 36 | let i = 0; 37 | const createStatelessHandler = id => ({i: i++, id}); 38 | const bot = createBot({createMessageHandler: createStatelessHandler}); 39 | expect(bot.getMessageHandler('a')).to.deep.equal({i: 0, id: 'a'}); 40 | expect(bot.getMessageHandler('a')).to.deep.equal({i: 1, id: 'a'}); 41 | expect(bot.getMessageHandler('b')).to.deep.equal({i: 2, id: 'b'}); 42 | expect(bot.getMessageHandler('c')).to.deep.equal({i: 3, id: 'c'}); 43 | expect(bot.getMessageHandler('b')).to.deep.equal({i: 4, id: 'b'}); 44 | expect(bot.getMessageHandler('a')).to.deep.equal({i: 5, id: 'a'}); 45 | expect(bot.getMessageHandler('c')).to.deep.equal({i: 6, id: 'c'}); 46 | }); 47 | 48 | it('should cache and retrieve stateful message handlers', function() { 49 | let i = 0; 50 | const createStatefulHandler = id => ({i: i++, id, hasState: true}); 51 | const bot = createBot({createMessageHandler: createStatefulHandler}); 52 | expect(bot.getMessageHandler('a')).to.deep.equal({i: 0, id: 'a', hasState: true}); 53 | expect(bot.getMessageHandler('a')).to.deep.equal({i: 0, id: 'a', hasState: true}); 54 | expect(bot.getMessageHandler('b')).to.deep.equal({i: 1, id: 'b', hasState: true}); 55 | expect(bot.getMessageHandler('c')).to.deep.equal({i: 2, id: 'c', hasState: true}); 56 | expect(bot.getMessageHandler('b')).to.deep.equal({i: 1, id: 'b', hasState: true}); 57 | expect(bot.getMessageHandler('a')).to.deep.equal({i: 0, id: 'a', hasState: true}); 58 | expect(bot.getMessageHandler('c')).to.deep.equal({i: 2, id: 'c', hasState: true}); 59 | }); 60 | 61 | }); 62 | 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /src/util/args-parser.js: -------------------------------------------------------------------------------- 1 | // Parse args from an array or string. Suitable for use with lines of chat. 2 | // 3 | // Example: 4 | // parseArgs(`foo 'bar baz' a=123 b="x y z = 456" "can't wait"`, {aaa: Number, bbb: String}) 5 | // Returns: 6 | // { options: { aaa: 123, bbb: 'x y z = 456' }, 7 | // args: [ 'foo', 'bar baz', 'can\'t wait' ], 8 | // errors: [] } 9 | export function parseArgs(args, validProps = {}) { 10 | const options = {}; 11 | const remain = []; 12 | const errors = []; 13 | 14 | if (typeof args === 'string') { 15 | args = args.split(' '); 16 | } 17 | // Use a copy so the originally passed args array isn't modified. 18 | else { 19 | args = [...args]; 20 | } 21 | 22 | function setOption(arg) { 23 | // Anything before the first = is the prop name, the rest is the value. 24 | const [, prop, value] = arg.match(/([^=]+)=(.*)/) || []; 25 | if (!prop && !value) { 26 | return false; 27 | } 28 | // Matches are case insensitive, and can be an abbreviation of the actual 29 | // prop name. 30 | const matches = Object.keys(validProps).filter(p => p.toLowerCase().indexOf(prop.toLowerCase()) === 0); 31 | if (matches.length === 1) { 32 | const [match] = matches; 33 | // Sanitize/coerce value with the specified function. 34 | options[match] = validProps[match](value); 35 | } 36 | else if (matches.length > 1) { 37 | errors.push(`Ambiguous option "${prop}" specified (matches: ${matches.join(', ')}).`); 38 | } 39 | else { 40 | errors.push(`Unknown option "${prop}" specified.`); 41 | } 42 | return true; 43 | } 44 | 45 | while (args.length > 0) { 46 | // Match arg starting with ' or " or containing =' or =" 47 | const {1: equals, 2: quote, index: eqIndex} = args[0].match(/(^|=)(['"])/) || []; 48 | let arg; 49 | // Arg contained a quote. 50 | if (quote) { 51 | // Find arg ending with matching quote. Don't look at the matched quote 52 | // part for the first argument. 53 | const re = new RegExp(quote + '$'); 54 | const offset = eqIndex + equals.length + 1; 55 | const endIndex = args.findIndex((a, i) => re.test(i === 0 ? a.slice(offset) : a)); 56 | // Matching arg was found. 57 | if (endIndex !== -1) { 58 | // Join all args between and including the start and end arg on space, 59 | // then remove trailing quote char. 60 | arg = args.splice(0, endIndex + 1).join(' ').slice(0, -1); 61 | // Remove starting quote char. 62 | arg = equals ? arg.slice(0, eqIndex + 1) + arg.slice(eqIndex + 2) : arg.slice(1); 63 | } 64 | } 65 | // If no quoted arg was found, use the next arg. 66 | if (!arg) { 67 | arg = args.shift(); 68 | } 69 | // If arg is an a=b style option, parse it. If it's an empty string, ignore 70 | // it. Otherwise add it to the remain array. 71 | if (!setOption(arg) && arg) { 72 | remain.push(arg); 73 | } 74 | } 75 | 76 | return { 77 | options, 78 | args: remain, 79 | errors, 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /Gruntfile.babel.js: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register'; 2 | import {grunt} from './Gruntfile'; 3 | 4 | const negate = p => `!${p}`; 5 | const exclude = a => Array.isArray(a) ? a.map(negate) : negate(a); 6 | 7 | const SOURCE_DIR = 'src'; 8 | const SOURCE_GLOB = '**/*.js'; 9 | const TEST_GLOB = '**/*.test.js'; 10 | const BUILD_DIR = 'dist'; 11 | 12 | const BUILD_FILES = ['*.js', 'tools/**/*.js']; 13 | const LEGACY_BUILD_FILES = ['Gruntfile.js']; 14 | const LINT_FILES = ['.eslintrc*', 'eslint/**/*']; 15 | const TEST_FILES = [`${SOURCE_DIR}/${TEST_GLOB}`]; 16 | const SRC_FILES = [`${SOURCE_DIR}/${SOURCE_GLOB}`, ...exclude(TEST_FILES)]; 17 | const EXAMPLE_FILES = ['examples/**/*.js']; 18 | 19 | const babel = { 20 | build: { 21 | expand: true, 22 | cwd: SOURCE_DIR, 23 | src: [SOURCE_GLOB, exclude(TEST_GLOB)], 24 | dest: BUILD_DIR, 25 | }, 26 | }; 27 | 28 | const clean = { 29 | build: BUILD_DIR, 30 | }; 31 | 32 | const watch = { 33 | src: { 34 | files: SRC_FILES, 35 | tasks: ['eslint:src', 'mochaTest', 'build'], 36 | }, 37 | examples: { 38 | files: EXAMPLE_FILES, 39 | tasks: ['eslint:examples'], 40 | }, 41 | test: { 42 | files: TEST_FILES, 43 | tasks: ['eslint:test', 'mochaTest'], 44 | }, 45 | build: { 46 | options: {reload: true}, 47 | files: [...BUILD_FILES, ...LEGACY_BUILD_FILES], 48 | tasks: ['eslint:build', 'eslint:legacy_build', 'mochaTest', 'build'], 49 | }, 50 | lint: { 51 | options: {reload: true}, 52 | files: LINT_FILES, 53 | tasks: ['eslint'], 54 | }, 55 | }; 56 | 57 | const eslint = { 58 | src: { 59 | options: {configFile: '.eslintrc-es2015.yaml'}, 60 | src: SRC_FILES, 61 | }, 62 | examples: { 63 | options: {configFile: '.eslintrc-examples.yaml'}, 64 | src: EXAMPLE_FILES, 65 | }, 66 | test: { 67 | options: {configFile: '.eslintrc-mocha.yaml'}, 68 | src: TEST_FILES, 69 | }, 70 | legacy_build: { 71 | options: {configFile: '.eslintrc-es5.yaml'}, 72 | src: LEGACY_BUILD_FILES, 73 | }, 74 | build: { 75 | options: {configFile: '.eslintrc-es2015.yaml'}, 76 | src: [...BUILD_FILES, ...exclude(LEGACY_BUILD_FILES)], 77 | }, 78 | }; 79 | 80 | const mochaTest = { 81 | test: { 82 | options: { 83 | reporter: 'spec', 84 | quiet: false, 85 | clearRequireCache: true, 86 | require: [ 87 | 'babel-register', 88 | 'tools/test-globals', 89 | ], 90 | }, 91 | src: TEST_FILES, 92 | }, 93 | }; 94 | 95 | grunt.initConfig({ 96 | clean, 97 | babel, 98 | eslint, 99 | mochaTest, 100 | watch, 101 | }); 102 | 103 | grunt.registerTask('test', ['eslint', 'mochaTest']); 104 | grunt.registerTask('build', ['clean', 'babel']); 105 | grunt.registerTask('default', ['watch']); 106 | 107 | grunt.loadNpmTasks('grunt-babel'); 108 | grunt.loadNpmTasks('grunt-contrib-clean'); 109 | grunt.loadNpmTasks('grunt-contrib-watch'); 110 | grunt.loadNpmTasks('grunt-eslint'); 111 | grunt.loadNpmTasks('grunt-mocha-test'); 112 | -------------------------------------------------------------------------------- /examples/slack-naive.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // SLACK_API_TOKEN= node examples/slack-naive.js 6 | // 7 | // Note that you'll first need a Slack API token, which you can get by going 8 | // to your team's settings page and creating a new bot: 9 | // https://my.slack.com/services/new/bot 10 | 11 | // ES2015 syntax: 12 | // import {processMessage, createMatcher} from 'chatter'; 13 | // ES5 syntax: 14 | // const chatter = require('chatter'); 15 | const chatter = require('..'); 16 | const processMessage = chatter.processMessage; 17 | const createMatcher = chatter.createMatcher; 18 | 19 | // import {RtmClient} from '@slack/client'; 20 | const slack = require('@slack/client'); 21 | const RtmClient = slack.RtmClient; 22 | 23 | // ================ 24 | // message handlers 25 | // ================ 26 | 27 | // Respond to the word "hello". 28 | const helloHandler = (text, message) => { 29 | if (/hello/i.test(text)) { 30 | return `Hello to you too, <@${message.user}>!`; 31 | } 32 | return false; 33 | }; 34 | 35 | // Respond, but only if the message contains the word "lol". 36 | const lolHandler = text => { 37 | if (/lol/i.test(text)) { 38 | const newText = text.replace(/lol/ig, 'laugh out loud'); 39 | return `More like "${newText}" amirite`; 40 | } 41 | return false; 42 | }; 43 | 44 | // Matches "add" prefix, then splits the message text into an array and adds the 45 | // array items into a sum. 46 | const addMatcher = createMatcher({match: 'add'}, text => { 47 | const numbers = text.split(' '); 48 | const result = numbers.reduce((sum, n) => sum + Number(n), 0); 49 | return `${numbers.join(' + ')} = ${result}`; 50 | }); 51 | 52 | // Message handlers to be processed, in order. 53 | const messageHandlers = [ 54 | helloHandler, 55 | lolHandler, 56 | addMatcher, 57 | ]; 58 | 59 | // =============== 60 | // naive slack bot 61 | // =============== 62 | 63 | // Note that, while this example works, it's lacking a lot of useful 64 | // functionality. See slack-bot.js for a more robust example. 65 | 66 | // Create a basic RTM Slack bot. 67 | const rtmClient = new RtmClient(process.env.SLACK_API_TOKEN, {logLevel: 'error'}); 68 | 69 | // Log to console when the bot connects. 70 | rtmClient.on('open', () => { 71 | console.log('Bot connected.'); 72 | }); 73 | 74 | // Process message through message handlers with the chatter "processMessage" 75 | // function whenever the bot receives a new message. 76 | rtmClient.on('message', message => { 77 | const text = message.text; 78 | // Pass message text through all message handlers. 79 | processMessage(messageHandlers, text, message) 80 | // Handle response. 81 | .then(response => { 82 | if (response === false) { 83 | response = `Sorry, I don't understand "${text}".`; 84 | } 85 | // Send response to the same channel in which the message was received 86 | // (this could be a channel, group or direct message). 87 | const channel = message.channel; 88 | rtmClient.sendMessage(response, channel); 89 | }); 90 | }); 91 | 92 | // Connect! 93 | rtmClient.start(); 94 | -------------------------------------------------------------------------------- /examples/create-args-adjuster.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/create-args-adjuster.js 6 | 7 | const Promise = require('bluebird'); 8 | const chalk = require('chalk'); 9 | 10 | // ES2015 syntax: 11 | // import {processMessage, createCommand, createArgsAdjuster, normalizeMessage} from 'chatter'; 12 | // ES5 syntax: 13 | // const chatter = require('chatter'); 14 | const chatter = require('..'); 15 | const processMessage = chatter.processMessage; 16 | const createCommand = chatter.createCommand; 17 | const createArgsAdjuster = chatter.createArgsAdjuster; 18 | const normalizeMessage = chatter.normalizeMessage; 19 | 20 | // ================ 21 | // message handlers 22 | // ================ 23 | 24 | // Increments the counter and returns a string decribing the new state. 25 | const incrementCommand = createCommand({ 26 | name: 'increment', 27 | description: 'Increment the counter and show it.', 28 | usage: '', 29 | }, (message, state) => { 30 | const delta = Number(message); 31 | if (!message) { 32 | return false; 33 | } 34 | else if (isNaN(delta)) { 35 | return `Sorry, but "${message}" doesn't appear to be a number!`; 36 | } 37 | state.counter += delta; 38 | return `The counter is now at ${state.counter}.`; 39 | }); 40 | 41 | // Returns a message handler that encapsualates some state, and passes that 42 | // state into child commands as an argument. 43 | function getStatefulMessageHandler() { 44 | const state = {counter: 0}; 45 | return createArgsAdjuster({ 46 | adjustArgs(message) { 47 | return [message, state]; 48 | }, 49 | }, createCommand({ 50 | isParent: true, 51 | description: 'An exciting command, for sure.', 52 | }, [ 53 | incrementCommand, 54 | ])); 55 | } 56 | 57 | // ==================================== 58 | // process messages with processMessage 59 | // ==================================== 60 | 61 | function log(color, prefix, message) { 62 | message = message.replace(/(\n)/g, `$1${' '.repeat(prefix.length + 1)}`); 63 | console.log(chalk[color](`${prefix} ${message}`)); 64 | } 65 | 66 | function header(message) { 67 | log('cyan', '\n=====', message); 68 | } 69 | 70 | function simulate(messageHandler, message) { 71 | log('magenta', '\n[In] ', message); 72 | return processMessage(messageHandler, message) 73 | .then(response => { 74 | const text = response !== false ? normalizeMessage(response) : '-'; 75 | log('green', '[Out]', text); 76 | }) 77 | .then(() => Promise.delay(100)); 78 | } 79 | 80 | const firstStatefulHandler = getStatefulMessageHandler('first'); 81 | const secondStatefulHandler = getStatefulMessageHandler('second'); 82 | 83 | Promise.mapSeries([ 84 | () => header('firstStatefulHandler'), 85 | () => simulate(firstStatefulHandler, 'help'), 86 | () => simulate(firstStatefulHandler, 'help increment'), 87 | () => simulate(firstStatefulHandler, 'increment 1'), 88 | () => simulate(firstStatefulHandler, 'increment 2'), 89 | 90 | () => header('secondStatefulHandler'), 91 | () => simulate(secondStatefulHandler, 'increment 101'), 92 | () => simulate(secondStatefulHandler, 'increment 202'), 93 | 94 | () => header('firstStatefulHandler'), 95 | () => simulate(firstStatefulHandler, 'increment 3'), 96 | 97 | () => header('secondStatefulHandler'), 98 | () => simulate(secondStatefulHandler, 'increment 303'), 99 | ], f => f()); 100 | -------------------------------------------------------------------------------- /examples/create-command.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/create-command.js 6 | 7 | const Promise = require('bluebird'); 8 | const chalk = require('chalk'); 9 | 10 | // ES2015 syntax: 11 | // import {processMessage, normalizeMessage, createCommand, createParser} from 'chatter'; 12 | // ES5 syntax: 13 | // const chatter = require('chatter'); 14 | const chatter = require('..'); 15 | const processMessage = chatter.processMessage; 16 | const normalizeMessage = chatter.normalizeMessage; 17 | const createCommand = chatter.createCommand; 18 | const createParser = chatter.createParser; 19 | 20 | // ================ 21 | // message handlers 22 | // ================ 23 | 24 | // Command that adds args into a sum. If no args were specified, return false 25 | // to display usage information. If sum isn't a number, display a message. 26 | const addCommand = createCommand({ 27 | name: 'add', 28 | description: 'Adds some numbers.', 29 | usage: 'number [ number [ number ... ] ]', 30 | }, createParser(parsed => { 31 | const args = parsed.args; 32 | if (args.length === 0) { 33 | return false; 34 | } 35 | const result = args.reduce((sum, n) => sum + Number(n), 0); 36 | if (isNaN(result)) { 37 | return `Whoops! Are you sure those were all numbers?`; 38 | } 39 | return `${args.join(' + ')} = ${result}`; 40 | })); 41 | 42 | // Command that multiplies args into a product. If no args were specified, 43 | // return false to display usage information. If product isn't a number, 44 | // display a message. 45 | const multiplyCommand = createCommand({ 46 | name: 'multiply', 47 | description: 'Multiplies some numbers.', 48 | usage: 'number [ number [ number ... ] ]', 49 | }, createParser(parsed => { 50 | const args = parsed.args; 51 | if (args.length === 0) { 52 | return false; 53 | } 54 | const result = args.reduce((product, n) => product * Number(n), 1); 55 | if (isNaN(result)) { 56 | return `Whoops! Are you sure those were all numbers?`; 57 | } 58 | return `${args.join(' x ')} = ${result}`; 59 | })); 60 | 61 | // Parent command that provides a "help" command and fallback usage information. 62 | const rootCommand = createCommand({ 63 | isParent: true, 64 | description: 'Some example math commands.', 65 | }, [ 66 | addCommand, 67 | multiplyCommand, 68 | ]); 69 | 70 | // ==================================== 71 | // process messages with processMessage 72 | // ==================================== 73 | 74 | function log(color, prefix, message) { 75 | message = message.replace(/(\n)/g, `$1${' '.repeat(prefix.length + 1)}`); 76 | console.log(chalk[color](`${prefix} ${message}`)); 77 | } 78 | 79 | function simulate(messageHandler, message) { 80 | log('magenta', '\n[In] ', message); 81 | return processMessage(messageHandler, message) 82 | .then(response => { 83 | const text = response !== false ? normalizeMessage(response) : '-'; 84 | log('green', '[Out]', text); 85 | }) 86 | .then(() => Promise.delay(100)); 87 | } 88 | 89 | Promise.mapSeries([ 90 | () => simulate(rootCommand, 'hello'), 91 | () => simulate(rootCommand, 'help'), 92 | () => simulate(rootCommand, 'help add'), 93 | () => simulate(rootCommand, 'help add 3 4 5'), 94 | () => simulate(rootCommand, 'add 3 4 5'), 95 | () => simulate(rootCommand, 'multiply'), 96 | () => simulate(rootCommand, 'multiply three four five'), 97 | () => simulate(rootCommand, 'help multiply'), 98 | () => simulate(rootCommand, 'multiply 3 4 5'), 99 | ], f => f()); 100 | -------------------------------------------------------------------------------- /src/util/queue.test.js: -------------------------------------------------------------------------------- 1 | import Queue from './queue'; 2 | 3 | const nop = () => {}; 4 | 5 | describe('util/queue', function() { 6 | 7 | it('should export the proper API', function() { 8 | expect(Queue).to.be.a('function'); 9 | }); 10 | 11 | describe('Queue', function() { 12 | 13 | describe('constructor', function() { 14 | const q = new Queue(); 15 | expect(q).to.be.instanceof(Queue); 16 | }); 17 | 18 | describe('enqueue', function() { 19 | 20 | it('should return the same promise each time while it is draining', function() { 21 | const q = new Queue({onDrain: nop}); 22 | const promise1 = q.enqueue('a', {}); 23 | const promise2 = q.enqueue('a', {}); 24 | const promise3 = q.enqueue('a', {}); 25 | expect(promise1).to.equal(promise2); 26 | expect(promise2).to.equal(promise3); 27 | }); 28 | 29 | it('should reset the returned promise after draining completes', function() { 30 | const q = new Queue({onDrain: nop}); 31 | const promise1 = q.enqueue('a', {}); 32 | const promise2 = promise1.then(() => q.enqueue('a', {})); 33 | promise1.then(() => { 34 | expect(promise2).to.not.equal(promise1); 35 | }); 36 | return Promise.all([ 37 | promise1, 38 | promise2, 39 | ]); 40 | }); 41 | 42 | it('should return per-id promises', function() { 43 | const q = new Queue({onDrain: nop}); 44 | const promise1 = q.enqueue('a', {}); 45 | const promise2 = q.enqueue('b', {}); 46 | const promise3 = q.enqueue('a', {}); 47 | const promise4 = q.enqueue('b', {}); 48 | expect(promise1).to.equal(promise3); 49 | expect(promise2).to.equal(promise4); 50 | expect(promise1).to.not.equal(promise2); 51 | }); 52 | 53 | }); 54 | 55 | describe('drain', function() { 56 | 57 | it('should pass id and data object into onDrain', function() { 58 | const result = []; 59 | const q = new Queue({ 60 | onDrain(id, data) { 61 | result.push(id, data); 62 | }, 63 | }); 64 | return Promise.all([ 65 | q.enqueue('a', {value: 1}), 66 | ]) 67 | .then(() => { 68 | expect(result).to.deep.equal(['a', {value: 1}]); 69 | }); 70 | }); 71 | 72 | it('should run onDrain for each enqueued item, in order', function() { 73 | const result = []; 74 | const q = new Queue({ 75 | onDrain(id, {value}) { 76 | result.push(id + value); 77 | }, 78 | }); 79 | return Promise.all([ 80 | q.enqueue('a', {value: 1}), 81 | q.enqueue('a', {value: 2}), 82 | q.enqueue('a', {value: 3}), 83 | ]) 84 | .then(() => { 85 | expect(result).to.deep.equal(['a1', 'a2', 'a3']); 86 | }); 87 | }); 88 | 89 | it('should run onDrain for each enqueued item, in order, per-id', function() { 90 | const result = []; 91 | const q = new Queue({ 92 | onDrain(id, {value}) { 93 | result.push(id + value); 94 | }, 95 | }); 96 | return Promise.all([ 97 | q.enqueue('a', {value: 1}), 98 | q.enqueue('a', {value: 2}), 99 | q.enqueue('b', {value: 4}), 100 | q.enqueue('c', {value: 7}), 101 | q.enqueue('a', {value: 3}), 102 | q.enqueue('b', {value: 5}), 103 | q.enqueue('b', {value: 6}), 104 | q.enqueue('c', {value: 8}), 105 | q.enqueue('c', {value: 9}), 106 | ]) 107 | .then(() => { 108 | expect(result).to.deep.equal([ 109 | 'a1', 'b4', 'c7', 110 | 'a2', 'b5', 'c8', 111 | 'a3', 'b6', 'c9', 112 | ]); 113 | }); 114 | }); 115 | 116 | }); 117 | 118 | }); 119 | 120 | }); 121 | -------------------------------------------------------------------------------- /examples/create-command-namespaced.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/create-command-namespaced.js 6 | 7 | const Promise = require('bluebird'); 8 | const chalk = require('chalk'); 9 | 10 | // ES2015 syntax: 11 | // import {processMessage, normalizeMessage, createCommand, createParser} from 'chatter'; 12 | // ES5 syntax: 13 | // const chatter = require('chatter'); 14 | const chatter = require('..'); 15 | const processMessage = chatter.processMessage; 16 | const normalizeMessage = chatter.normalizeMessage; 17 | const createCommand = chatter.createCommand; 18 | const createParser = chatter.createParser; 19 | 20 | // ================ 21 | // message handlers 22 | // ================ 23 | 24 | // Command that adds args into a sum. If no args were specified, return false 25 | // to display usage information. If sum isn't a number, display a message. 26 | const addCommand = createCommand({ 27 | name: 'add', 28 | description: 'Adds some numbers.', 29 | usage: 'number [ number [ number ... ] ]', 30 | }, createParser(parsed => { 31 | const args = parsed.args; 32 | if (args.length === 0) { 33 | return false; 34 | } 35 | const result = args.reduce((sum, n) => sum + Number(n), 0); 36 | if (isNaN(result)) { 37 | return `Whoops! Are you sure those were all numbers?`; 38 | } 39 | return `${args.join(' + ')} = ${result}`; 40 | })); 41 | 42 | // Command that multiplies args into a product. If no args were specified, 43 | // return false to display usage information. If product isn't a number, 44 | // display a message. 45 | const multiplyCommand = createCommand({ 46 | name: 'multiply', 47 | description: 'Multiplies some numbers.', 48 | usage: 'number [ number [ number ... ] ]', 49 | }, createParser(parsed => { 50 | const args = parsed.args; 51 | if (args.length === 0) { 52 | return false; 53 | } 54 | const result = args.reduce((product, n) => product * Number(n), 1); 55 | if (isNaN(result)) { 56 | return `Whoops! Are you sure those were all numbers?`; 57 | } 58 | return `${args.join(' x ')} = ${result}`; 59 | })); 60 | 61 | // Parent command that "namespaces" its sub-commands and provides a "help" 62 | // command and fallback usage information. 63 | const mathCommand = createCommand({ 64 | isParent: true, 65 | name: 'math', 66 | alias: 'math:', 67 | description: 'Math-related commands.', 68 | }, [ 69 | addCommand, 70 | multiplyCommand, 71 | ]); 72 | 73 | // ==================================== 74 | // process messages with processMessage 75 | // ==================================== 76 | 77 | function log(color, prefix, message) { 78 | message = message.replace(/(\n)/g, `$1${' '.repeat(prefix.length + 1)}`); 79 | console.log(chalk[color](`${prefix} ${message}`)); 80 | } 81 | 82 | function simulate(messageHandler, message) { 83 | log('magenta', '\n[In] ', message); 84 | return processMessage(messageHandler, message) 85 | .then(response => { 86 | const text = response !== false ? normalizeMessage(response) : '-'; 87 | log('green', '[Out]', text); 88 | }) 89 | .then(() => Promise.delay(100)); 90 | } 91 | 92 | Promise.mapSeries([ 93 | () => simulate(mathCommand, 'hello'), 94 | () => simulate(mathCommand, 'help'), 95 | () => simulate(mathCommand, 'math: hello'), 96 | () => simulate(mathCommand, 'math: help'), 97 | () => simulate(mathCommand, 'math help add'), 98 | () => simulate(mathCommand, 'math: help add 3 4 5'), 99 | () => simulate(mathCommand, 'math: add 3 4 5'), 100 | () => simulate(mathCommand, 'math multiply'), 101 | () => simulate(mathCommand, 'math multiply three four five'), 102 | () => simulate(mathCommand, 'math: help multiply'), 103 | () => simulate(mathCommand, 'math multiply 3 4 5'), 104 | ], f => f()); 105 | -------------------------------------------------------------------------------- /examples/message-handlers.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/message-handlers.js 6 | 7 | const Promise = require('bluebird'); 8 | const chalk = require('chalk'); 9 | 10 | // ES2015 syntax: 11 | // import {processMessage} from 'chatter'; 12 | // ES5 syntax: 13 | // const chatter = require('chatter'); 14 | const chatter = require('..'); 15 | const processMessage = chatter.processMessage; 16 | 17 | // Simulate a promise-yielding database abstraction. 18 | const db = { 19 | query() { 20 | return new Promise(resolve => { 21 | const stuff = ['stapler', 'robot', 'another robot', 'piano']; 22 | setTimeout(() => resolve(stuff), 100); 23 | }); 24 | }, 25 | }; 26 | 27 | // ================ 28 | // message handlers 29 | // ================ 30 | 31 | // Plain function. Receives a message and always returns a response. 32 | const alwaysRespond = message => `You said "${message}".`; 33 | 34 | // Plain function. Receives a message and sometimes returns a response, but 35 | // sometimes returns false. 36 | const sometimesRespond = message => { 37 | if (/lol/i.test(message)) { 38 | const newMessage = message.replace(/lol/ig, 'laugh out loud'); 39 | return `More like "${newMessage}" amirite`; 40 | } 41 | return false; 42 | }; 43 | 44 | // Array of functions. Messages will be sent through them in order, and the 45 | // first one that returns a non-false value wins! 46 | const multipleResponders = [ 47 | sometimesRespond, 48 | alwaysRespond, 49 | ]; 50 | 51 | // Object with a handleMessage method. Also returns a promise that yields a 52 | // value. 53 | const respondEventually = { 54 | handleMessage(message) { 55 | if (message === 'get stuff') { 56 | return db.query('SELECT * FROM STUFF').then(results => { 57 | const stuff = results.join(', '); 58 | return `Look at all the stuff: ${stuff}`; 59 | }); 60 | } 61 | return false; 62 | }, 63 | }; 64 | 65 | // Object with a handleMessage method. This is basically what you get when you 66 | // use createMatcher, so use that instead. The chatter "processMessage" 67 | // function is used to process the message through all children. 68 | const matchAndRunChildHandlers = { 69 | match: 'yo', 70 | children: [ 71 | sometimesRespond, 72 | alwaysRespond, 73 | ], 74 | getMatchRemainder(message) { 75 | if (message.indexOf(this.match) !== 0) { 76 | return false; 77 | } 78 | return message.slice(this.match.length).replace(/^\s+/, ''); 79 | }, 80 | handleMessage(message) { 81 | const remainder = this.getMatchRemainder(message); 82 | if (remainder === false) { 83 | return false; 84 | } 85 | else if (remainder === '') { 86 | return `You need to specify something after "yo".`; 87 | } 88 | return processMessage(this.children, remainder); 89 | }, 90 | }; 91 | 92 | 93 | // ==================================== 94 | // process messages with processMessage 95 | // ==================================== 96 | 97 | function log(color, prefix, message) { 98 | message = message.replace(/(\n)/g, `$1${' '.repeat(prefix.length + 1)}`); 99 | console.log(chalk[color](`${prefix} ${message}`)); 100 | } 101 | 102 | function header(message) { 103 | log('cyan', '\n=====', message); 104 | } 105 | 106 | function simulate(messageHandler, message) { 107 | log('magenta', '\n[In] ', message); 108 | return processMessage(messageHandler, message) 109 | .then(response => { 110 | const text = response !== false ? response : '-'; 111 | log('green', '[Out]', text); 112 | }) 113 | .then(() => Promise.delay(100)); 114 | } 115 | 116 | Promise.mapSeries([ 117 | () => header('alwaysRespond'), 118 | () => simulate(alwaysRespond, 'hello'), 119 | () => simulate(alwaysRespond, 'world'), 120 | 121 | () => header('sometimesRespond'), 122 | () => simulate(sometimesRespond, 'hello'), 123 | () => simulate(sometimesRespond, 'lol world'), 124 | 125 | () => header('multipleResponders'), 126 | () => simulate(multipleResponders, 'hello'), 127 | () => simulate(multipleResponders, 'lol world'), 128 | 129 | () => header('respondEventually'), 130 | () => simulate(respondEventually, 'get stuff'), 131 | () => simulate(respondEventually, 'get nothing'), 132 | 133 | () => header('matchAndRunChildHandlers'), 134 | () => simulate(matchAndRunChildHandlers, 'not yo'), 135 | () => simulate(matchAndRunChildHandlers, 'yo'), 136 | () => simulate(matchAndRunChildHandlers, 'yo hello'), 137 | () => simulate(matchAndRunChildHandlers, 'yo lol world'), 138 | ], f => f()); 139 | -------------------------------------------------------------------------------- /src/message-handler/delegate.test.js: -------------------------------------------------------------------------------- 1 | import createDelegate, {DelegatingMessageHandler, getHandlers} from './delegate'; 2 | 3 | const nop = () => {}; 4 | 5 | describe('message-handler/delegate', function() { 6 | 7 | it('should export the proper API', function() { 8 | expect(createDelegate).to.be.a('function'); 9 | expect(DelegatingMessageHandler).to.be.a('function'); 10 | expect(getHandlers).to.be.a('function'); 11 | }); 12 | 13 | describe('getHandlers', function() { 14 | 15 | it('should throw if no/invalid message handlers were specified', function() { 16 | // Valid 17 | expect(getHandlers({handleMessage: nop, other: true})).to.deep.equal(nop); 18 | expect(getHandlers(nop)).to.deep.equal(nop); 19 | expect(getHandlers({handleMessage: nop})).to.deep.equal(nop); 20 | expect(getHandlers([])).to.deep.equal([]); 21 | expect(getHandlers([nop])).to.deep.equal([nop]); 22 | expect(getHandlers([{handleMessage: nop}])).to.deep.equal([{handleMessage: nop}]); 23 | expect(getHandlers({}, nop)).to.deep.equal(nop); 24 | expect(getHandlers({}, {handleMessage: nop})).to.deep.equal({handleMessage: nop}); 25 | expect(getHandlers({}, [])).to.deep.equal([]); 26 | expect(getHandlers({}, [nop])).to.deep.equal([nop]); 27 | expect(getHandlers({}, [{handleMessage: nop}])).to.deep.equal([{handleMessage: nop}]); 28 | // Invalid 29 | expect(() => getHandlers()).to.throw(/missing.*message.*handler/i); 30 | expect(() => getHandlers(123)).to.throw(/missing.*message.*handler/i); 31 | expect(() => getHandlers({})).to.throw(/missing.*message.*handler/i); 32 | expect(() => getHandlers([123])).to.throw(/missing.*message.*handler/i); 33 | expect(() => getHandlers({}, {handleMessage: 123})).to.throw(/missing.*message.*handler/i); 34 | expect(() => getHandlers({}, 123)).to.throw(/missing.*message.*handler/i); 35 | expect(() => getHandlers({}, [123])).to.throw(/missing.*message.*handler/i); 36 | }); 37 | 38 | }); 39 | 40 | describe('createDelegate', function() { 41 | 42 | it('should return an instance of DelegatingMessageHandler', function() { 43 | const delegate = createDelegate(nop); 44 | expect(delegate).to.be.an.instanceof(DelegatingMessageHandler); 45 | }); 46 | 47 | }); 48 | 49 | describe('DelegatingMessageHandler', function() { 50 | 51 | describe('constructor', function() { 52 | 53 | it('should throw if no/invalid message handlers were specified', function() { 54 | // Valid 55 | expect(() => createDelegate({handleMessage: nop, other: true})).to.not.throw(); 56 | expect(() => createDelegate(nop)).to.not.throw(); 57 | expect(() => createDelegate({handleMessage: nop})).to.not.throw(); 58 | expect(() => createDelegate([])).to.not.throw(); 59 | expect(() => createDelegate([nop])).to.not.throw(); 60 | expect(() => createDelegate([{handleMessage: nop}])).to.not.throw(); 61 | expect(() => createDelegate({}, nop)).to.not.throw(); 62 | expect(() => createDelegate({}, {handleMessage: nop})).to.not.throw(); 63 | expect(() => createDelegate({}, [])).to.not.throw(); 64 | expect(() => createDelegate({}, [nop])).to.not.throw(); 65 | expect(() => createDelegate({}, [{handleMessage: nop}])).to.not.throw(); 66 | // Invalid 67 | expect(() => createDelegate()).to.throw(/missing.*message.*handler/i); 68 | expect(() => createDelegate(123)).to.throw(/missing.*message.*handler/i); 69 | expect(() => createDelegate({})).to.throw(/missing.*message.*handler/i); 70 | expect(() => createDelegate([123])).to.throw(/missing.*message.*handler/i); 71 | expect(() => createDelegate({}, {handleMessage: 123})).to.throw(/missing.*message.*handler/i); 72 | expect(() => createDelegate({}, 123)).to.throw(/missing.*message.*handler/i); 73 | expect(() => createDelegate({}, [123])).to.throw(/missing.*message.*handler/i); 74 | }); 75 | 76 | }); 77 | 78 | describe('handleMessage', function() { 79 | 80 | it('should return a promise that gets fulfilled', function() { 81 | const delegate = createDelegate(nop); 82 | return expect(delegate.handleMessage()).to.be.fulfilled(); 83 | }); 84 | 85 | it('should pass additional arguments into child handlers', function() { 86 | const handleMessage = [ 87 | { 88 | handleMessage(message, a, b) { 89 | return {message: `${message} ${a} ${b}`}; 90 | }, 91 | }, 92 | ]; 93 | const delegate = createDelegate({handleMessage}); 94 | return expect(delegate.handleMessage('foo', 1, 2)).to.become({message: 'foo 1 2'}); 95 | }); 96 | 97 | it('should reject if an exception is thrown in a child handler', function() { 98 | const handleMessage = [ 99 | { 100 | handleMessage(message) { 101 | throw new Error(`whoops ${message}`); 102 | }, 103 | }, 104 | ]; 105 | const delegate = createDelegate({handleMessage}); 106 | return expect(delegate.handleMessage('foo')).to.be.rejectedWith('whoops foo'); 107 | }); 108 | 109 | }); 110 | 111 | }); 112 | 113 | }); 114 | -------------------------------------------------------------------------------- /src/message-handler/command.test.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import createCommand, {CommandMessageHandler} from './command'; 3 | 4 | const nop = () => {}; 5 | 6 | describe('message-handler/command', function() { 7 | 8 | it('should export the proper API', function() { 9 | expect(createCommand).to.be.a('function'); 10 | expect(CommandMessageHandler).to.be.a('function'); 11 | }); 12 | 13 | describe('createCommand', function() { 14 | 15 | it('should return an instance of CommandMessageHandler', function() { 16 | const command = createCommand({name: 'foo', handleMessage: []}); 17 | expect(command).to.be.an.instanceof(CommandMessageHandler); 18 | }); 19 | 20 | }); 21 | 22 | describe('CommandMessageHandler', function() { 23 | 24 | describe('constructor', function() { 25 | 26 | it('should behave like DelegatingMessageHandler', function() { 27 | expect(() => createCommand({name: 'foo'}, nop)).to.not.throw(); 28 | expect(() => createCommand({name: 'foo'})).to.throw(/missing.*message.*handler/i); 29 | }); 30 | 31 | }); 32 | 33 | describe('handleMessage', function() { 34 | 35 | it('should return a promise that gets fulfilled', function() { 36 | const command = createCommand({name: 'foo'}, nop); 37 | return expect(command.handleMessage()).to.be.fulfilled(); 38 | }); 39 | 40 | it('should match messages starting with the command name', function() { 41 | const command = createCommand({name: 'foo'}, [ 42 | message => message === 'xyz' && 'xyz-match', 43 | message => message, 44 | ]); 45 | return Promise.all([ 46 | expect(command.handleMessage('foo')).to.become(''), 47 | expect(command.handleMessage('foo ')).to.become(''), 48 | expect(command.handleMessage('foo bar')).to.become('bar'), 49 | expect(command.handleMessage('foo bar')).to.become('bar'), 50 | expect(command.handleMessage('foo xyz')).to.become('xyz-match'), 51 | expect(command.handleMessage('foo-bar')).to.become(false), 52 | ]); 53 | }); 54 | 55 | it('should match messages starting with the command name or an alias', function() { 56 | const command = createCommand({name: 'foo', aliases: ['aaa', 'aaa:']}, [ 57 | message => message === 'xyz' && 'xyz-match', 58 | message => message, 59 | ]); 60 | return Promise.all([ 61 | // name 62 | expect(command.handleMessage('foo')).to.become(''), 63 | expect(command.handleMessage('foo ')).to.become(''), 64 | expect(command.handleMessage('foo bar')).to.become('bar'), 65 | expect(command.handleMessage('foo bar')).to.become('bar'), 66 | expect(command.handleMessage('foo xyz')).to.become('xyz-match'), 67 | // alias 1 68 | expect(command.handleMessage('aaa')).to.become(''), 69 | expect(command.handleMessage('aaa ')).to.become(''), 70 | expect(command.handleMessage('aaa bar')).to.become('bar'), 71 | expect(command.handleMessage('aaa bar')).to.become('bar'), 72 | expect(command.handleMessage('aaa xyz')).to.become('xyz-match'), 73 | // alias 2 74 | expect(command.handleMessage('aaa:')).to.become(''), 75 | expect(command.handleMessage('aaa: ')).to.become(''), 76 | expect(command.handleMessage('aaa: bar')).to.become('bar'), 77 | expect(command.handleMessage('aaa: bar')).to.become('bar'), 78 | expect(command.handleMessage('aaa: xyz')).to.become('xyz-match'), 79 | // messages that don't match the name or an alias just fail 80 | expect(command.handleMessage('aaa-bar')).to.become(false), 81 | expect(command.handleMessage('aaa:bar')).to.become(false), 82 | expect(command.handleMessage('xyz')).to.become(false), 83 | ]); 84 | }); 85 | 86 | it('should match messages starting with no name but with an alias', function() { 87 | const command = createCommand({aliases: ['aaa', 'aaa:']}, [ 88 | message => message === 'xyz' && 'xyz-match', 89 | message => message, 90 | ]); 91 | return Promise.all([ 92 | // alias 1 93 | expect(command.handleMessage('aaa')).to.become(''), 94 | expect(command.handleMessage('aaa ')).to.become(''), 95 | expect(command.handleMessage('aaa bar')).to.become('bar'), 96 | expect(command.handleMessage('aaa bar')).to.become('bar'), 97 | expect(command.handleMessage('aaa xyz')).to.become('xyz-match'), 98 | // alias 2 99 | expect(command.handleMessage('aaa:')).to.become(''), 100 | expect(command.handleMessage('aaa: ')).to.become(''), 101 | expect(command.handleMessage('aaa: bar')).to.become('bar'), 102 | expect(command.handleMessage('aaa: bar')).to.become('bar'), 103 | expect(command.handleMessage('aaa: xyz')).to.become('xyz-match'), 104 | // not matched 105 | expect(command.handleMessage('aaa-bar')).to.become('aaa-bar'), 106 | expect(command.handleMessage('aaa:bar')).to.become('aaa:bar'), 107 | expect(command.handleMessage('xyz')).to.become('xyz-match'), 108 | ]); 109 | }); 110 | 111 | }); 112 | 113 | }); 114 | 115 | }); 116 | -------------------------------------------------------------------------------- /eslint/eslint-defaults.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | 4 | # Possible Errors 5 | 6 | comma-dangle: 7 | - 2 8 | - always-multiline 9 | no-cond-assign: 10 | - 2 11 | - except-parens 12 | no-console: 0 13 | no-constant-condition: 1 14 | no-control-regex: 2 15 | no-debugger: 1 16 | no-dupe-args: 2 17 | no-dupe-keys: 2 18 | no-duplicate-case: 2 19 | no-empty-character-class: 2 20 | no-empty: 1 21 | no-ex-assign: 2 22 | no-extra-boolean-cast: 2 23 | no-extra-parens: 24 | - 2 25 | - functions 26 | no-extra-semi: 2 27 | no-func-assign: 2 28 | no-inner-declarations: 2 29 | no-invalid-regexp: 2 30 | no-irregular-whitespace: 2 31 | no-negated-in-lhs: 2 32 | no-obj-calls: 2 33 | no-regex-spaces: 2 34 | no-sparse-arrays: 2 35 | no-unreachable: 1 36 | use-isnan: 2 37 | valid-jsdoc: 38 | - 2 39 | - prefer: 40 | return: returns 41 | valid-typeof: 2 42 | no-unexpected-multiline: 2 43 | 44 | # Best Practices 45 | 46 | accessor-pairs: 2 47 | block-scoped-var: 2 48 | complexity: 0 49 | consistent-return: 2 50 | curly: 51 | - 2 52 | - all 53 | default-case: 2 54 | dot-notation: 2 55 | dot-location: 56 | - 2 57 | - property 58 | eqeqeq: 59 | - 2 60 | - "allow-null" 61 | guard-for-in: 0 62 | no-alert: 2 63 | no-caller: 2 64 | no-div-regex: 0 65 | no-else-return: 2 66 | no-eq-null: 0 67 | no-eval: 2 68 | no-extend-native: 0 69 | no-extra-bind: 2 70 | no-fallthrough: 2 71 | no-floating-decimal: 2 72 | no-implicit-coercion: 2 73 | no-implied-eval: 2 74 | no-invalid-this: 0 75 | no-iterator: 2 76 | no-labels: 77 | - 2 78 | - allowLoop: true 79 | no-lone-blocks: 2 80 | no-loop-func: 2 81 | no-multi-spaces: 2 82 | no-multi-str: 2 83 | no-native-reassign: 2 84 | no-new-func: 0 85 | no-new-wrappers: 2 86 | no-new: 2 87 | no-octal-escape: 2 88 | no-octal: 2 89 | no-param-reassign: 0 90 | no-process-env: 0 91 | no-proto: 2 92 | no-redeclare: 93 | - 2 94 | - builtinGlobals: false 95 | no-return-assign: 2 96 | no-script-url: 2 97 | no-self-compare: 2 98 | no-sequences: 2 99 | no-throw-literal: 2 100 | no-unused-expressions: 2 101 | no-useless-call: 2 102 | no-void: 2 103 | no-warning-comments: 1 104 | no-with: 2 105 | radix: 2 106 | vars-on-top: 0 107 | wrap-iife: 108 | - 2 109 | - inside 110 | yoda: 111 | - 2 112 | - never 113 | 114 | # Strict Mode 115 | 116 | strict: 117 | - 2 118 | - function 119 | 120 | # Variables 121 | 122 | init-declarations: 0 123 | no-catch-shadow: 0 124 | no-delete-var: 2 125 | no-label-var: 2 126 | no-shadow-restricted-names: 2 127 | no-shadow: 2 128 | no-undef-init: 2 129 | no-undef: 2 130 | no-undefined: 2 131 | no-unused-vars: 132 | - 1 133 | - vars: all 134 | args: none 135 | no-use-before-define: 2 136 | 137 | # Stylistic Issues 138 | 139 | array-bracket-spacing: 140 | - 2 141 | - never 142 | brace-style: 143 | - 2 144 | - stroustrup 145 | - allowSingleLine: true 146 | camelcase: 147 | - 2 148 | - properties: never 149 | comma-spacing: 150 | - 2 151 | - before: false 152 | after: true 153 | comma-style: 154 | - 2 155 | - last 156 | computed-property-spacing: 157 | - 2 158 | - never 159 | consistent-this: 160 | - 2 161 | - that 162 | eol-last: 0 163 | func-names: 0 164 | func-style: 0 165 | id-length: 0 166 | id-match: 0 167 | indent: 168 | - 2 169 | - 2 170 | - SwitchCase: 1 171 | key-spacing: 172 | - 2 173 | - beforeColon: false 174 | afterColon: true 175 | lines-around-comment: 176 | - 2 177 | - beforeBlockComment: true 178 | linebreak-style: 179 | - 2 180 | - unix 181 | max-len: 182 | - 1 183 | - 120 184 | - 4 185 | max-nested-callbacks: 0 186 | new-cap: 187 | - 2 188 | - newIsCap: true 189 | capIsNew: true 190 | new-parens: 0 191 | newline-after-var: 0 192 | no-array-constructor: 2 193 | no-continue: 0 194 | no-inline-comments: 0 195 | no-lonely-if: 2 196 | no-mixed-spaces-and-tabs: 2 197 | no-multiple-empty-lines: 198 | - 2 199 | - max: 2 200 | no-nested-ternary: 0 201 | no-new-object: 2 202 | no-spaced-func: 2 203 | no-ternary: 0 204 | no-trailing-spaces: 2 205 | no-underscore-dangle: 0 206 | no-unneeded-ternary: 2 207 | object-curly-spacing: 208 | - 2 209 | - never 210 | one-var: 211 | - 2 212 | - uninitialized: always 213 | initialized: never 214 | operator-assignment: 215 | - 2 216 | - always 217 | operator-linebreak: 218 | - 2 219 | - after 220 | padded-blocks: 0 221 | quote-props: 222 | - 2 223 | - as-needed 224 | - {keywords: false} 225 | quotes: 226 | - 2 227 | - single 228 | - avoid-escape 229 | semi-spacing: 230 | - 2 231 | - before: false 232 | after: true 233 | semi: 234 | - 2 235 | - always 236 | sort-vars: 0 237 | keyword-spacing: 238 | - 2 239 | - before: true 240 | after: true 241 | space-before-blocks: 242 | - 2 243 | - always 244 | space-before-function-paren: 245 | - 2 246 | - never 247 | space-in-parens: 248 | - 2 249 | - never 250 | space-infix-ops: 251 | - 2 252 | - int32Hint: false 253 | space-unary-ops: 254 | - 2 255 | - words: true 256 | nonwords: false 257 | spaced-comment: 258 | - 2 259 | - always 260 | wrap-regex: 0 261 | -------------------------------------------------------------------------------- /examples/bot-stateful.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/bot-stateful.js 6 | 7 | 'use strict'; // eslint-disable-line strict 8 | 9 | const Promise = require('bluebird'); 10 | const chalk = require('chalk'); 11 | 12 | // ES2015 syntax: 13 | // import {createBot, createCommand, createArgsAdjuster} from 'chatter'; 14 | // ES5 syntax: 15 | // const chatter = require('chatter'); 16 | const chatter = require('..'); 17 | const createBot = chatter.createBot; 18 | const createCommand = chatter.createCommand; 19 | const createArgsAdjuster = chatter.createArgsAdjuster; 20 | 21 | // =================== 22 | // timer utility class 23 | // =================== 24 | 25 | class Timer { 26 | constructor() { 27 | this.startTime = null; 28 | } 29 | wasStarted() { 30 | return Boolean(this.startTime); 31 | } 32 | start() { 33 | this.startTime = new Date(); 34 | } 35 | getElapsed() { 36 | if (!this.startTime) { 37 | return 'Timer not yet started.'; 38 | } 39 | const diff = new Date((new Date() - this.startTime)); 40 | const elapsed = diff.toISOString().slice(11, 19); 41 | return `Elapsed time: ${elapsed}.`; 42 | } 43 | } 44 | 45 | // ================ 46 | // message handlers 47 | // ================ 48 | 49 | const startHandler = createCommand({ 50 | name: 'start', 51 | description: 'Start or re-start the current timer.', 52 | }, (text, timer) => { 53 | const wasStarted = timer.wasStarted(); 54 | timer.start(); 55 | return `Timer ${wasStarted ? 're-' : ''}started.`; 56 | }); 57 | 58 | const elapsedHandler = createCommand({ 59 | name: 'elapsed', 60 | description: 'Get the elapsed time for the current timer.', 61 | }, (text, timer) => { 62 | return timer.getElapsed(); 63 | }); 64 | 65 | // ============= 66 | // the basic bot 67 | // ============= 68 | 69 | const myBot = createBot({ 70 | // This function must be specified. Even though not used here, this function 71 | // receives the id returned by getMessageHandlerCacheId, which can be used to 72 | // programatically return a different message handler. 73 | createMessageHandler(id) { 74 | // Create a new instance of the Timer class. 75 | const timer = new Timer(); 76 | // Create a message handler that first adjusts the args received from the 77 | // bot to include the timer instance, then calls the command message handler 78 | // with the adjusted arguments. While we're not using the original message 79 | // object in our message handlers, it's included for completeness' sake. 80 | const messageHandler = createArgsAdjuster({ 81 | adjustArgs(text, message) { 82 | return [text, timer, message]; 83 | }, 84 | }, createCommand({ 85 | isParent: true, 86 | description: 'Your own personal timer.', 87 | }, [ 88 | startHandler, 89 | elapsedHandler, 90 | ])); 91 | // Let the Bot know the message handler has state so it will be cached and 92 | // retrieved for future messages with the same id. Try commenting out this 93 | // line to see how Bot uses hasState. 94 | messageHandler.hasState = true; 95 | // Return the message handler. 96 | return messageHandler; 97 | }, 98 | // Get a cache id from the "message" object passed into onMessage. Try 99 | // returning a fixed value to show how the bot uses the return value to cache 100 | // message handlers. 101 | getMessageHandlerCacheId(message) { 102 | return message.user; 103 | }, 104 | // Normally, this would actually send a message to a chat service, but since 105 | // this is a simulation, just log the response to the console. 106 | sendResponse(message, text) { 107 | // Display the bot response. 108 | console.log(chalk.cyan(`[bot] ${text}`)); 109 | }, 110 | }); 111 | 112 | // ======================================== 113 | // simulate the bot interacting with a user 114 | // ======================================== 115 | 116 | const colorMap = {cowboy: 'magenta', joe: 'yellow'}; 117 | 118 | function simulate(user, text) { 119 | // Display the user message. 120 | console.log(chalk[colorMap[user]](`\n[${user}] ${text}`)); 121 | // Create a "message" object for the Bot's methods to use. 122 | const message = {user, text}; 123 | // Normally, this would be run when a message event is received from a chat 124 | // service, but in this case we'll call it manually. 125 | return myBot.onMessage(message).then(() => Promise.delay(1000)); 126 | } 127 | 128 | // Simulate a series of messages, in order. Note that multiple users can talk 129 | // simultaneously and the bot will keep track of their conversations separately 130 | // because their user name is used as the message handler cache id (see the 131 | // getMessageHandlerCacheId function). If both users were both talking in a 132 | // shared channel and the channel name was used as the cache id, the results 133 | // would be very different. 134 | Promise.mapSeries([ 135 | () => simulate('cowboy', 'hello'), 136 | () => simulate('cowboy', 'help'), 137 | () => simulate('cowboy', 'elapsed'), 138 | () => simulate('cowboy', 'start'), 139 | () => simulate('cowboy', 'elapsed'), 140 | () => simulate('joe', 'start'), 141 | () => simulate('joe', 'elapsed'), 142 | () => simulate('cowboy', 'elapsed'), 143 | () => simulate('cowboy', 'start'), 144 | () => simulate('cowboy', 'elapsed'), 145 | () => simulate('joe', 'elapsed'), 146 | ], f => f()); 147 | -------------------------------------------------------------------------------- /src/message-handler/command.js: -------------------------------------------------------------------------------- 1 | import {DelegatingMessageHandler} from './delegate'; 2 | import createMatcher from './matcher'; 3 | import createParser from './parser'; 4 | 5 | function formatCommand(...args) { 6 | // console.log(`formatCommand <${args.join('> <')}>`); 7 | return args.filter(Boolean).join(' '); 8 | } 9 | 10 | export class CommandMessageHandler extends DelegatingMessageHandler { 11 | 12 | constructor(options = {}, children) { 13 | super(options, children); 14 | const {name, aliases, usage, description, details, isParent} = options; 15 | this.isCommand = true; 16 | this.name = name; 17 | this.usage = usage; 18 | this.description = description; 19 | this.details = details; 20 | this.isParent = isParent; 21 | // Ensure children is an array. 22 | if (!Array.isArray(this.children)) { 23 | this.children = [this.children]; 24 | } 25 | // If this command has no name, it's the "top-level" command. Add a help 26 | // command and a fallback handler for usage information. 27 | if (isParent) { 28 | this.children = [ 29 | ...this.children, 30 | this.createHelpCommand(), 31 | this.createFallbackHandler(), 32 | ]; 33 | } 34 | // Keep track of this command's sub-commands for later use. 35 | this.subCommands = this.children.filter(c => c.isCommand); 36 | // If this command has a name or aliases, create a matching wrapper around 37 | // children that responds only to that name or an alias. 38 | if (name || aliases) { 39 | const items = !aliases ? [] : Array.isArray(aliases) ? aliases : [aliases]; 40 | if (name) { 41 | items.unshift(name); 42 | } 43 | const origChildren = this.children; 44 | this.children = items.map(item => createMatcher({match: item}, origChildren)); 45 | if (!name) { 46 | this.children.push(origChildren); 47 | } 48 | } 49 | } 50 | 51 | // Does this command have sub-commands? 52 | hasSubCommands() { 53 | return this.subCommands.length > 0; 54 | } 55 | 56 | // Search for a matching sub-command. If an exact match isn't found, return 57 | // the closest matching parent command. Returns the matched command, the full 58 | // name (ie. path) to that command, and whether or not it was an exact match. 59 | getMatchingSubCommand(search) { 60 | let command = this; // eslint-disable-line consistent-this 61 | let exact = true; 62 | const prefix = this.isParent ? this.name : ''; 63 | const subCommandNameParts = []; 64 | if (search) { 65 | const parts = search.split(/\s+/); 66 | for (let i = 0; i < parts.length; i++) { 67 | const subCommand = command.subCommands.find(({name}) => { 68 | if (name) { 69 | // Handle spaces in command names. 70 | for (let j = i; j < parts.length; j++) { 71 | if (parts.slice(i, j + 1).join(' ') === name) { 72 | i = j; 73 | return true; 74 | } 75 | } 76 | } 77 | return false; 78 | }); 79 | if (!subCommand) { 80 | exact = false; 81 | break; 82 | } 83 | command = subCommand; 84 | subCommandNameParts.push(command.name); 85 | } 86 | } 87 | return { 88 | command, 89 | prefix, 90 | exact, 91 | subCommandName: subCommandNameParts.join(' '), 92 | }; 93 | } 94 | 95 | // Display usage info for this command, given the specified command name. 96 | getUsage(command, prefix) { 97 | if (!this.name) { 98 | return false; 99 | } 100 | command = formatCommand(prefix, command); 101 | const usageFormatter = details => formatCommand(command, details); 102 | // If usage is a function, pass the command to it. 103 | const usage = typeof this.usage === 'function' ? this.usage(command) : 104 | // If usage is a string, format it. 105 | this.usage ? usageFormatter(this.usage) : 106 | // If usage isn't specified, but the command has sub-commands, format it. 107 | this.hasSubCommands() ? usageFormatter('') : 108 | // Otherwise just return the command, 109 | command; 110 | return `Usage: \`${usage}\``; 111 | } 112 | 113 | // Get help info for this command, given the specified arguments. 114 | helpInfo(search, command, prefix, exact) { 115 | const helpText = command ? ` for *${formatCommand(prefix, command)}*` : prefix ? ` for *${prefix}*` : ''; 116 | return [ 117 | !exact && `_Unknown command *${formatCommand(prefix, search)}*, showing help${helpText}._`, 118 | this.description, 119 | this.getUsage(command, prefix), 120 | this.hasSubCommands() && '*Commands:*', 121 | this.subCommands.map(c => `> *${c.name}* - ${c.description}`), 122 | this.details, 123 | ]; 124 | } 125 | 126 | // Create a top-level "help" command handler that displays help for the 127 | // closest matching command to what was specified. 128 | createHelpCommand() { 129 | return createCommand({ // eslint-disable-line no-use-before-define 130 | name: 'help', 131 | description: 'Get help for the specified command.', 132 | usage: '', 133 | handleMessage: createParser(({args}) => { 134 | const search = args.join(' '); 135 | const {command, subCommandName, prefix, exact} = this.getMatchingSubCommand(search); 136 | return command.helpInfo(search, subCommandName, prefix, exact); 137 | }), 138 | }); 139 | } 140 | 141 | // Get usage info for this command, given the specified arguments. 142 | usageInfo(message, command, prefix) { 143 | const isMatch = !message || Boolean(command); 144 | const usage = isMatch && this.getUsage(command, prefix); 145 | const help = command = formatCommand(prefix, 'help', command); 146 | return [ 147 | !isMatch && `Unknown command *${formatCommand(prefix, message)}*.`, 148 | usage, 149 | `${usage ? 'Or try' : 'Try'} *${help}* for more information.`, 150 | ]; 151 | } 152 | 153 | // Create a top-level "fallback" handler that displays usage info for the 154 | // closest matching command to what was specified. This handler only runs if 155 | // every other handler returns false. Ie. no other command matched. 156 | createFallbackHandler() { 157 | return message => { 158 | const {command, subCommandName, prefix} = this.getMatchingSubCommand(message); 159 | return command.usageInfo(message, subCommandName, prefix); 160 | }; 161 | } 162 | 163 | } 164 | 165 | export default function createCommand(...args) { 166 | return new CommandMessageHandler(...args); 167 | } 168 | -------------------------------------------------------------------------------- /src/util/response.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-undefined: 0 */ 2 | 3 | import { 4 | isMessage, 5 | isArrayOfMessages, 6 | normalizeMessage, 7 | normalizeMessages, 8 | normalizeResponse, 9 | } from './response'; 10 | 11 | describe('util/response', function() { 12 | 13 | it('should export the proper API', function() { 14 | expect(isMessage).to.be.a('function'); 15 | expect(isArrayOfMessages).to.be.a('function'); 16 | expect(normalizeMessage).to.be.a('function'); 17 | expect(normalizeMessages).to.be.a('function'); 18 | expect(normalizeResponse).to.be.a('function'); 19 | }); 20 | 21 | describe('isMessage', function() { 22 | 23 | it('should handle single values', function() { 24 | expect(isMessage('foo')).to.equal(true); 25 | expect(isMessage(123)).to.equal(true); 26 | expect(isMessage(null)).to.equal(true); 27 | expect(isMessage(undefined)).to.equal(true); 28 | expect(isMessage(false)).to.equal(true); 29 | expect(isMessage(true)).to.equal(false); 30 | expect(isMessage({})).to.equal(false); 31 | }); 32 | 33 | it('should handle arrays of values', function() { 34 | expect(isMessage([])).to.equal(true); 35 | expect(isMessage(['foo'])).to.equal(true); 36 | expect(isMessage([123])).to.equal(true); 37 | expect(isMessage([null])).to.equal(true); 38 | expect(isMessage([undefined])).to.equal(true); 39 | expect(isMessage([false])).to.equal(true); 40 | expect(isMessage([true])).to.equal(false); 41 | expect(isMessage([{}])).to.equal(false); 42 | }); 43 | 44 | it('should handle deeply-nested arrays of values', function() { 45 | expect(isMessage([['foo', [123, [[null], [[undefined], false]]]]])).to.equal(true); 46 | expect(isMessage([['foo', [123, [[null], [[undefined, {}], false]]]]])).to.equal(false); 47 | }); 48 | 49 | }); 50 | 51 | describe('isArrayOfMessages', function() { 52 | 53 | it('should handle simple values', function() { 54 | expect(isArrayOfMessages('foo')).to.equal(false); 55 | expect(isArrayOfMessages(123)).to.equal(false); 56 | expect(isArrayOfMessages(null)).to.equal(false); 57 | expect(isArrayOfMessages(undefined)).to.equal(false); 58 | expect(isArrayOfMessages(false)).to.equal(false); 59 | expect(isArrayOfMessages(true)).to.equal(false); 60 | 61 | expect(isArrayOfMessages([])).to.equal(true); 62 | expect(isArrayOfMessages(['foo'])).to.equal(true); 63 | expect(isArrayOfMessages(['foo', 123, null, undefined, false])).to.equal(true); 64 | expect(isArrayOfMessages([['foo', [123, [[null], [[undefined], false]]]]])).to.equal(true); 65 | 66 | expect(isArrayOfMessages(['foo', 123, {}])).to.equal(false); 67 | expect(isArrayOfMessages([['foo', [123, [[null], [[undefined, {}], false]]]]])).to.equal(false); 68 | }); 69 | 70 | }); 71 | 72 | describe('normalizeMessage', function() { 73 | 74 | it('should handle single values', function() { 75 | expect(normalizeMessage('foo')).to.equal('foo'); 76 | expect(normalizeMessage(123)).to.equal('123'); 77 | expect(normalizeMessage(null)).to.equal(''); 78 | expect(normalizeMessage(undefined)).to.equal(''); 79 | expect(normalizeMessage(false)).to.equal(''); 80 | }); 81 | 82 | it('should handle arrays of values', function() { 83 | expect(normalizeMessage([])).to.equal(''); 84 | expect(normalizeMessage(['foo'])).to.equal('foo'); 85 | expect(normalizeMessage([123])).to.equal('123'); 86 | expect(normalizeMessage([null])).to.equal(''); 87 | expect(normalizeMessage([undefined])).to.equal(''); 88 | expect(normalizeMessage([false])).to.equal(''); 89 | expect(normalizeMessage(['foo', 'bar'])).to.equal('foo\nbar'); 90 | expect(normalizeMessage(['foo', '', 'bar', '', '', 'baz'])).to.equal('foo\n\nbar\n\n\nbaz'); 91 | }); 92 | 93 | it('should handle deeply-nested arrays of values', function() { 94 | expect( 95 | normalizeMessage([['foo', [123, [[null], [[undefined], false]]]]]) 96 | ).to.equal('foo\n123'); 97 | expect( 98 | normalizeMessage([['foo', [123, [[null], ['', [undefined, 'bar'], false]]], 456]]) 99 | ).to.equal('foo\n123\n\nbar\n456'); 100 | }); 101 | 102 | }); 103 | 104 | describe('normalizeMessages', function() { 105 | 106 | it('should handle messages containing single values', function() { 107 | expect(normalizeMessages(['foo', 123, null])).to.deep.equal(['foo', '123']); 108 | expect(normalizeMessages([undefined, 'test', false, 123])).to.deep.equal(['test', '123']); 109 | }); 110 | 111 | it('should handle messages containing arrays of values', function() { 112 | expect(normalizeMessages([])).to.deep.equal([]); 113 | expect(normalizeMessages([ 114 | ['foo'], [123], [null], [undefined], 115 | ])).to.deep.equal([ 116 | 'foo', '123', '', '', 117 | ]); 118 | expect(normalizeMessages([ 119 | null, 120 | ['foo', 'bar'], 121 | undefined, 122 | ['foo', '', 'bar', '', '', 'baz'], 123 | ])).to.deep.equal([ 124 | 'foo\nbar', 125 | 'foo\n\nbar\n\n\nbaz', 126 | ]); 127 | }); 128 | 129 | it('should handle deeply-nested arrays of values', function() { 130 | expect( 131 | normalizeMessages([ 132 | [['foo', [123, [[null], [[undefined], false]]]]], 133 | [['foo', [123, [[null], ['', [undefined, 'bar'], false]]], 456]], 134 | ]) 135 | ).to.deep.equal([ 136 | 'foo\n123', 137 | 'foo\n123\n\nbar\n456', 138 | ]); 139 | }); 140 | 141 | }); 142 | 143 | describe('normalizeResponse', function() { 144 | 145 | beforeEach(function() { 146 | this.message = [['foo', [123, [[null], [[undefined], false]]], 'bar']]; 147 | this.normalized = 'foo\n123\nbar'; 148 | }); 149 | 150 | it('should handle a value that is just a message', function() { 151 | expect(normalizeResponse(this.message)).to.deep.equal([this.normalized]); 152 | }); 153 | 154 | it('should handle a value that is an object with a message property', function() { 155 | expect(normalizeResponse({ 156 | message: this.message, 157 | })).to.deep.equal([ 158 | this.normalized, 159 | ]); 160 | }); 161 | 162 | it('should handle a value that is an object with a messages property', function() { 163 | expect(normalizeResponse({ 164 | messages: [ 165 | this.message, 166 | this.message, 167 | ], 168 | })).to.deep.equal([ 169 | this.normalized, 170 | this.normalized, 171 | ]); 172 | }); 173 | 174 | it('should return false otherwise', function() { 175 | expect(normalizeResponse({})).to.equal(false); 176 | expect(normalizeResponse([{}])).to.equal(false); 177 | expect(normalizeResponse([{}, {}])).to.equal(false); 178 | }); 179 | 180 | }); 181 | 182 | }); 183 | -------------------------------------------------------------------------------- /examples/slack-bot.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // SLACK_API_TOKEN= node examples/slack-bot.js 6 | // 7 | // Note that you'll first need a Slack API token, which you can get by going 8 | // to your team's settings page and creating a new bot: 9 | // https://my.slack.com/services/new/bot 10 | 11 | const Promise = require('bluebird'); 12 | 13 | // ES2015 syntax: 14 | // import {createSlackBot, createCommand, createParser} from 'chatter'; 15 | // ES5 syntax: 16 | // const chatter = require('chatter'); 17 | const chatter = require('..'); 18 | const createSlackBot = chatter.createSlackBot; 19 | const createCommand = chatter.createCommand; 20 | const createParser = chatter.createParser; 21 | 22 | // import {RtmClient} from '@slack/client'; 23 | const slack = require('@slack/client'); 24 | const RtmClient = slack.RtmClient; 25 | const WebClient = slack.WebClient; 26 | const MemoryDataStore = slack.MemoryDataStore; 27 | 28 | // ================ 29 | // message handlers 30 | // ================ 31 | 32 | // Respond to the word "hello" 33 | const helloHandler = text => { 34 | if (/hello/i.test(text)) { 35 | return `Hello to you too!`; 36 | } 37 | return false; 38 | }; 39 | 40 | // Respond, but only if the message contains the word "lol". 41 | const lolHandler = text => { 42 | if (/lol/i.test(text)) { 43 | const newText = text.replace(/lol/ig, 'laugh out loud'); 44 | return `More like "${newText}" amirite`; 45 | } 46 | return false; 47 | }; 48 | 49 | // A command that says something after a delay. Be careful, though! Even though 50 | // this command yields false to indicate when it doesn't know how to handle the 51 | // message, it does so after the delay. If possible, return false immediately! 52 | const delayCommand = createCommand({ 53 | name: 'delay', 54 | description: `I'll say something after a delay.`, 55 | usage: '[yes | no]', 56 | }, text => { 57 | return new Promise(resolve => { 58 | setTimeout(() => { 59 | if (!text) { 60 | resolve(false); 61 | } 62 | else if (text.toLowerCase() === 'yes') { 63 | resolve('Awesome!'); 64 | } 65 | else { 66 | resolve('Bummer!'); 67 | } 68 | }, 250); 69 | }); 70 | }); 71 | 72 | // A command that echoes user input, as long as the user says something after 73 | // the command name! 74 | const echoCommand = createCommand({ 75 | name: 'echo', 76 | description: `I'm the echo command.`, 77 | usage: '', 78 | }, text => { 79 | if (text) { 80 | const isAmazing = text.toLowerCase() === 'amazing'; 81 | return { 82 | messages: [ 83 | `You said *${text}*.`, 84 | isAmazing ? 'Which is amazing...' : 'Which is great, and all...', 85 | isAmazing ? 'Literally!' : 'But not amazing.', 86 | ], 87 | }; 88 | } 89 | return false; 90 | }); 91 | 92 | // Command that adds args into a sum. If no args were specified, return false 93 | // to display usage information. If sum isn't a number, display a message. 94 | const addCommand = createCommand({ 95 | name: 'add', 96 | description: 'Adds some numbers.', 97 | usage: 'number [ number [ number ... ] ]', 98 | }, createParser(parsed => { 99 | const args = parsed.args; 100 | if (args.length === 0) { 101 | return false; 102 | } 103 | const result = args.reduce((sum, n) => sum + Number(n), 0); 104 | if (isNaN(result)) { 105 | return `Whoops! Are you sure those were all numbers?`; 106 | } 107 | return `${args.join(' + ')} = ${result}`; 108 | })); 109 | 110 | // Command that multiplies args into a product. If no args were specified, 111 | // return false to display usage information. If product isn't a number, 112 | // display a message. 113 | const multiplyCommand = createCommand({ 114 | name: 'multiply', 115 | description: 'Multiplies some numbers.', 116 | usage: 'number [ number [ number ... ] ]', 117 | }, createParser(parsed => { 118 | const args = parsed.args; 119 | if (args.length === 0) { 120 | return false; 121 | } 122 | const result = args.reduce((product, n) => product * Number(n), 1); 123 | if (isNaN(result)) { 124 | return `Whoops! Are you sure those were all numbers?`; 125 | } 126 | return `${args.join(' x ')} = ${result}`; 127 | })); 128 | 129 | // Parent math command that "namespaces" its sub-commands. 130 | const mathCommand = createCommand({ 131 | name: 'math', 132 | description: 'Math-related commands.', 133 | }, [ 134 | addCommand, 135 | multiplyCommand, 136 | ]); 137 | 138 | // ================ 139 | // proper slack bot 140 | // ================ 141 | 142 | const bot = createSlackBot({ 143 | // The bot name. 144 | name: 'Chatter Bot', 145 | // The getSlack function should return instances of the slack rtm and web 146 | // clients, like so. See https://github.com/slackhq/node-slack-sdk 147 | getSlack() { 148 | return { 149 | rtmClient: new RtmClient(process.env.SLACK_API_TOKEN, { 150 | dataStore: new MemoryDataStore(), 151 | autoReconnect: true, 152 | logLevel: 'error', 153 | }), 154 | webClient: new WebClient(process.env.SLACK_API_TOKEN), 155 | }; 156 | }, 157 | // The createMessageHandler function should return a top-level message handler 158 | // to handle each message. 159 | createMessageHandler(id, meta) { 160 | const channel = meta.channel; 161 | // Get actual bot name and aliases for the bot. If the bot's actual name in 162 | // Slack was "test", the aliases would be "test:" "@test" "@test:", allowing 163 | // the bot to respond to commands prefixed with any of them. If the channel 164 | // is a DM, the bot name will be null, and the actual name will be added to 165 | // the aliases list, so the bot can respond to both prefixed and non- 166 | // prefixed messages. Alternately, specify your own bot name and aliases! 167 | const nameObj = this.getBotNameAndAliases(channel.is_im); 168 | console.log(nameObj); 169 | // Create the top-level command. 170 | const rootCommand = createCommand({ 171 | isParent: true, 172 | name: nameObj.name, 173 | aliases: nameObj.aliases, 174 | description: `Hi, I'm the test bot!`, 175 | }, [ 176 | delayCommand, 177 | echoCommand, 178 | mathCommand, 179 | ]); 180 | // Direct message. 181 | if (channel.is_im) { 182 | return rootCommand; 183 | } 184 | // Public channel message. 185 | return [ 186 | rootCommand, 187 | // You can certainly combine commands and other message handlers, as long 188 | // as the command has a name. (If the command didn't have a name, it would 189 | // handle all messages, and message handlers after it would never run). 190 | helloHandler, 191 | lolHandler, 192 | ]; 193 | }, 194 | }); 195 | 196 | // Connect! 197 | bot.login(); 198 | -------------------------------------------------------------------------------- /src/bot.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import {overrideProperties} from './util/bot-helpers'; 3 | import {processMessage} from './util/process-message'; 4 | import {normalizeResponse} from './util/response'; 5 | 6 | export class Bot { 7 | 8 | constructor(options = {}) { 9 | const {createMessageHandler, verbose} = options; 10 | if (!createMessageHandler) { 11 | throw new TypeError('Missing required "createMessageHandler" option.'); 12 | } 13 | this.createMessageHandler = createMessageHandler; 14 | // Log more? 15 | this.verbose = verbose; 16 | // Cache of message handlers. 17 | this.handlerCache = {}; 18 | // Allow any of these options to override default Bot methods. 19 | overrideProperties(this, options, [ 20 | 'formatErrorMessage', 21 | 'log', 22 | 'logError', 23 | 'onMessage', 24 | 'ignoreMessage', 25 | 'getMessageHandlerCacheId', 26 | 'getMessageHandler', 27 | 'getMessageHandlerArgs', 28 | 'handleResponse', 29 | 'handleError', 30 | 'sendResponse', 31 | ]); 32 | } 33 | 34 | // Expose the processMessage function on Bot instances for convenience. 35 | processMessage(...args) { 36 | return processMessage(...args); 37 | } 38 | 39 | // String formatting helper functions. 40 | formatErrorMessage(message) { return `An error occurred: ${message}`; } 41 | 42 | // Overridable logger. 43 | log(...args) { 44 | console.log(...args); 45 | } 46 | 47 | // Overridable error logger. 48 | logError(...args) { 49 | console.error(...args); 50 | } 51 | 52 | // This is main "run loop" for the bot. When a message is received, it gets 53 | // passed into this function to be handled. 54 | onMessage(message) { 55 | return Promise.try(() => { 56 | // Get the message text and an optional array of arguments based on the 57 | // current message. This is especially useful when "message" is an object, 58 | // and not a text string. 59 | const messageHandlerArgs = this.getMessageHandlerArgs(message); 60 | // Abort if false was returned. This gives the getMessageHandlerArg 61 | // function the ability to pre-emptively ignore messages. 62 | if (messageHandlerArgs === false) { 63 | return [false]; 64 | } 65 | const {text, args = [message]} = messageHandlerArgs; 66 | // Get the id to retrieve a stateful message handler from the cache. 67 | const id = this.getMessageHandlerCacheId(...args); 68 | // Get a cached message handler via its id, or call createMessageHandler 69 | // to create a new one. 70 | const messageHandler = this.getMessageHandler(id, ...args); 71 | return [messageHandler, text, args]; 72 | }) 73 | .spread((messageHandler, text, args) => { 74 | // If messageHandlerArgs or getMessageHandler returned false, abort. 75 | if (messageHandler === false) { 76 | return false; 77 | } 78 | // Process text and additional args through the message handler. 79 | return this.processMessage(messageHandler, text, ...args) 80 | // Then handle the response. 81 | .then(response => this.handleResponse(message, response)); 82 | }) 83 | // If there was an error, handle that. 84 | .catch(error => this.handleError(message, error)); 85 | } 86 | 87 | // Return an object that defines the message text and any additional arguments 88 | // to be passed into message handlers (and the getMessageHandlerCacheId and 89 | // getMessageHandler functions). 90 | // 91 | // This function receives the "message" value passed into onMessage. 92 | // 93 | // By default, Bot expect "message" to be an object with, at the minimum, a 94 | // "text" property. If your message is in a different format, override this 95 | // function. Eg. If messages are just strings of text, return {text: message}. 96 | getMessageHandlerArgs(message) { 97 | return { 98 | text: message.text, 99 | args: [message], 100 | }; 101 | } 102 | 103 | // Return a value that will be used as an id to cache stateful message 104 | // handlers returned from the getMessageHandler function. 105 | // 106 | // This function receives the "args" returned from getMessageHandlerArgs. 107 | // 108 | // By default, Bot expects "message" to be an object with an "id" property. If 109 | // your message is in a different format, override this function. 110 | getMessageHandlerCacheId(message) { 111 | return message && message.id; 112 | } 113 | 114 | // Return a message handler, either from cache (if it exists) or created by 115 | // the createMessageHandler function. If the message handler is stateful (ie. 116 | // has a true "hasState" property) store it in the cache for later retrieval. 117 | // 118 | // This function receives the "args" returned from getMessageHandlerArgs. 119 | getMessageHandler(id, ...args) { 120 | if (this.handlerCache[id]) { 121 | return this.handlerCache[id]; 122 | } 123 | const messageHandler = this.createMessageHandler(id, ...args); 124 | if (!messageHandler) { 125 | return false; 126 | } 127 | if (messageHandler.hasState) { 128 | this.handlerCache[id] = messageHandler; 129 | } 130 | return messageHandler; 131 | } 132 | 133 | // If a message handler didn't throw an exception and wasn't rejected, run 134 | // this function. 135 | // 136 | // This function receives the original "message" and "response" value returned 137 | // or yielded by the message handler. Normalize the response into an array 138 | // containing zero or more messages, and pass each to the sendResponse method, 139 | // in order. 140 | handleResponse(message, response) { 141 | if (response === false) { 142 | return false; 143 | } 144 | const responses = normalizeResponse(response) || []; 145 | return Promise.all(responses.map(text => this.sendResponse(message, text))); 146 | } 147 | 148 | // If a message handler threw an exception or was otherwise rejected, run this 149 | // function. 150 | // 151 | // This function receives the original "message" and error object. Show the 152 | // error message in the same channel, group or DM from which the message 153 | // originated, and optionally log the error stack. 154 | handleError(message, error) { 155 | if (this.verbose) { 156 | this.logError(error.stack); 157 | } 158 | return this.sendResponse(message, this.formatErrorMessage(error.message)); 159 | } 160 | 161 | // Once the response (successful or not) has been handled, this message does 162 | // the actual "sending" of the response back to the chat service. Which means 163 | // it needs to be overridden. If not, it will just log to the console. 164 | // 165 | // This function receives the original "message" and the normalized message 166 | // or formatted error message as the "text" value. 167 | // 168 | // If sending the response runs asynchronously, this function should return a 169 | // promise that is resolved when the response has been sent. 170 | sendResponse(message, text) { 171 | this.log('sendResponse', text); 172 | } 173 | 174 | } 175 | 176 | export default function createBot(options) { 177 | return new Bot(options); 178 | } 179 | -------------------------------------------------------------------------------- /src/message-handler/conversation.test.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import createConversation, {ConversingMessageHandler} from './conversation'; 3 | 4 | const nop = () => {}; 5 | 6 | describe('message-handler/conversation', function() { 7 | 8 | it('should export the proper API', function() { 9 | expect(createConversation).to.be.a('function'); 10 | expect(ConversingMessageHandler).to.be.a('function'); 11 | }); 12 | 13 | describe('createConversation', function() { 14 | 15 | it('should return an instance of ConversingMessageHandler', function() { 16 | const conversation = createConversation(nop); 17 | expect(conversation).to.be.an.instanceof(ConversingMessageHandler); 18 | }); 19 | 20 | }); 21 | 22 | describe('ConversingMessageHandler', function() { 23 | 24 | describe('constructor', function() { 25 | 26 | it('should behave like DelegatingMessageHandler', function() { 27 | expect(() => createConversation(nop)).to.not.throw(); 28 | expect(() => createConversation()).to.throw(/missing.*message.*handler/i); 29 | }); 30 | 31 | }); 32 | 33 | describe('hasState', function() { 34 | 35 | it('should indicate that is has state (for things that care, like Bot)', function() { 36 | const conversation = createConversation(nop); 37 | expect(conversation.hasState).to.equal(true); 38 | }); 39 | 40 | }); 41 | 42 | describe('handleMessage', function() { 43 | 44 | beforeEach(function() { 45 | // Object-type message handler 46 | const dialog = { 47 | handleMessage(message, a, b) { 48 | return {message: `dialog ${message} ${a} ${b}`}; 49 | }, 50 | }; 51 | // Function-type message handler 52 | const deepDialog = function(message, a, b) { 53 | return { 54 | message: `deep-dialog ${message} ${a} ${b}`, 55 | dialog, 56 | }; 57 | }; 58 | // Object-type message handler 59 | const dialogThatThrows = { 60 | handleMessage(message, a, b) { 61 | throw new Error(`whoops ${message} ${a} ${b}`); 62 | }, 63 | }; 64 | // Function-type message handler 65 | const returnsFalse = () => false; 66 | // Object-type message handler 67 | const childThatReturnsDialog = { 68 | handleMessage(message, a, b) { 69 | return { 70 | message: `${message} ${a} ${b}`, 71 | dialog, 72 | }; 73 | }, 74 | }; 75 | // Function-type message handler 76 | const childThatReturnsNestedDialogs = (message, a, b) => { 77 | return { 78 | message: `${message} ${a} ${b}`, 79 | dialog: deepDialog, 80 | }; 81 | }; 82 | const childThatReturnsThrowingDialog = (message, a, b) => { 83 | return { 84 | message: `${message} ${a} ${b}`, 85 | dialog: dialogThatThrows, 86 | }; 87 | }; 88 | this.conversation = createConversation({ 89 | handleMessage: [returnsFalse, childThatReturnsDialog], 90 | }); 91 | this.deepConversation = createConversation({ 92 | handleMessage: [returnsFalse, childThatReturnsNestedDialogs], 93 | }); 94 | this.conversationObjectChild = createConversation({ 95 | handleMessage: childThatReturnsDialog, 96 | }); 97 | this.deepConversationFunctionChild = createConversation({ 98 | handleMessage: childThatReturnsNestedDialogs, 99 | }); 100 | this.conversationWithThrowingDialog = createConversation({ 101 | handleMessage: [returnsFalse, childThatReturnsThrowingDialog], 102 | }); 103 | }); 104 | 105 | it('should return a promise that gets fulfilled', function() { 106 | const conversation = createConversation(nop); 107 | return expect(conversation.handleMessage()).to.be.fulfilled(); 108 | }); 109 | 110 | it('should delegate to a returned message handler on the next message', function() { 111 | const conversation = this.conversation; 112 | return Promise.mapSeries([ 113 | () => expect(conversation.handleMessage('foo', 1, 2)).to.become({message: 'foo 1 2'}), 114 | () => expect(conversation.handleMessage('bar', 3, 4)).to.become({message: 'dialog bar 3 4'}), 115 | () => expect(conversation.handleMessage('baz', 5, 6)).to.become({message: 'baz 5 6'}), 116 | ], f => f()); 117 | }); 118 | 119 | it('should allow deeply nested dialogs / should support function child handlers', function() { 120 | const conversation = this.deepConversation; 121 | return Promise.mapSeries([ 122 | () => expect(conversation.handleMessage('foo', 1, 2)).to.become({message: 'foo 1 2'}), 123 | () => expect(conversation.handleMessage('bar', 3, 4)).to.become({message: 'deep-dialog bar 3 4'}), 124 | () => expect(conversation.handleMessage('baz', 5, 6)).to.become({message: 'dialog baz 5 6'}), 125 | () => expect(conversation.handleMessage('qux', 7, 8)).to.become({message: 'qux 7 8'}), 126 | ], f => f()); 127 | }); 128 | 129 | // The following two tests are the same as the previous two, except using 130 | // an explicit child instead of an array of children. 131 | it('should support a single child handler (object) instead of array', function() { 132 | const conversation = this.conversationObjectChild; 133 | return Promise.mapSeries([ 134 | () => expect(conversation.handleMessage('foo', 1, 2)).to.become({message: 'foo 1 2'}), 135 | () => expect(conversation.handleMessage('bar', 3, 4)).to.become({message: 'dialog bar 3 4'}), 136 | () => expect(conversation.handleMessage('baz', 5, 6)).to.become({message: 'baz 5 6'}), 137 | ], f => f()); 138 | }); 139 | 140 | it('should support a single child handler (function) instead of array', function() { 141 | const conversation = this.deepConversationFunctionChild; 142 | return Promise.mapSeries([ 143 | () => expect(conversation.handleMessage('foo', 1, 2)).to.become({message: 'foo 1 2'}), 144 | () => expect(conversation.handleMessage('bar', 3, 4)).to.become({message: 'deep-dialog bar 3 4'}), 145 | () => expect(conversation.handleMessage('baz', 5, 6)).to.become({message: 'dialog baz 5 6'}), 146 | () => expect(conversation.handleMessage('qux', 7, 8)).to.become({message: 'qux 7 8'}), 147 | ], f => f()); 148 | }); 149 | 150 | it('should clear the current dialog with .clearDialog', function() { 151 | const conversation = this.conversation; 152 | return Promise.mapSeries([ 153 | () => expect(conversation.handleMessage('foo', 1, 2)).to.become({message: 'foo 1 2'}), 154 | () => conversation.clearDialog(), 155 | () => expect(conversation.handleMessage('bar', 3, 4)).to.become({message: 'bar 3 4'}), 156 | ], f => f()); 157 | }); 158 | 159 | it('should clear the current dialog even if the dialog throws an exception', function() { 160 | const conversation = this.conversationWithThrowingDialog; 161 | return Promise.mapSeries([ 162 | () => expect(conversation.handleMessage('foo', 1, 2)).to.become({message: 'foo 1 2'}), 163 | () => expect(conversation.handleMessage('bar', 3, 4)).to.be.rejectedWith('whoops bar 3 4'), 164 | () => expect(conversation.handleMessage('baz', 5, 6)).to.become({message: 'baz 5 6'}), 165 | ], f => f()); 166 | }); 167 | 168 | }); 169 | 170 | }); 171 | 172 | }); 173 | 174 | -------------------------------------------------------------------------------- /src/util/args-parser.test.js: -------------------------------------------------------------------------------- 1 | import {parseArgs} from './args-parser'; 2 | 3 | describe('util/args-parser', function() { 4 | 5 | it('should export the proper API', function() { 6 | expect(parseArgs).to.be.a('function'); 7 | }); 8 | 9 | describe('parseArgs', function() { 10 | 11 | it('should use an input array as-is', function() { 12 | expect(parseArgs(['a', 'b', 'c'])).to.deep.equal({ 13 | options: {}, 14 | args: ['a', 'b', 'c'], 15 | errors: [], 16 | }); 17 | }); 18 | 19 | it('should not mutate the input array', function() { 20 | const args = ['a', 'b', 'c']; 21 | parseArgs(args); 22 | expect(args).to.deep.equal(['a', 'b', 'c']); 23 | }); 24 | 25 | it('should split an input string on space', function() { 26 | expect(parseArgs('a b c')).to.deep.equal({ 27 | options: {}, 28 | args: ['a', 'b', 'c'], 29 | errors: [], 30 | }); 31 | }); 32 | 33 | it('should properly handle quoted args', function() { 34 | // single quotes 35 | expect(parseArgs(`'a b c'`).args).to.deep.equal([`a b c`]); 36 | expect(parseArgs(`'a' b c`).args).to.deep.equal([`a`, `b`, `c`]); 37 | expect(parseArgs(`a 'b' c`).args).to.deep.equal([`a`, `b`, `c`]); 38 | expect(parseArgs(`a b 'c'`).args).to.deep.equal([`a`, `b`, `c`]); 39 | expect(parseArgs(`'a b c'`).args).to.deep.equal([`a b c`]); 40 | expect(parseArgs(`'a b' c`).args).to.deep.equal([`a b`, `c`]); 41 | expect(parseArgs(`a 'b c'`).args).to.deep.equal([`a`, `b c`]); 42 | // double quotes 43 | expect(parseArgs(`"a b c"`).args).to.deep.equal([`a b c`]); 44 | expect(parseArgs(`"a" b c`).args).to.deep.equal([`a`, `b`, `c`]); 45 | expect(parseArgs(`a "b" c`).args).to.deep.equal([`a`, `b`, `c`]); 46 | expect(parseArgs(`a b "c"`).args).to.deep.equal([`a`, `b`, `c`]); 47 | expect(parseArgs(`"a b c"`).args).to.deep.equal([`a b c`]); 48 | expect(parseArgs(`"a b" c`).args).to.deep.equal([`a b`, `c`]); 49 | expect(parseArgs(`a "b c"`).args).to.deep.equal([`a`, `b c`]); 50 | // nested quotes 51 | expect(parseArgs(`"'a b c'"`).args).to.deep.equal([`'a b c'`]); 52 | expect(parseArgs(`"a 'b' c"`).args).to.deep.equal([`a 'b' c`]); 53 | expect(parseArgs(`"a 'b c"`).args).to.deep.equal([`a 'b c`]); 54 | expect(parseArgs(`"'a b" "'c'"`).args).to.deep.equal([`'a b`, `'c'`]); 55 | expect(parseArgs(`'"a b c"'`).args).to.deep.equal([`"a b c"`]); 56 | expect(parseArgs(`'a "b" c'`).args).to.deep.equal([`a "b" c`]); 57 | expect(parseArgs(`'a "b c'`).args).to.deep.equal([`a "b c`]); 58 | expect(parseArgs(`'"a b' '"c"'`).args).to.deep.equal([`"a b`, `"c"`]); 59 | }); 60 | 61 | it('should ignore extra spaces', function() { 62 | expect(parseArgs(' a b c d ').args).to.deep.equal(['a', 'b', 'c', 'd']); 63 | expect(parseArgs('a b c d').args).to.deep.equal(['a', 'b', 'c', 'd']); 64 | }); 65 | 66 | it('should properly parse options', function() { 67 | const validProps = {foo: String, bar: Boolean, baz: Number}; 68 | let options, args; 69 | ({options, args} = parseArgs(`a b c d foo=1 bar=1 baz=1`, validProps)); 70 | expect(options).to.deep.equal({foo: `1`, bar: true, baz: 1}); 71 | expect(args).to.deep.equal([`a`, `b`, `c`, `d`]); 72 | 73 | ({options, args} = parseArgs(`a foo=1 b bar=1 c baz=1 d`, validProps)); 74 | expect(options).to.deep.equal({foo: `1`, bar: true, baz: 1}); 75 | expect(args).to.deep.equal([`a`, `b`, `c`, `d`]); 76 | 77 | ({options, args} = parseArgs(`bar=1 a baz=1 b c foo=1 d`, validProps)); 78 | expect(options).to.deep.equal({foo: `1`, bar: true, baz: 1}); 79 | expect(args).to.deep.equal([`a`, `b`, `c`, `d`]); 80 | 81 | ({options, args} = parseArgs(`a b c d Foo=1 bAr=1 baZ=1`, validProps)); 82 | expect(options).to.deep.equal({foo: `1`, bar: true, baz: 1}); 83 | expect(args).to.deep.equal([`a`, `b`, `c`, `d`]); 84 | 85 | ({options, args} = parseArgs(`a b c d foo="" bar="" baz=""`, validProps)); 86 | expect(options).to.deep.equal({foo: ``, bar: false, baz: 0}); 87 | expect(args).to.deep.equal([`a`, `b`, `c`, `d`]); 88 | 89 | ({options, args} = parseArgs(`a b c d foo=" " bar=" " baz=" "`, validProps)); 90 | expect(options).to.deep.equal({foo: ` `, bar: true, baz: 0}); 91 | expect(args).to.deep.equal([`a`, `b`, `c`, `d`]); 92 | 93 | ({options, args} = parseArgs(`a b c d foo=" a b " bar=" c " baz=" 123 "`, validProps)); 94 | expect(options).to.deep.equal({foo: ` a b `, bar: true, baz: 123}); 95 | expect(args).to.deep.equal([`a`, `b`, `c`, `d`]); 96 | 97 | ({options, args} = parseArgs(`a foo="1 'b'" bar='1 c' baz="1 d" e`, validProps)); 98 | expect(options).to.deep.equal({foo: `1 'b'`, bar: true, baz: NaN}); 99 | expect(args).to.deep.equal([`a`, `e`]); 100 | }); 101 | 102 | it('should complain about unknown options', function() { 103 | const validProps = {foo: String, bar: Boolean}; 104 | const {options, errors} = parseArgs(`a b c foo=1 bar=1 baz=1`, validProps); 105 | expect(options).to.deep.equal({foo: '1', bar: true}); 106 | expect(errors).to.have.length(1); 107 | expect(errors[0]).to.match(/unknown.+"baz"/i); 108 | }); 109 | 110 | it('should allow option abbreviation where not ambiguous', function() { 111 | const validProps = {foo: String, barf: Boolean, bazz: Number}; 112 | let options, errors; 113 | ({options} = parseArgs(`f=1`, validProps)); 114 | expect(options).to.deep.equal({foo: '1'}); 115 | 116 | ({options, errors} = parseArgs(`f=1 b=1`, validProps)); 117 | expect(options).to.deep.equal({foo: '1'}); 118 | expect(errors).to.have.length(1); 119 | expect(errors[0]).to.match(/ambiguous.+"b"/i); 120 | 121 | ({options, errors} = parseArgs(`f=1 ba=1`, validProps)); 122 | expect(options).to.deep.equal({foo: '1'}); 123 | expect(errors).to.have.length(1); 124 | expect(errors[0]).to.match(/ambiguous.+"ba"/i); 125 | 126 | ({options} = parseArgs(`f=1 bar=1`, validProps)); 127 | expect(options).to.deep.equal({foo: '1', barf: true}); 128 | 129 | ({options} = parseArgs(`F=1 bAR=1`, validProps)); 130 | expect(options).to.deep.equal({foo: '1', barf: true}); 131 | }); 132 | 133 | it('should properly handle quoted options', function() { 134 | const validProps = {a: String, d: String}; 135 | expect(parseArgs(`a="b" c d='f'`, validProps).options).to.deep.equal({a: `b`, d: `f`}); 136 | expect(parseArgs(`a="b c" d=f`, validProps).options).to.deep.equal({a: `b c`, d: `f`}); 137 | expect(parseArgs(`a="'b' c d=f"`, validProps).options).to.deep.equal({a: `'b' c d=f`}); 138 | expect(parseArgs(`a="'b c d=f'"`, validProps).options).to.deep.equal({a: `'b c d=f'`}); 139 | expect(parseArgs(`a=b=c d=f`, validProps).options).to.deep.equal({a: `b=c`, d: `f`}); 140 | }); 141 | 142 | it('should handle crazy examples', function() { 143 | expect(parseArgs(`foo 'bar baz' a=123 b="x y z = 456" "can't wait"`, {aaa: Number, bbb: String})) 144 | .to.deep.equal({ 145 | options: { 146 | aaa: 123, 147 | bbb: `x y z = 456`, 148 | }, 149 | args: [`foo`, `bar baz`, `can't wait`], 150 | errors: [], 151 | }); 152 | expect(parseArgs(` foo 'bar baz' a=123 b="x y z = 456" "can't wait" `, {aaa: Number, bbb: String})) 153 | .to.deep.equal({ 154 | options: { 155 | aaa: 123, 156 | bbb: `x y z = 456`, 157 | }, 158 | args: [`foo`, `bar baz`, `can't wait`], 159 | errors: [], 160 | }); 161 | }); 162 | }); 163 | 164 | }); 165 | -------------------------------------------------------------------------------- /examples/bot-conversation.js: -------------------------------------------------------------------------------- 1 | // If this syntax looks unfamiliar, don't worry, it's just JavaScript! 2 | // Learn more about ES2015 here: https://babeljs.io/docs/learn-es2015/ 3 | // 4 | // Run "npm install" and then test with this command in your shell: 5 | // node examples/bot-conversation.js 6 | 7 | const Promise = require('bluebird'); 8 | const chalk = require('chalk'); 9 | 10 | // ES2015 syntax: 11 | // import {createBot, createConversation, createMatcher, createParser} from 'chatter'; 12 | // ES5 syntax: 13 | // const chatter = require('chatter'); 14 | const chatter = require('..'); 15 | const createBot = chatter.createBot; 16 | const createConversation = chatter.createConversation; 17 | const createMatcher = chatter.createMatcher; 18 | const createParser = chatter.createParser; 19 | 20 | // ======================================= 21 | // "parent" message handler's sub-handlers 22 | // ======================================= 23 | 24 | // Respond to a message starting with "parse", then return response with the 25 | // parsed args. 26 | const parseHandler = createMatcher({match: 'parse'}, createParser((parsed, message) => { 27 | const args = parsed.args; 28 | const user = message.user; 29 | return `parseHandler received <${args.join('> <')}> from ${user}.`; 30 | })); 31 | 32 | // Respond to a message starting with "message", then return response with 33 | // the remainder of the message. 34 | const messageHandler = createMatcher({match: 'message'}, (text, message) => { 35 | const user = message.user; 36 | return `messageHandler received "${text}" from ${user}.`; 37 | }); 38 | 39 | // Respond to a message starting with "ask", then return response object with 40 | // message and "dialog" message handler that preempts handling of the next 41 | // message. 42 | const askHandler = createMatcher({ 43 | match: 'ask', 44 | handleMessage(text, message) { 45 | const user = message.user; 46 | return { 47 | messages: [ 48 | `That's great!`, 49 | `Wait a second...`, 50 | `Why do you want me to ask you a question, ${user}?`, 51 | ], 52 | dialog: dialogHandler, 53 | }; 54 | }, 55 | }); 56 | 57 | // The "dialog" message handler that's returned after "ask" is matched. 58 | function dialogHandler(text, message) { 59 | const user = message.user; 60 | return `I'm not sure "${text}" is a good reason, ${user}.`; 61 | } 62 | 63 | // Respond to a message starting with "choose", then return response object with 64 | // message and "dialog" message handler that preempts handling of the next 65 | // message. 66 | const chooseHandler = createMatcher({ 67 | match: 'choose', 68 | handleMessage(text, message) { 69 | const user = message.user; 70 | return { 71 | message: `Choose one of the following, ${user}: a, b, c or exit.`, 72 | dialog: getChooseHandlerChoices(['a', 'b', 'c'], choice => `Thank you for choosing "${choice}".`), 73 | }; 74 | }, 75 | }); 76 | 77 | // Once the "choose" handler has been matched, this function is called, which 78 | // returns (and keeps returning) a new set of message handlers that only match 79 | // a very specific set of messages. 80 | function getChooseHandlerChoices(choices, choiceHandler) { 81 | return [ 82 | // Abort if the user says "exit". 83 | createMatcher({match: 'exit'}, () => `Choose aborted.`), 84 | // Call the choiceHandler function if the message matches one of the 85 | // specified choices. This could also have been implemented as a series 86 | // of individual "matcher" handlers. 87 | message => { 88 | const choice = message.toLowerCase(); 89 | if (choices.find(c => c === choice)) { 90 | return choiceHandler(choice); 91 | } 92 | return false; 93 | }, 94 | // If none of the preceding handlers match, this handler returns the same 95 | // set of handlers again. 96 | message => ({ 97 | message: `I'm sorry, but "${message}" is an invalid choice, please try again.`, 98 | dialog: getChooseHandlerChoices(choices, choiceHandler), 99 | }), 100 | ]; 101 | } 102 | 103 | // ======================== 104 | // "parent" message handler 105 | // ======================== 106 | 107 | // Pass any message starting with "parent" to the child message handlers, with 108 | // the leading "parent" removed. 109 | const parentHandler = createMatcher({match: 'parent'}, [ 110 | parseHandler, 111 | messageHandler, 112 | askHandler, 113 | chooseHandler, 114 | // Create a "fallback" handler that always returns a message if none of the 115 | // preceding message handlers match (ie. they all return false) 116 | (text, message) => `Parent fallback received "${text}" from ${message.user}.`, 117 | ]); 118 | 119 | // ========================= 120 | // another top-level handler 121 | // ========================= 122 | 123 | // This handler throws an exception, which is caught by the bot. 124 | const whoopsHandler = createMatcher({ 125 | match: 'whoops', 126 | handleMessage() { 127 | throw new Error('Whoops error.'); 128 | }, 129 | }); 130 | 131 | // ============= 132 | // the basic bot 133 | // ============= 134 | 135 | const myBot = createBot({ 136 | // This function must be specified. Even though not used here, this function 137 | // receives the id returned by getMessageHandlerCacheId, which can be used to 138 | // programatically return a different message handler. 139 | createMessageHandler(id) { 140 | // Because a "conversation" message handler has state, it will be cached 141 | // for future use for messages with the same id. Try replacing the 142 | // createConversation function with just the the array of message handlers 143 | // to see how differently the bot behaves! 144 | return createConversation([ 145 | parentHandler, 146 | whoopsHandler, 147 | ]); 148 | }, 149 | // Get a cache id from the message object passed into onMessage. Try 150 | // returning a fixed value to show how the bot uses the return value to cache 151 | // message handlers. 152 | getMessageHandlerCacheId(message) { 153 | return message.user; 154 | }, 155 | // Normally, this would actually send a message to a chat service, but since 156 | // this is a simulation, just log the response to the console. 157 | sendResponse(message, text) { 158 | // Display the bot response. 159 | console.log(chalk.cyan(`[bot] ${text}`)); 160 | }, 161 | }); 162 | 163 | // ======================================== 164 | // simulate the bot interacting with a user 165 | // ======================================== 166 | 167 | const colorMap = {cowboy: 'magenta', joe: 'yellow'}; 168 | 169 | function simulate(user, text) { 170 | // Display the user message. 171 | console.log(chalk[colorMap[user]](`\n[${user}] ${text}`)); 172 | // Create a "message" object for the Bot's methods to use. 173 | const message = {user, text}; 174 | // Normally, this would be run when a message event is received from a chat 175 | // service, but in this case we'll call it manually. 176 | return myBot.onMessage(message).then(() => Promise.delay(1000)); 177 | } 178 | 179 | // Simulate a series of messages, in order. Note that multiple users can talk 180 | // simultaneously and the bot will keep track of their conversations separately 181 | // because their user name is used as the message handler cache id (see the 182 | // getMessageHandlerCacheId function). If both users were both talking in a 183 | // shared channel and the channel name was used as the cache id, the results 184 | // would be very different. 185 | Promise.mapSeries([ 186 | () => simulate('cowboy', 'parent message should be handled by messageHandler'), 187 | () => simulate('joe', 'parent parse should be parsed by parseHandler'), 188 | () => simulate('cowboy', 'whoops should throw an exception'), 189 | () => simulate('cowboy', 'parent ask'), 190 | () => simulate('joe', 'parent should be handled by the fallback handler'), 191 | () => simulate('joe', 'parent choose'), 192 | () => simulate('cowboy', 'i dunno'), 193 | () => simulate('cowboy', 'parent choose'), 194 | () => simulate('cowboy', 'parent ask'), 195 | () => simulate('joe', 'exit'), 196 | () => simulate('cowboy', 'a'), 197 | () => simulate('joe', 'xyz should not be handled by anything'), 198 | () => simulate('cowboy', 'parent should be handled by the fallback handler'), 199 | ], f => f()); 200 | -------------------------------------------------------------------------------- /src/slack/slack-bot.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import {Bot} from '../bot'; 3 | import {overrideProperties} from '../util/bot-helpers'; 4 | import {isMessage, normalizeResponse} from '../util/response'; 5 | import Queue from '../util/queue'; 6 | import {parseMessage} from './util/message-parser'; 7 | 8 | export class SlackBot extends Bot { 9 | 10 | constructor(options = {}) { 11 | super(options); 12 | const { 13 | slack, 14 | getSlack, 15 | name = 'Chatter Bot', 16 | icon = 'https://static.bocoup.com/chatter/logo.png', 17 | eventNames = ['open', 'error', 'message'], 18 | postMessageDelay = 250, 19 | } = options; 20 | if (!slack && !getSlack) { 21 | throw new TypeError('Missing required "slack" or "getSlack" option.'); 22 | } 23 | // Bot name and icon. 24 | this.name = name; 25 | this.icon = icon; 26 | // Either specify a slack object or a function that will be used to get one. 27 | this.slack = slack; 28 | this.getSlack = getSlack; 29 | // Slack rtm client event names to bind to. 30 | this.eventNames = eventNames; 31 | // Delay between messages sent via postMessage. 32 | this.postMessageDelay = postMessageDelay; 33 | // Allow any of these options to override default Bot methods. 34 | overrideProperties(this, options, [ 35 | 'formatOnOpen', 36 | 'formatOnError', 37 | 'login', 38 | 'onOpen', 39 | 'onError', 40 | 'postMessageOptions', 41 | 'postMessageActual', 42 | ]); 43 | // Create per-channel queues of messages to be sent. 44 | this.postMessageQueue = new Queue({ 45 | onDrain: this.postMessageActual.bind(this), 46 | }); 47 | } 48 | 49 | // Provide a bound-to-this-slack wrapper around the parseMessage utility 50 | // function. 51 | parseMessage(...args) { 52 | return parseMessage(this.slack, ...args); 53 | } 54 | 55 | // String formatting helper functions. 56 | formatErrorMessage(message) { return `An error occurred: \`${message}\``; } 57 | formatOnOpen({user, team}) { return `Connected to ${team.name} as ${user.name}.`; } 58 | formatOnError(args) { return `${this.name} error: ${JSON.stringify(args)}`; } 59 | 60 | // Return an object that defines the message text and an additional "meta" 61 | // argument containing a number of relevant properties, to be passed into 62 | // message handlers (and the getMessageHandlerCacheId and getMessageHandler 63 | // functions). 64 | // 65 | // This function receives a slack "message" object. 66 | getMessageHandlerArgs(message) { 67 | // Ignore bot messages. 68 | if (message.subtype === 'bot_message') { 69 | return false; 70 | } 71 | const origMessage = message; 72 | const channel = this.slack.rtmClient.dataStore.getChannelGroupOrDMById(message.channel); 73 | // Ignore non-message messages. 74 | if (message.type !== 'message') { 75 | return false; 76 | } 77 | // If the message was a "changed" message, get the underlying message. 78 | if (message.subtype === 'message_changed') { 79 | message = message.message; 80 | } 81 | // Ignore any message with a subtype or attachments. 82 | if (message.subtype || message.attachments) { 83 | return false; 84 | } 85 | const user = this.slack.rtmClient.dataStore.getUserById(message.user); 86 | const meta = { 87 | bot: this, 88 | slack: this.slack, 89 | message, 90 | origMessage, 91 | channel, 92 | user, 93 | }; 94 | return { 95 | text: message.text, 96 | args: [meta], 97 | }; 98 | } 99 | 100 | // Return a value that will be used as an id to cache stateful message 101 | // handlers returned from the getMessageHandler function. 102 | // 103 | // This function receives the "meta" object from getMessageHandlerArgs. and 104 | // returns the message.channel property, which is the channel / group / DM id. 105 | getMessageHandlerCacheId(meta) { 106 | return meta.message.channel; 107 | } 108 | 109 | // First, ensure the bot has a "slack" object, then bind event handlers and 110 | // start the bot. 111 | login() { 112 | if (!this.slack) { 113 | this.slack = this.getSlack(); 114 | if (!this.slack || typeof this.slack !== 'object') { 115 | throw new TypeError('The "getSlack" function must return an object.'); 116 | } 117 | } 118 | const slack = this.slack; 119 | if (!slack.rtmClient) { 120 | throw new TypeError('The "slack" object is missing a required "rtmClient" property.'); 121 | } 122 | else if (!slack.rtmClient.dataStore) { 123 | throw new TypeError('The "slack" object is missing a required "rtmClient.dataStore" property.'); 124 | } 125 | else if (!slack.webClient) { 126 | throw new TypeError('The "slack" object is missing a required "webClient" property.'); 127 | } 128 | // Bind event handlers to the slack rtm client. 129 | this.bindEventHandlers(this.eventNames); 130 | // Start the rtm client! 131 | this.slack.rtmClient.start(); 132 | // Make it chainable. 133 | return this; 134 | } 135 | 136 | // Bind whitelisted "foo"-type slack rtm events to "onFoo"-type bot methods. 137 | bindEventHandlers(events) { 138 | events.forEach(name => { 139 | const method = this[`on${name[0].toUpperCase()}${name.slice(1)}`]; 140 | if (method) { 141 | this.slack.rtmClient.on(name, method.bind(this)); 142 | } 143 | }); 144 | } 145 | 146 | // When the slack rtm client connects, log a message. 147 | onOpen() { 148 | const {dataStore, activeUserId, activeTeamId} = this.slack.rtmClient; 149 | const user = dataStore.getUserById(activeUserId); 150 | const team = dataStore.getTeamById(activeTeamId); 151 | this.log(this.formatOnOpen({user, team})); 152 | } 153 | 154 | // If a slack error is encountered, log an error. 155 | onError(...args) { 156 | this.logError(this.formatOnError(args)); 157 | } 158 | 159 | // After a message handler response has been normalized, send the response 160 | // text to the channel from where the message originated. 161 | sendResponse(message, text) { 162 | return this._postMessage(message.channel, text); 163 | } 164 | 165 | // Send an arbitrary message to an arbitrary slack channel, Returns a promise 166 | // that resolves after all queued messages for the given channelId have been 167 | // sent. 168 | // Usage: 169 | // postMessage(channelId, message) // message will be normalized and passed into postMessageOptions 170 | // postMessage(channelId, options) // options will be used instead of postMessageOptions 171 | postMessage(channelId, options) { 172 | if (isMessage(options)) { 173 | options = normalizeResponse(options)[0]; 174 | } 175 | return this._postMessage(channelId, options); 176 | } 177 | 178 | // For use internally. Doesn't call normalizeResponse since Bot#handleResponse 179 | // and SlackBot#postMessage will have already done that. 180 | _postMessage(channelId, options) { 181 | if (typeof options === 'string') { 182 | options = this.postMessageOptions(options); 183 | } 184 | // Create a per-channelId queue of responses to be sent. 185 | return this.postMessageQueue.enqueue(channelId, options); 186 | } 187 | 188 | // Get postMessage options. See the slack API documentation for more info: 189 | // https://api.slack.com/methods/chat.postMessage 190 | postMessageOptions(text) { 191 | return { 192 | as_user: false, 193 | username: this.name, 194 | icon_url: this.icon, 195 | text, 196 | unfurl_links: false, 197 | unfurl_media: false, 198 | }; 199 | } 200 | 201 | // For each response, call the slack web client postMessage API and then 202 | // pause briefly. This prevents flooding and allows the bot's responses to 203 | // feel well-paced. 204 | postMessageActual(channelId, options) { 205 | return this.slack.webClient.chat.postMessage(channelId, null, options) 206 | .then(() => Promise.delay(this.postMessageDelay)); 207 | } 208 | 209 | // Get the bot's name and a list of aliases suitable for use in a top-level 210 | // command message handler. If "isIm" is true, set "name" to null and add 211 | // the bot name to the list of aliases, so the bot will both respond to the 212 | // name (or any other aliases) but also to un-prefixed messages. 213 | getBotNameAndAliases(isIm = false) { 214 | const {activeUserId, dataStore} = this.slack.rtmClient; 215 | // Bot name. 216 | let name = dataStore.getUserById(activeUserId).name; 217 | // Aliases for a top-level bot command. 218 | const aliases = [ 219 | `${name}:`, 220 | `<@${activeUserId}>`, 221 | `<@${activeUserId}>:`, 222 | ]; 223 | if (isIm) { 224 | aliases.unshift(name); 225 | name = null; 226 | } 227 | return {name, aliases}; 228 | } 229 | 230 | } 231 | 232 | export default function createSlackBot(options) { 233 | return new SlackBot(options); 234 | } 235 | -------------------------------------------------------------------------------- /src/message-handler/matcher.test.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import createMatcher, {MatchingMessageHandler, matchStringOrRegex} from './matcher'; 3 | 4 | const nop = () => {}; 5 | 6 | describe('message-handler/matcher', function() { 7 | 8 | it('should export the proper API', function() { 9 | expect(createMatcher).to.be.a('function'); 10 | expect(MatchingMessageHandler).to.be.a('function'); 11 | }); 12 | 13 | describe('matchStringOrRegex', function() { 14 | 15 | it('should match entire message', function() { 16 | expect(matchStringOrRegex('foo', 'foo')).to.equal(''); 17 | expect(matchStringOrRegex('foo', 'foo1')).to.equal(false); 18 | expect(matchStringOrRegex('foo', '1foo')).to.equal(false); 19 | }); 20 | 21 | it('should match words at the beginning of the message', function() { 22 | expect(matchStringOrRegex('foo', 'foo bar')).to.equal('bar'); 23 | expect(matchStringOrRegex('foo', 'foo bar baz')).to.equal('bar baz'); 24 | expect(matchStringOrRegex('foo', 'foobar')).to.equal(false); 25 | expect(matchStringOrRegex('foo', 'foo-bar')).to.equal(false); 26 | expect(matchStringOrRegex('foo', 'foo:bar')).to.equal(false); 27 | expect(matchStringOrRegex('foo', 'foo: bar')).to.equal(false); 28 | expect(matchStringOrRegex('foo', 'bar foo')).to.equal(false); 29 | }); 30 | 31 | it('should trim leading spaces from the remainder', function() { 32 | expect(matchStringOrRegex('foo', 'foo ')).to.equal(''); 33 | expect(matchStringOrRegex('foo', 'foo bar')).to.equal('bar'); 34 | expect(matchStringOrRegex('foo', 'foo bar baz')).to.equal('bar baz'); 35 | }); 36 | 37 | it('should ignore case when matching via string', function() { 38 | expect(matchStringOrRegex('foo', 'FoO')).to.equal(''); 39 | expect(matchStringOrRegex('foo', 'Foo bar')).to.equal('bar'); 40 | expect(matchStringOrRegex('foo', 'FOO bar baz')).to.equal('bar baz'); 41 | expect(matchStringOrRegex('FOO', 'FoO')).to.equal(''); 42 | expect(matchStringOrRegex('fOO', 'Foo bar')).to.equal('bar'); 43 | expect(matchStringOrRegex('FoO', 'foo bar baz')).to.equal('bar baz'); 44 | }); 45 | 46 | it('should match via regex', function() { 47 | expect(matchStringOrRegex(/^/, 'foo')).to.equal(''); 48 | expect(matchStringOrRegex(/^foo/, 'foo')).to.equal(''); 49 | expect(matchStringOrRegex(/bar/, 'foo bar baz')).to.equal(''); 50 | }); 51 | 52 | it('should return the first captured capture group as the remainder when matching via regex', function() { 53 | let re = /^(?:foo|bar)/; 54 | expect(matchStringOrRegex(re, 'foo')).to.equal(''); 55 | expect(matchStringOrRegex(re, 'bar baz')).to.equal(''); 56 | re = /^(foo|bar)/; 57 | expect(matchStringOrRegex(re, 'foo')).to.equal('foo'); 58 | expect(matchStringOrRegex(re, 'bar baz')).to.equal('bar'); 59 | re = /^(?:foo-(\S+)|bar=(\S+)|(.*))/; 60 | expect(matchStringOrRegex(re, 'foo-123 bar')).to.equal('123'); 61 | expect(matchStringOrRegex(re, 'bar=456 qux')).to.equal('456'); 62 | expect(matchStringOrRegex(re, 'xyz abc')).to.equal('xyz abc'); 63 | re = /(.*)\s+bar(.*)/; 64 | expect(matchStringOrRegex(re, 'foo bar baz')).to.equal('foo'); 65 | re = /(?:(f.*)\s+)?bar\s+(.*)/; 66 | expect(matchStringOrRegex(re, 'foo bar baz')).to.equal('foo'); 67 | expect(matchStringOrRegex(re, 'goo bar baz')).to.equal('baz'); 68 | re = /(^)(foo)/; 69 | expect(matchStringOrRegex(re, 'foo')).to.equal(''); 70 | }); 71 | 72 | it('should escape regex special characters', function() { 73 | expect(matchStringOrRegex('+[]/-^$*.?()\\|', '')).to.equal(false); 74 | expect(matchStringOrRegex('\\w+', 'foo')).to.equal(false); 75 | expect(matchStringOrRegex('+', '+')).to.equal(''); 76 | expect(matchStringOrRegex('\\w+', '\\w+')).to.equal(''); 77 | }); 78 | 79 | }); 80 | 81 | describe('createMatcher', function() { 82 | 83 | it('should return an instance of MatchingMessageHandler', function() { 84 | const matcher = createMatcher({match: 'foo', handleMessage: []}); 85 | expect(matcher).to.be.an.instanceof(MatchingMessageHandler); 86 | }); 87 | 88 | }); 89 | 90 | describe('MatchingMessageHandler', function() { 91 | 92 | describe('constructor', function() { 93 | 94 | it('should behave like DelegatingMessageHandler', function() { 95 | expect(() => createMatcher({match: 'foo'}, nop)).to.not.throw(); 96 | expect(() => createMatcher({match: 'foo'})).to.throw(/missing.*message.*handler/i); 97 | }); 98 | 99 | it('should throw if no match option was specified', function() { 100 | expect(() => createMatcher(nop)).to.throw(/missing.*match/i); 101 | expect(() => createMatcher({}, nop)).to.throw(/missing.*match/i); 102 | expect(() => createMatcher({match: 'foo'}, nop)).to.not.throw(); 103 | expect(() => createMatcher({match() {}}, nop)).to.not.throw(); 104 | }); 105 | 106 | }); 107 | 108 | describe('handleMessage', function() { 109 | 110 | it('should return a promise that gets fulfilled', function() { 111 | const matcher = createMatcher({match: 'foo'}, nop); 112 | return expect(matcher.handleMessage()).to.be.fulfilled(); 113 | }); 114 | 115 | it('should accept a match string', function() { 116 | const handleMessage = (remainder, arg) => ({message: `${remainder} ${arg}`}); 117 | const matcher = createMatcher({match: 'foo', handleMessage}); 118 | return Promise.all([ 119 | expect(matcher.handleMessage('foo', 1)).to.become({message: ' 1'}), 120 | expect(matcher.handleMessage('foo bar', 1)).to.become({message: 'bar 1'}), 121 | expect(matcher.handleMessage('foo bar', 1)).to.become({message: 'bar 1'}), 122 | expect(matcher.handleMessage('foo-bar', 1)).to.become(false), 123 | ]); 124 | }); 125 | 126 | it('should accept a match regex', function() { 127 | const handleMessage = (remainder, arg) => ({message: `${remainder} ${arg}`}); 128 | const matcher = createMatcher({match: /^foo(?:$|\s+(.*))/, handleMessage}); 129 | return Promise.all([ 130 | expect(matcher.handleMessage('foo', 1)).to.become({message: ' 1'}), 131 | expect(matcher.handleMessage('foo bar', 1)).to.become({message: 'bar 1'}), 132 | expect(matcher.handleMessage('foo bar', 1)).to.become({message: 'bar 1'}), 133 | expect(matcher.handleMessage('foo-bar', 1)).to.become(false), 134 | ]); 135 | }); 136 | 137 | it('should accept a match function', function() { 138 | const handleMessage = (remainder, arg) => ({message: `${remainder} ${arg}`}); 139 | const match = message => { 140 | const [fullMatch, capture = ''] = message.match(/^foo(?:$|\s+(.*))/) || []; 141 | return fullMatch ? capture : false; 142 | }; 143 | const matcher = createMatcher({match, handleMessage}); 144 | return Promise.all([ 145 | expect(matcher.handleMessage('foo', 1)).to.become({message: ' 1'}), 146 | expect(matcher.handleMessage('foo bar', 1)).to.become({message: 'bar 1'}), 147 | expect(matcher.handleMessage('foo bar', 1)).to.become({message: 'bar 1'}), 148 | expect(matcher.handleMessage('foo-bar', 1)).to.become(false), 149 | ]); 150 | }); 151 | 152 | it('should only run child handlers on match / should return false on no match', function() { 153 | let i = 0; 154 | const handleMessage = () => { 155 | i++; 156 | return {message: 'yay'}; 157 | }; 158 | const matcher = createMatcher({match: 'foo', handleMessage}); 159 | return Promise.mapSeries([ 160 | () => expect(matcher.handleMessage('foo')).to.become({message: 'yay'}), 161 | () => expect(matcher.handleMessage('baz')).to.become(false), 162 | () => expect(i).to.equal(1), 163 | ], f => f()); 164 | }); 165 | 166 | it('should support function matching / should pass message and additional arguments into match fn', function() { 167 | const match = (message, arg) => { 168 | const [greeting, remainder] = message.split(' '); 169 | return greeting === 'hello' ? `the ${remainder} is ${arg}` : false; 170 | }; 171 | const handleMessage = (remainder, arg) => ({message: `${remainder}, ${arg}`}); 172 | const matcher = createMatcher({match, handleMessage}); 173 | return Promise.all([ 174 | expect(matcher.handleMessage('hello world', 'me')).to.become({message: 'the world is me, me'}), 175 | expect(matcher.handleMessage('hello universe', 'me')).to.become({message: 'the universe is me, me'}), 176 | expect(matcher.handleMessage('goodbye world', 'me')).to.become(false), 177 | expect(matcher.handleMessage('goodbye universe', 'me')).to.become(false), 178 | ]); 179 | }); 180 | 181 | it('should reject if the match option is invalid', function() { 182 | const matcher = createMatcher({match: 123, handleMessage() {}}); 183 | return expect(matcher.handleMessage('foo')).to.be.rejectedWith(/invalid.*match/i); 184 | }); 185 | 186 | it('should reject if the match function throws an exception', function() { 187 | const match = message => { throw new Error(`whoops ${message}`); }; 188 | const matcher = createMatcher({match, handleMessage() {}}); 189 | return expect(matcher.handleMessage('foo')).to.be.rejectedWith('whoops foo'); 190 | }); 191 | 192 | }); 193 | 194 | }); 195 | 196 | }); 197 | -------------------------------------------------------------------------------- /src/util/process-message.test.js: -------------------------------------------------------------------------------- 1 | /* eslint object-shorthand: 0 */ 2 | 3 | import Promise from 'bluebird'; 4 | import {callMessageHandler, isMessageHandlerOrHandlers, processMessage} from './process-message'; 5 | 6 | describe('util/process-message', function() { 7 | 8 | it('should export the proper API', function() { 9 | expect(callMessageHandler).to.be.a('function'); 10 | expect(isMessageHandlerOrHandlers).to.be.a('function'); 11 | expect(processMessage).to.be.a('function'); 12 | }); 13 | 14 | describe('callMessageHandler', function() { 15 | 16 | it('should throw if argument is not a function or object with a callMessageHandler method', function() { 17 | expect(() => callMessageHandler()).to.throw(/message handler/i); 18 | expect(() => callMessageHandler('whoops')).to.throw(/message handler/i); 19 | expect(() => callMessageHandler({})).to.throw(/message handler/i); 20 | expect(() => callMessageHandler(null)).to.throw(/message handler/i); 21 | }); 22 | 23 | it('should invoke a function directly', function() { 24 | const fn = (a, b) => a + b; 25 | expect(callMessageHandler(fn, 1, 2)).to.equal(3); 26 | }); 27 | 28 | it('should invoke an object handleMessage method', function() { 29 | const obj = { 30 | handleMessage(a, b) { 31 | return a + b; 32 | }, 33 | }; 34 | expect(callMessageHandler(obj, 1, 2)).to.equal(3); 35 | }); 36 | 37 | }); 38 | 39 | describe('isMessageHandlerOrHandlers', function() { 40 | 41 | it('should return true for message handler functions', function() { 42 | expect(isMessageHandlerOrHandlers(() => {})).to.equal(true); 43 | expect(isMessageHandlerOrHandlers(function() {})).to.equal(true); 44 | expect(isMessageHandlerOrHandlers(123)).to.equal(false); 45 | expect(isMessageHandlerOrHandlers(null)).to.equal(false); 46 | expect(isMessageHandlerOrHandlers(0)).to.equal(false); 47 | }); 48 | 49 | it('should return true for message handler objects', function() { 50 | expect(isMessageHandlerOrHandlers({handleMessage() {}})).to.equal(true); 51 | expect(isMessageHandlerOrHandlers({handleMessage: () => {}})).to.equal(true); 52 | expect(isMessageHandlerOrHandlers({handleMessage: function() {}})).to.equal(true); 53 | expect(isMessageHandlerOrHandlers({bloops: function() {}})).to.equal(false); 54 | expect(isMessageHandlerOrHandlers({})).to.equal(false); 55 | }); 56 | 57 | it('should return true for arrays comprised only of message handler functions or objects', function() { 58 | const f = () => {}; 59 | const o = {handleMessage() {}}; 60 | expect(isMessageHandlerOrHandlers([])).to.equal(true); 61 | expect(isMessageHandlerOrHandlers([f, o, [], f, [o, [[[], [f]], []], o], f])).to.equal(true); 62 | expect(isMessageHandlerOrHandlers([f, o, [o, f], f, [o, [f, o], f], f])).to.equal(true); 63 | expect(isMessageHandlerOrHandlers([f, o, [o, f], f, [o, [null, o], f], f])).to.equal(false); 64 | expect(isMessageHandlerOrHandlers([f, o, [o, f], f, [o, [f, o], 1], f])).to.equal(false); 65 | }); 66 | 67 | }); 68 | 69 | describe('processMessage', function() { 70 | 71 | it('should throw if handlers are invalid', function() { 72 | const f = () => { return false; }; 73 | const o = {handleMessage: f}; 74 | return Promise.all([ 75 | expect(processMessage()).to.be.rejectedWith(/message handler/i), 76 | expect(processMessage(null)).to.be.rejectedWith(/message handler/i), 77 | expect(processMessage([null])).to.be.rejectedWith(/message handler/i), 78 | expect(processMessage([null, f, o, [o, f]])).to.be.rejectedWith(/message handler/i), 79 | expect(processMessage([f, o, [o, null, f]])).to.be.rejectedWith(/message handler/i), 80 | expect(processMessage([])).to.be.fulfilled(), 81 | expect(processMessage(f)).to.be.fulfilled(), 82 | expect(processMessage(o)).to.be.fulfilled(), 83 | expect(processMessage([f, o])).to.be.fulfilled(), 84 | expect(processMessage([f, o, [o, f]])).to.be.fulfilled(), 85 | ]); 86 | }); 87 | 88 | it('should return a promise that gets fulfilled', function() { 89 | return expect(processMessage([])).to.be.fulfilled(); 90 | }); 91 | 92 | it('should support a single function handler', function() { 93 | const handler = (message, a, b) => ({message: `${message} ${a} ${b}`}); 94 | return expect(processMessage(handler, 'foo', 1, 2)).to.become({message: 'foo 1 2'}); 95 | }); 96 | 97 | it('should support a single object handler', function() { 98 | const handler = { 99 | handleMessage(message, a, b) { 100 | return {message: `${message} ${a} ${b}`}; 101 | }, 102 | }; 103 | return expect(processMessage(handler, 'foo', 1, 2)).to.become({message: 'foo 1 2'}); 104 | }); 105 | 106 | // Like the previous example, but handler returns a promise. 107 | it('should resolve promises yielded by handlers', function() { 108 | const handler = { 109 | handleMessage(message, a, b) { 110 | return Promise.resolve({message: `${message} ${a} ${b}`}); // THIS LINE IS DIFFERENT 111 | }, 112 | }; 113 | return expect(processMessage(handler, 'foo', 1, 2)).to.become({message: 'foo 1 2'}); 114 | }); 115 | 116 | it('should reject if an exception is thrown in a child handler', function() { 117 | const handlers = [ 118 | { 119 | handleMessage() { 120 | throw new Error('whoops'); 121 | }, 122 | }, 123 | ]; 124 | return expect(processMessage(handlers, 'foo', 1, 2)).to.be.rejectedWith('whoops'); 125 | }); 126 | 127 | describe('complex examples', function() { 128 | 129 | beforeEach(function() { 130 | this.memo = ''; 131 | this.getHandler = (id, retval) => (message, arg) => { 132 | this.memo += message + id + arg; 133 | return retval; 134 | }; 135 | }); 136 | 137 | it('should run handlers in order / should yield false if no children returned a non-false value', function() { 138 | const handlers = 'abcdefgh'.split('').map(s => this.getHandler(s, false)); 139 | const promise = processMessage(handlers, '<', '>'); 140 | return Promise.all([ 141 | expect(promise).to.become(false), 142 | promise.then(() => { 143 | expect(this.memo).to.equal(''); 144 | }), 145 | ]); 146 | }); 147 | 148 | it('should run nested arrays of handlers in order', function() { 149 | const getHandler = this.getHandler; 150 | const handlers = [ 151 | getHandler('a', false), 152 | [ 153 | getHandler('b', false), 154 | [ 155 | getHandler('c', false), 156 | [ 157 | getHandler('d', false), 158 | getHandler('e', false), 159 | ], 160 | getHandler('f', false), 161 | ], 162 | getHandler('g', false), 163 | ], 164 | getHandler('h', {message: 'done'}), 165 | ]; 166 | const promise = processMessage(handlers, '<', '>'); 167 | return Promise.all([ 168 | expect(promise).to.become({message: 'done'}), 169 | promise.then(() => { 170 | expect(this.memo).to.equal(''); 171 | }), 172 | ]); 173 | }); 174 | 175 | it('should allow handlers to return new handlers or arrays of handlers', function() { 176 | const getHandler = this.getHandler; 177 | const handlers = [ 178 | getHandler('a', false), 179 | [ 180 | getHandler('b', getHandler('bb', false)), 181 | [ 182 | getHandler('c', {handleMessage: getHandler('cc', false)}), 183 | [ 184 | getHandler('d', [ 185 | getHandler('d1', false), 186 | getHandler('d2', [ 187 | getHandler('d3', getHandler('d4', false)), 188 | ]), 189 | ]), 190 | getHandler('e', false), 191 | ], 192 | getHandler('f', false), 193 | ], 194 | getHandler('g', false), 195 | ], 196 | getHandler('h', {message: 'done'}), 197 | ]; 198 | const promise = processMessage(handlers, '<', '>'); 199 | return Promise.all([ 200 | expect(promise).to.become({message: 'done'}), 201 | promise.then(() => { 202 | expect(this.memo).to.equal(''); 203 | }), 204 | ]); 205 | }); 206 | 207 | // Like the previous example but handler 'd4' returns an actual message. 208 | it('should stop iterating when a non-false, non-handler message is received', function() { 209 | const getHandler = this.getHandler; 210 | const handlers = [ 211 | getHandler('a', false), 212 | [ 213 | getHandler('b', getHandler('bb', false)), 214 | [ 215 | getHandler('c', {handleMessage: getHandler('cc', false)}), 216 | [ 217 | getHandler('d', [ 218 | getHandler('d1', false), 219 | getHandler('d2', [ 220 | getHandler('d3', getHandler('d4', {message: 'early'})), // THIS LINE IS DIFFERENT 221 | ]), 222 | ]), 223 | getHandler('e', false), 224 | ], 225 | getHandler('f', false), 226 | ], 227 | getHandler('g', false), 228 | ], 229 | getHandler('h', {message: 'done'}), 230 | ]; 231 | const promise = processMessage(handlers, '<', '>'); 232 | return Promise.all([ 233 | expect(promise).to.become({message: 'early'}), 234 | promise.then(() => { 235 | expect(this.memo).to.equal(''); 236 | }), 237 | ]); 238 | }); 239 | 240 | // Like the previous example but handlers return promises. 241 | it('should allow handlers to return promises', function() { 242 | const getHandler = (id, retval) => (message, arg) => { 243 | this.memo += message + id + arg; 244 | return Promise.resolve(retval); 245 | }; 246 | const handlers = [ 247 | getHandler('a', false), 248 | [ 249 | getHandler('b', getHandler('bb', false)), 250 | [ 251 | getHandler('c', {handleMessage: getHandler('cc', false)}), 252 | [ 253 | getHandler('d', [ 254 | getHandler('d1', false), 255 | getHandler('d2', [ 256 | getHandler('d3', getHandler('d4', {message: 'early'})), 257 | ]), 258 | ]), 259 | getHandler('e', false), 260 | ], 261 | getHandler('f', false), 262 | ], 263 | getHandler('g', false), 264 | ], 265 | getHandler('h', {message: 'done'}), 266 | ]; 267 | const promise = processMessage(handlers, '<', '>'); 268 | return Promise.all([ 269 | expect(promise).to.become({message: 'early'}), 270 | promise.then(() => { 271 | expect(this.memo).to.equal(''); 272 | }), 273 | ]); 274 | }); 275 | 276 | }); 277 | 278 | }); 279 | 280 | }); 281 | -------------------------------------------------------------------------------- /assets/chatter-robot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 72 | 73 | 74 | 76 | 77 | 78 | 80 | 81 | 82 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 140 | 141 | 142 | 144 | 145 | 146 | 148 | 149 | 150 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 218 | 219 | 220 | 221 | 222 | 223 | 225 | 226 | 227 | 228 | 230 | 231 | 232 | 233 | 259 | 260 | 261 | 262 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 366 | 367 | 368 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chatter 2 | > A collection of useful primitives for creating interactive chat bots. 3 | 4 | 5 | 6 | [![NPM](https://nodei.co/npm/chatter.png)](https://nodei.co/npm/chatter/) 7 | 8 | [![Build Status](https://travis-ci.org/bocoup/chatter.svg?branch=master)](https://travis-ci.org/bocoup/chatter) 9 | [![Built with Grunt](https://cdn.gruntjs.com/builtwith.svg)](http://gruntjs.com/) 10 | 11 | [normalizeMessage]: #normalizeMessage 12 | [node]: https://nodejs.org/en/download/ 13 | 14 | ## Usage 15 | 16 | ``` 17 | npm install --save chatter 18 | ``` 19 | 20 | Tested in [Node][node] 4.x and 6.x. 21 | 22 | _Note: If the following code example syntax looks unfamiliar, don't worry, it's 23 | just JavaScript! Read [a detailed overview of ECMAScript 2015 24 | features](https://babeljs.io/docs/learn-es2015/) to learn more._ 25 | 26 | ### What is a bot? 27 | 28 | For the purposes of this documentation, a bot is an automated system that 29 | selectively responds to text messages with text responses. The bot may simply 30 | respond to messages, statelessly, or the bot may do more complex things, like 31 | keep track of ongoing conversations with users. 32 | 33 | The most basic bot looks something like this (note, this is pseudocode): 34 | 35 | ```js 36 | // Import the chat service's library. 37 | const ChatService = require('chat-service'); 38 | 39 | // Create a chat service instance with the relevant options. 40 | const myChatService = new ChatService(options); 41 | 42 | // When the chat service receives a message that it cares about, respond to it. 43 | myChatService.on('message', message => { 44 | // This is the code you'll spend most of your time writing. Ie, the bot: 45 | if (doICareAbout(message)) { 46 | myChatService.send(response); 47 | } 48 | }); 49 | 50 | // Actually connect to the chat service. 51 | myChatService.connect(); 52 | ``` 53 | 54 | Usually, all the behind-the-scenes work of connecting the bot to the remote 55 | service, handling reconnections, keeping track of state (what users the bot is 56 | currently talking to, what channels the bot is currently in) is done by a 57 | service-specific library. 58 | 59 | So, what's left to do? Well, as shown in the previous example, you'll need 60 | to write the code that determines if a given message warrants a response, and 61 | to then deliver that response to the user. 62 | 63 | This project aims to help with all that. 64 | 65 | ### Message handlers 66 | 67 | Since most bot code is centered around these two steps: 68 | 69 | 1. Testing an incoming message to see if it should be handled 70 | 2. Sending a response based on the incoming message 71 | 72 | It makes sense to introduce a primitive that does these things. That primitive 73 | is called a "message handler." 74 | 75 | The simplest message handler is a function that accepts a message argument and 76 | returns a response if it should respond, or `false` if it doesn't care about 77 | that message: 78 | 79 | ```js 80 | const lolHandler = message => { 81 | if (/lol/i.test(message)) { 82 | const newMessage = message.replace(/lol/ig, 'laugh out loud'); 83 | return `More like "${newMessage}" amirite`; 84 | } 85 | return false; 86 | }; 87 | 88 | lolHandler('what') // false 89 | lolHandler('lol what') // 'More like "laugh out loud what" amirite' 90 | ``` 91 | 92 | But what if a message handler needs to yield a response asynchronously, instead 93 | of returning a value immediately? It can, by returning a Promise: 94 | 95 | ```js 96 | const stuffHandler = message => { 97 | if (message === 'get stuff') { 98 | return db.query('SELECT * FROM STUFF').then(results => { 99 | const stuff = results.join(', '); 100 | return `Look at all the stuff: ${stuff}`; 101 | }); 102 | } 103 | return false; 104 | }; 105 | 106 | stuffHandler('huh') // false 107 | stuffHandler('get stuff') // Promise -> 'Look at all the stuff! ...' 108 | ``` 109 | 110 | But what do you do if you want to pass all messages through both `lolHandler` 111 | and `stuffHandler`? What if your bot needs to be able to respond to a dozen 112 | types of message in a dozen different ways? 113 | 114 | Just create an array of message handlers: 115 | 116 | ```js 117 | const messageHandlers = [ 118 | lolHandler, 119 | stuffHandler, 120 | ]; 121 | ``` 122 | 123 | Now, you've undoubtedly realized that functions that return values or promises 124 | and arrays behave quite differently. Without a helper function that knows how to 125 | iterate over that array, or wait for a promise to resolve, or both of those 126 | things, this won't make your job any easier. 127 | 128 | Fortunately, chatter gives you that helper function. 129 | 130 | ### Processing messages 131 | 132 | The `processMessage` function (that chatter exports) takes two arguments: 133 | 134 | 1. A message handler 135 | 2. A message 136 | 137 | This function understands that a message handler might be a function or an 138 | array of functions like the ones described above (or a few other possible 139 | things, which will be explained later). 140 | 141 | It takes the message handler and message you give it and intelligently processes 142 | them to produce a response (if any), and it returns a Promise that will be 143 | resolved with that response: 144 | 145 | ```js 146 | const processMessage = require('chatter').processMessage; 147 | 148 | // (See the previous examples for the definition of "messageHandlers") 149 | processMessage(messageHandlers, message).then(response => { 150 | // do something with response 151 | }); 152 | 153 | // An example: 154 | function simulate(message) { 155 | processMessage(messageHandlers, message).then(response => { 156 | if (response === false) { 157 | response = `Sorry, I don't understand "${message}".`; 158 | } 159 | console.log(response); 160 | }); 161 | } 162 | 163 | simulate('lol what') // Logs: More like "laugh out loud what" amirite 164 | simulate('get stuff') // Logs: Look at all the stuff! ... 165 | simulate('huh') // Logs: Sorry, I don't understand "huh". 166 | ``` 167 | 168 | Of course, instead of logging the response, your bot would be sending it back to 169 | the user who said the message or the channel it was said in, using the chat 170 | service library. But you get the idea. 171 | 172 | ### Message handlers, more specifically 173 | 174 | As far as the `processMessage` function is concerned, a message handler is 175 | a function, an object with a `handleMessage` method, or an array of any 176 | combination of those things. That array may contain other arrays. 177 | 178 | ```js 179 | const functionMessageHandler = message => { 180 | if (condition) { 181 | return response; 182 | } 183 | return false; 184 | }; 185 | 186 | const objectMessageHandler = { 187 | handleMessage(message) { 188 | if (condition) { 189 | return response; 190 | } 191 | return false; 192 | }, 193 | }; 194 | 195 | const arrayMessageHandler = [ 196 | functionMessageHandler, 197 | objectMessageHandler, 198 | ]; 199 | ``` 200 | 201 | A message handler function (or method) may return `false` or return a Promise 202 | that yields `false` to indicate that the message handler doesn't care about the 203 | message, and the next message handler (if any) should process the message. 204 | 205 | ```js 206 | const returnsFalseMessageHandler = function(message) { 207 | return false; 208 | }; 209 | 210 | const yieldsFalseMessageHandler = function(message) { 211 | return Promise.resolve(false); 212 | }; 213 | ``` 214 | 215 | A message handler may return or yield any other value, and if so, iteration will 216 | be stopped immediately and that value will be yielded. 217 | 218 | ```js 219 | const returnsValueMessageHandler = function(message) { 220 | return 'hello'; 221 | }; 222 | 223 | const yieldsValueMessageHandler = function(message) { 224 | return Promise.resolve('world'); 225 | }; 226 | ``` 227 | 228 | Also, a message handler may return another message handler (function, object or 229 | array) and those new message handlers will be processed in-line. 230 | 231 | As you can see, message handlers may be very simple, but may be composed in very 232 | creative ways. 233 | 234 | (See [message-handlers](./examples/message-handlers.js) for more examples) 235 | 236 | ### A naive bot using message handlers and processMessage 237 | 238 | Like the earlier [What is a bot?](#what-is-a-bot) example, this bot is 239 | pseudocode, but this time it uses message handlers and the `processMessage` 240 | helper function: 241 | 242 | ```js 243 | // Import the chat service's library. 244 | const ChatService = require('chat-service'); 245 | 246 | // Import the chatter processMessage helper function. 247 | const processMessage = require('chatter').processMessage; 248 | 249 | // Define your message handlers. 250 | const messageHandlers = [...]; 251 | 252 | // Create a chat service instance with the relevant options. 253 | const myChatService = new ChatService(options); 254 | 255 | // When the chat service receives a message that it cares about, respond to it. 256 | myChatService.on('message', message => { 257 | // The bot just became a whole lot more flexible: 258 | processMessage(messageHandlers, message).then(response => { 259 | if (response === false) { 260 | response = `Sorry, I don't understand "${message}".`; 261 | } 262 | myChatService.send(response); 263 | }); 264 | }); 265 | 266 | // Actually connect to the chat service. 267 | myChatService.connect(); 268 | ``` 269 | 270 | ### Additional included message handlers 271 | 272 | Because there are a number of common things message handlers need to do, a few 273 | message handler "creator" functions have been included to make creating common 274 | message handlers easier. 275 | 276 | #### createMatcher 277 | 278 | The `createMatcher` function creates a new message handler that only calls the 279 | specified message handler if the message matches. It accepts a `match` option, 280 | which is a string, regex or function, to match against the message. If a string 281 | is specified, it matches the beginning of the message. If a message is matched, 282 | the remainder of the message will be passed into the specified message handler: 283 | 284 | ```js 285 | const createMatcher = require('chatter').createMatcher; 286 | 287 | // Matches "add" prefix, then splits the message into an array and adds the 288 | // array items into a sum. 289 | const addMatcher = createMatcher({match: 'add'}, message => { 290 | const numbers = message.split(' '); 291 | const result = numbers.reduce((sum, n) => sum + Number(n), 0); 292 | return `${numbers.join(' + ')} = ${result}`; 293 | }); 294 | 295 | // Matches "multiply" prefix, then splits the message into an array and 296 | // multiplies the array items into a product. 297 | const multiplyMatcher = createMatcher({match: 'mult'}, message => { 298 | const numbers = message.split(' '); 299 | const result = numbers.reduce((product, n) => product * Number(n), 1); 300 | return `${numbers.join(' x ')} = ${result}`; 301 | }); 302 | 303 | // Parent message handler that "namespaces" its sub-handlers and provides a 304 | // fallback message if a sub-handler isn't matched. 305 | const mathMatcher = createMatcher({match: 'math'}, [ 306 | addMatcher, 307 | multiplyMatcher, 308 | message => `Sorry, I don't understand "${message}".`, 309 | ]); 310 | 311 | processMessage(mathMatcher, 'add 3 4 5') // Promise -> false 312 | processMessage(mathMatcher, 'math add 3 4 5') // Promise -> 3 + 4 + 5 = 12 313 | processMessage(mathMatcher, 'math mult 3 4 5') // Promise -> 3 x 4 x 5 = 60 314 | processMessage(mathMatcher, 'math sub 3 4 5') // Promise -> Sorry, I don't understand "sub 3 4 5". 315 | ``` 316 | 317 | See the [create-matcher](examples/create-matcher.js) example. 318 | 319 | #### createParser 320 | 321 | The `createParser` function creates a new message handler that calls the 322 | specified message handler, not with a message string, but with an object 323 | representing the "parsed" message. This is especially useful if you want to 324 | work with an array of words from the message, instead of just a string message. 325 | 326 | ```js 327 | const createParser = require('chatter').createParser; 328 | 329 | // Reduce the array of parsed args into a sum. 330 | const addHandler = createParser(parsed => { 331 | const args = parsed.args; 332 | const result = args.reduce((sum, num) => sum + Number(num), 0); 333 | return `${args.join(' + ')} = ${result}`; 334 | }); 335 | 336 | processMessage(addHandler, '1 2 3') // Promise -> 1 + 2 + 3 = 6 337 | processMessage(addHandler, '4 five 6') // Promise -> 4 + five + 6 = NaN 338 | ``` 339 | 340 | When `parseOptions` is defined, any options in the message specified like 341 | `option=value` will be parsed and processed via the defined function, and made 342 | available in `parsed.options` (any non-options args will still be available in 343 | `parsed.args`). As you can see, options may be abbreviated! 344 | 345 | ```js 346 | const parsingHandler = createParser({ 347 | parseOptions: { 348 | alpha: String, 349 | beta: Number, 350 | }, 351 | }, parsed => JSON.stringify(parsed.options)); 352 | 353 | processMessage(parsingHandler, 'a=1 b=2') // Promise -> {"alpha":"1","beta":2} 354 | ``` 355 | 356 | See the [create-parser](examples/create-parser.js) example. 357 | 358 | #### createCommand 359 | 360 | The `createCommand` function is meant to be used to create a nested tree of 361 | message handlers that each have a name, description and usage information, 362 | with an automatically-created `help` command and a fallback message handler. 363 | 364 | Like with the `createMatcher` `match` option, the `name` will be used to match 365 | the message, with the remainder of the message being passed into the specified 366 | message handler. The `name`, `description` and `usage` options will be used to 367 | display contextual help and usage information. 368 | 369 | Note that because the response from message handlers created with 370 | `createCommand` may return arrays, they should be normalized into a 371 | newline-joined string with the included [normalizeMessage] helper function. 372 | 373 | ```js 374 | const createCommand = require('chatter').createCommand; 375 | 376 | // Command that adds args into a sum. 377 | const addCommand = createCommand({ 378 | name: 'add', 379 | description: 'Adds some numbers.', 380 | usage: 'number [ number [ number ... ] ]', 381 | }, createParser(parsed => { 382 | const args = parsed.args; 383 | const result = args.reduce((sum, n) => sum + Number(n), 0); 384 | return `${args.join(' + ')} = ${result}`; 385 | })); 386 | 387 | // Command that multiplies args into a product. 388 | const multiplyCommand = createCommand({ 389 | name: 'multiply', 390 | description: 'Multiplies some numbers.', 391 | usage: 'number [ number [ number ... ] ]', 392 | }, createParser(parsed => { 393 | const args = parsed.args; 394 | const result = args.reduce((product, n) => product * Number(n), 1); 395 | return `${args.join(' x ')} = ${result}`; 396 | })); 397 | 398 | // Parent command that provides a "help" command and fallback usage information. 399 | const rootCommand = createCommand({ 400 | isParent: true, 401 | description: 'Some example math commands.', 402 | }, [ 403 | addCommand, 404 | multiplyCommand, 405 | ]); 406 | 407 | processMessage(rootCommand, 'hello').then(normalizeMessage); 408 | // Unknown command *hello*. 409 | // Try *help* for more information. 410 | 411 | processMessage(rootCommand, 'help').then(normalizeMessage); 412 | // Some example math commands. 413 | // *Commands:* 414 | // > *add* - Adds some numbers. 415 | // > *multiply* - Multiplies some numbers. 416 | // > *help* - Get help for the specified command. 417 | 418 | processMessage(rootCommand, 'help add').then(normalizeMessage); 419 | // Adds some numbers. 420 | // Usage: `add number [ number [ number ... ] ]` 421 | 422 | processMessage(rootCommand, 'add 3 4 5').then(normalizeMessage); 423 | // 3 + 4 + 5 = 12 424 | 425 | processMessage(rootCommand, 'multiply').then(normalizeMessage); 426 | // Usage: `multiply number [ number [ number ... ] ]` 427 | // Or try *help multiply* for more information. 428 | 429 | processMessage(rootCommand, 'multiply 3 4 5').then(normalizeMessage); 430 | // 3 x 4 x 5 = 60 431 | ``` 432 | 433 | See the [create-command](examples/create-command.js) and 434 | [create-command-namespaced](examples/create-command-namespaced.js) examples. 435 | 436 | #### createConversation 437 | 438 | The `createConversation` function creates a new message handler that calls the 439 | specified message handler, doing nothing of note until that message handler 440 | returns an object with a `dialog` property, which should be a new 441 | message handler. At that point, the new message handler is stored and used 442 | _instead of the originally-specified message handler_ to handle the next 443 | message. After that message, the message handler is reverted to the original, 444 | unless another `dialog` is specified, in which case that is used instead. 445 | 446 | Conversations can be used to create an interactive sequence of message handlers, 447 | and must be be cached on a per-conversation basis (usually per-channel or 448 | per-direct message), because of the need to keep track of the current dialog. 449 | 450 | ```js 451 | const createConversation = require('chatter').createConversation; 452 | 453 | const helloHandler = message => { 454 | return message.indexOf('hello') !== -1 ? 'Hello to you too!' : false; 455 | }; 456 | 457 | const askHandler = createMatcher({match: 'ask'}, () => { 458 | return { 459 | message: 'Why do you want me to ask you a question?', 460 | dialog(message) { 461 | return `I'm not sure "${message}" is a good reason.`; 462 | }, 463 | }; 464 | }); 465 | 466 | const chooseHandler = createMatcher({match: 'choose'}, () => { 467 | return { 468 | message: `Choose one of the following: a, b or c.`, 469 | dialog: handleChoices, 470 | }; 471 | }); 472 | 473 | const handleChoices = choice => { 474 | if (choice === 'a' || choice === 'b' || choice === 'c') { 475 | return `Thank you for choosing "${choice}".`; 476 | } 477 | return { 478 | message: `I'm sorry, but "${choice}" is not a valid choice. Try again.`, 479 | dialog: handleChoices, 480 | }; 481 | }; 482 | 483 | const conversationHandler = createConversation([ 484 | helloHandler, 485 | askHandler, 486 | chooseHandler, 487 | ]); 488 | 489 | function handleResponse(response) { 490 | if (response !== false) { 491 | console.log(response.message || response); 492 | } 493 | } 494 | 495 | processMessage(conversationHandler, 'ask').then(handleResponse); 496 | // Why do you want me to ask you a question? 497 | 498 | processMessage(conversationHandler, 'hello').then(handleResponse); 499 | // I'm not sure "hello" is a good reason. 500 | 501 | processMessage(conversationHandler, 'hello').then(handleResponse); 502 | // Hello to you too! 503 | 504 | processMessage(conversationHandler, 'choose').then(handleResponse); 505 | // Choose one of the following: a, b or c. 506 | 507 | processMessage(conversationHandler, 'hello').then(handleResponse); 508 | // I'm sorry, but "hello" is not a valid choice. Try again. 509 | 510 | processMessage(conversationHandler, 'b').then(handleResponse); 511 | // Thank you for choosing "b". 512 | 513 | processMessage(conversationHandler, 'hello').then(handleResponse); 514 | // Hello to you too! 515 | ``` 516 | 517 | See the [bot-conversation](examples/bot-conversation.js) example. 518 | 519 | #### createArgsAdjuster 520 | 521 | The `createArgsAdjuster` function creates a new message handler that calls the 522 | specified message handler with a different set of arguments than the message 523 | handler received. This is especially useful when you need to pass state from 524 | where a parent message handler is created into a child message handler. 525 | 526 | ```js 527 | const createArgsAdjuster = require('chatter').createArgsAdjuster; 528 | 529 | // Increments the counter and returns a string decribing the new state. 530 | const incrementCommand = createCommand({ 531 | name: 'increment', 532 | description: 'Increment the counter and show it.', 533 | }, (message, state) => { 534 | state.counter++; 535 | return `The counter is now at ${state.counter}.`; 536 | }); 537 | 538 | // Returns a message handler that encapsualates some state, and passes that 539 | // state into child commands as an argument. 540 | function getStatefulMessageHandler() { 541 | const state = {counter: 0}; 542 | return createArgsAdjuster({ 543 | adjustArgs(message) { 544 | return [message, state]; 545 | }, 546 | }, createCommand({ 547 | isParent: true, 548 | description: 'An exciting command, for sure.', 549 | }, [ 550 | incrementCommand, 551 | ])); 552 | } 553 | 554 | const firstStatefulHandler = getStatefulMessageHandler(); 555 | 556 | processMessage(firstStatefulHandler, 'increment') // Promise -> The counter is now at 1. 557 | processMessage(firstStatefulHandler, 'increment') // Promise -> The counter is now at 2. 558 | 559 | const secondStatefulHandler = getStatefulMessageHandler(); 560 | 561 | processMessage(secondStatefulHandler, 'increment') // Promise -> The counter is now at 1. 562 | processMessage(firstStatefulHandler, 'increment') // Promise -> The counter is now at 3. 563 | processMessage(secondStatefulHandler, 'increment') // Promise -> The counter is now at 2. 564 | ``` 565 | 566 | See the [create-args-adjuster](examples/create-args-adjuster.js) and 567 | [bot-stateful](examples/bot-stateful.js) examples. 568 | 569 | ### Creating a more robust bot 570 | 571 | As with message handlers, bot behaviors can get a little complex. As shown in 572 | the preceding [createConversation] and [createArgsAdjuster] examples, message 573 | handlers may have state. Additionally, the previous bot examples don't handle 574 | errors in a useful way or do anything to normalize responses, which means your 575 | message handlers may need extra code to help format multi-line responses. 576 | 577 | To that end, this pseudocode example bot is implemented using `createBot`: 578 | 579 | [createConversation]: #createconversation 580 | [createArgsAdjuster]: #createargsadjuster 581 | 582 | ```js 583 | // Import the chat service's library. 584 | const ChatService = require('chat-service'); 585 | 586 | // Import the chatter createBot function. 587 | const createBot = require('chatter').createBot; 588 | 589 | // Create a chat service instance with the relevant options. 590 | const myChatService = new ChatService(options); 591 | 592 | // Create the chatter bot. 593 | const myBot = createBot({ 594 | // Return message handler for this message. See the "createArgsAdjuster" 595 | // example for the definition of getStatefulMessageHandler. 596 | createMessageHandler(id) { 597 | const messageHandler = getStatefulMessageHandler(); 598 | // Let the bot know that the message handler has state, so it'll be cached. 599 | messageHandler.hasState = true; 600 | return messageHandler; 601 | }, 602 | // Get a cache id from the "message" object passed into onMessage. In this 603 | // example, each user gets their own stateful message handler, with its own 604 | // unique counter. 605 | getMessageHandlerCacheId(message) { 606 | return message.user; 607 | }, 608 | // If a message handler responded to a message, send the normalized text 609 | // response back to the user. 610 | sendResponse(message, text) { 611 | myChatService.getUser(message.user).send(text); 612 | }, 613 | }); 614 | 615 | // Whenever a chat service message is received, pass its user and text values 616 | // into the chatter bot. 617 | myChatService.on('message', message => { 618 | const user = message.userName; 619 | const text = message.messageText; 620 | return myBot.onMessage({user, text}); 621 | }); 622 | 623 | // Actually connect to the chat service. 624 | myChatService.connect(); 625 | 626 | // Simulated chat log: 627 | // test 628 | // (nothing happens) 629 | // help 630 | // An exciting command, for sure. 631 | // *Commands:* 632 | // > *increment* - Increment the counter and show it. 633 | // increment 634 | // The counter is now at 1. 635 | // increment 636 | // The counter is now at 2. 637 | // increment 638 | // The counter is now at 1. 639 | // increment 640 | // The counter is now at 3. 641 | // increment 642 | // The counter is now at 2. 643 | ``` 644 | 645 | See the [bot-stateful](examples/bot-stateful.js) and 646 | [bot-conversation](examples/bot-conversation.js) examples. 647 | 648 | ### Creating a Slack bot 649 | 650 | While the aforementioned [Bot](#creating-a-more-robust-bot) is generally useful, 651 | it's really meant to be a starting point from which other more specific bots may 652 | be derived. Which brings us to SlackBot. 653 | 654 | SlackBot contains all of the functionality of Bot, with some useful additional 655 | features like passing channel, user and slack information into message handlers, 656 | alowing message handlers to be chosen dynamically based on channel, group or dm, 657 | and automatically connecting the bot to the slack rtm & web clients. 658 | 659 | When you create a SlackBot, you pass in an instance of the Slack `RtmClient` and 660 | `WebClient`, and the connections between the bot and service are handled for you 661 | automatically. All you have to do is specify your message handlers (which can be 662 | done programmatically, as in the example below) and tell the bot to login: 663 | 664 | ```js 665 | // Import the official Slack client. 666 | const slack = require('@slack/client'); 667 | const RtmClient = slack.RtmClient; 668 | const WebClient = slack.WebClient; 669 | const MemoryDataStore = slack.MemoryDataStore; 670 | 671 | // Import the chatter createBot function. 672 | const createSlackBot = require('chatter').createSlackBot; 673 | 674 | const bot = createSlackBot({ 675 | // The bot name. 676 | name: 'Chatter Bot', 677 | // The getSlack function should return instances of the slack rtm and web 678 | // clients, like so. See https://github.com/slackhq/node-slack-sdk 679 | getSlack() { 680 | return { 681 | rtmClient: new RtmClient(process.env.SLACK_API_TOKEN, { 682 | dataStore: new MemoryDataStore(), 683 | autoReconnect: true, 684 | logLevel: 'error', 685 | }), 686 | webClient: new WebClient(process.env.SLACK_API_TOKEN), 687 | }; 688 | }, 689 | // Return message handler for this message. See the "createArgsAdjuster" 690 | // example for the definition of getStatefulMessageHandler. 691 | createMessageHandler(id, meta) { 692 | const channel = meta.channel; 693 | // In this example, the bot will only handle DMs and ignore public channels. 694 | if (channel.is_im) { 695 | const messageHandler = getStatefulMessageHandler(); 696 | // Let the bot know that the message handler has state, so it'll be cached. 697 | messageHandler.hasState = true; 698 | return messageHandler; 699 | } 700 | }, 701 | }); 702 | 703 | // Connect! 704 | bot.login(); 705 | ``` 706 | 707 | See the [slack-bot](examples/slack-bot.js) example. 708 | 709 | ### API 710 | 711 | #### Bot 712 | * `Bot` - The base bot class. 713 | * `createBot` - function that returns an instance of `Bot`. 714 | * `SlackBot` - Subclass of `Bot` that contains Slack-specific functionality. 715 | * `createSlackBot` - function that returns an instance of `SlackBot`. 716 | 717 | #### Message handlers 718 | * `DelegatingMessageHandler` - 719 | * `createDelegate` - function that returns an instance of `DelegatingMessageHandler`. 720 | * `MatchingMessageHandler` - 721 | * `createMatcher` - function that returns an instance of `MatchingMessageHandler`. 722 | * `ArgsAdjustingMessageHandler` - 723 | * `createArgsAdjuster` - function that returns an instance of `ArgsAdjustingMessageHandler`. 724 | * `ParsingMessageHandler` - 725 | * `createParser` - function that returns an instance of `ParsingMessageHandler`. 726 | * `ConversingMessageHandler` - 727 | * `createConversation` - function that returns an instance of `ConversingMessageHandler`. 728 | * `CommandMessageHandler` - 729 | * `createCommand` - function that returns an instance of `CommandMessageHandler`. 730 | 731 | #### Util 732 | * `handleMessage` - Pass specified arguments through a message handler or array 733 | of message handlers. 734 | * `isMessageHandlerOrHandlers` - Facilitate message handler result parsing. 735 | * `parseArgs` - Parse args from an array or string. Suitable for use with lines 736 | of chat. 737 | * `isMessage` - Is the argument a message? It's a message if it's an Array, 738 | nested Arrays, or a value comprised solely of String, Number, null, undefined 739 | or false values. 740 | * `normalizeMessage` - Flatten message array and remove null, undefined or false 741 | items, then join on newline. 742 | * `composeCreators` - Compose creators that accept a signature like getHandlers() 743 | into a single creator. All creators receive the same options object. 744 | 745 | ## Developing and Contributing 746 | 747 | ### npm scripts 748 | 749 | This project and all examples are written for nodejs in ES2015, using babel. 750 | Ensure you have [Node][node] 4.x or 6.x and Npm installed and run `npm install` 751 | before running any of the following commands: 752 | 753 | * `npm test` - Lints project code and runs tests. 754 | * `npm run build` - Builds project code from `src` into `dist` for publishing. 755 | * `npm run start` - Watches project files for changes, linting, testing and 756 | building as-necessary. 757 | * `npm run babel` - Run ES2015 javascript via the babel cli. 758 | * `npm run prepublish` - Automatically runs `npm run build` before publishing. 759 | 760 | ### Contributing 761 | 762 | TBD 763 | --------------------------------------------------------------------------------