Skip to content

Commit

Permalink
feat(server): Web server with public and message routes
Browse files Browse the repository at this point in the history
New thought process and branch type to process data incoming from server endpoints. Koa router
provides methods for serving custom API and http/s pages from the bot.

fix #39
  • Loading branch information
timkinnane committed Sep 29, 2018
1 parent 4660eb6 commit 6ff0d23
Show file tree
Hide file tree
Showing 21 changed files with 1,656 additions and 60 deletions.
6 changes: 6 additions & 0 deletions package.json
Expand Up @@ -64,6 +64,7 @@
"@types/chai": "^4.1.0",
"@types/mocha": "^5.0.0",
"@types/sinon": "^5.0.0",
"axios": "^0.18.0",
"chai": "^4.0.0",
"codecov": "^3.0.0",
"commitizen": "^2.10.0",
Expand All @@ -83,13 +84,18 @@
"dependencies": {
"@rocket.chat/sdk": "^0.2.7",
"@types/inquirer": "^0.0.42",
"@types/koa": "^2.0.46",
"@types/koa-router": "^7.0.32",
"@types/mongoose": "^5.2.0",
"@types/node": "^10.0.0",
"@types/request": "^2.47.1",
"@types/yargs": "^11.0.0",
"chalk": "^2.4.0",
"dotenv": "^6.0.0",
"inquirer": "^6.0.0",
"koa": "^2.5.3",
"koa-body": "^4.0.4",
"koa-router": "^7.4.0",
"mongoose": "^5.2.0",
"request": "^2.88.0",
"to-regex-range": "^4.0.2",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -20,4 +20,5 @@ export * from './lib/memory'
export * from './lib/store'
export * from './lib/core'
export * from './lib/thought'
export * from './lib/server'
export * from './lib/json'
53 changes: 53 additions & 0 deletions src/lib/branch.spec.ts
Expand Up @@ -336,4 +336,57 @@ describe('[branch]', () => {
assert.isFalse(b.matched)
})
})
describe('ServerBranch', () => {
it('.matcher matches on empty criteria if no data', async () => {
const reqBranch = new bot.ServerBranch({}, () => null)
const reqMessage = new bot.ServerMessage({ userId: '111' })
expect(await reqBranch.matcher(reqMessage)).to.eql({})
})
it('.matcher matches on property at attribute', async () => {
const reqBranch = new bot.ServerBranch({
foo: 'bar'
}, () => null)
const reqMessage = new bot.ServerMessage({
data: { foo: 'bar' },
userId: '111',
roomId: 'test'
})
expect(await reqBranch.matcher(reqMessage)).to.eql({
foo: 'bar'
})
})
})
it('.matcher fails on wrong property at attribute', async () => {
const reqBranch = new bot.ServerBranch({
foo: 'bar'
}, () => null)
const reqMessage = new bot.ServerMessage({
data: { foo: 'baz' },
userId: '111',
roomId: 'test'
})
expect(await reqBranch.matcher(reqMessage)).to.eql(undefined)
})
it('.matcher matches on property at path', async () => {
const reqBranch = new bot.ServerBranch({
'foo.bar.baz': 'qux'
}, () => null)
const reqMessage = new bot.ServerMessage({
data: { foo: { bar: { baz: 'qux' } } },
userId: '111',
roomId: 'test'
})
expect(await reqBranch.matcher(reqMessage)).to.eql({ 'foo.bar.baz': 'qux' })
})
it('.matcher matches on property matching expression', async () => {
const reqBranch = new bot.ServerBranch({
foo: /b.r/i
}, () => null)
const reqMessage = new bot.ServerMessage({
data: { foo: 'BAR' },
userId: '111',
roomId: 'test'
})
expect(await reqBranch.matcher(reqMessage)).to.eql({ 'foo': /b.r/i.exec('BAR') })
})
})
65 changes: 61 additions & 4 deletions src/lib/branch.ts
Expand Up @@ -76,7 +76,6 @@ export abstract class Branch {
b.setBranch(this)
await middleware.execute(b, (b) => {
bot.logger.debug(`[branch] executing ${this.constructor.name} callback, ID ${this.id}`)
if (!b.message.nlu) b.processed.listen = Date.now() // workaround for thought process
return Promise.resolve(this.callback(b))
}).then(() => {
bot.logger.debug(`[branch] ${this.id} process done (${(this.matched) ? 'matched' : 'no match'})`)
Expand Down Expand Up @@ -210,7 +209,7 @@ export class NaturalLanguageBranch extends Branch {
}
}

/** Natural Language Direct Branch pre-matches the text for bot name prefix */
/** Natural Language Direct Branch pre-matches the text for bot name prefix. */
export class NaturalLanguageDirectBranch extends NaturalLanguageBranch {
async matcher (message: bot.TextMessage) {
if (directPattern().exec(message.toString())) {
Expand All @@ -221,8 +220,66 @@ export class NaturalLanguageDirectBranch extends NaturalLanguageBranch {
}
}

export interface IServerBranchCriteria {
[path: string]: any
}

/** Server branch matches data in a message received on the server. */
export class ServerBranch extends Branch {
match: any

/** Create server branch for data matching. */
constructor (
public criteria: IServerBranchCriteria,
callback: IBranchCallback | string,
options?: IBranch
) {
super(callback, options)
}

/**
* Match on any exact or regex values at path of key in criteria.
* Will also match on empty data if criteria is an empty object.
*/
async matcher (message: bot.ServerMessage) {
const match: { [path: string]: any } = {}
if (
Object.keys(this.criteria).length === 0 &&
(
typeof message.data === 'undefined' ||
Object.keys(message.data).length === 0
)
) {
return match
} else {
if (!message.data) {
bot.logger.error(`[branch] server branch attempted matching without data, ID ${this.id}`)
return undefined
}
}
for (let path in this.criteria) {
const valueAtPath = path.split('.').reduce((pre, cur) => {
return (typeof pre !== 'undefined') ? pre[cur] : undefined
}, message.data)
if (
this.criteria[path] instanceof RegExp &&
this.criteria[path].exec(valueAtPath)
) match[path] = this.criteria[path].exec(valueAtPath)
else if (
this.criteria[path] === valueAtPath ||
this.criteria[path].toString() === valueAtPath
) match[path] = valueAtPath
}
if (Object.keys(match).length) {
bot.logger.debug(`[branch] Data matched server branch ID ${this.id}`)
return match
}
return undefined
}
}

/**
* Build a regular expression that matches text prefixed with the bot's name
* Build a regular expression that matches text prefixed with the bot's name.
* - matches when alias is substring of name
* - matches when name is substring of alias
*/
Expand All @@ -238,7 +295,7 @@ export function directPattern () {
return new RegExp(`^\\s*[@]?(?:${botAlias}[:,]?|${botName}[:,]?)\\s*`, 'i')
}

/** Build a regular expression for bot's name combined with another regex */
/** Build a regular expression for bot's name combined with another regex. */
export function directPatternCombined (regex: RegExp) {
const regexWithoutModifiers = regex.toString().split('/')
regexWithoutModifiers.shift()
Expand Down
20 changes: 20 additions & 0 deletions src/lib/config.ts
Expand Up @@ -45,6 +45,26 @@ const initOptions: { [key: string]: yargs.Options } = {
describe: 'Save data in the brain every 5 seconds (defaults true).',
default: true
},
'use-server': {
type: 'boolean',
describe: 'Enable/disable the internal Koa server for incoming requests and http/s messages.',
default: true
},
'server-host': {
type: 'string',
describe: 'The host the bot is running on.',
default: 'localhost'
},
'server-port': {
type: 'string',
describe: 'The port the server should listen on.',
default: '3000'
},
'server-secure': {
type: 'boolean',
describe: 'Server should listen on HTTPS only.',
default: false
},
'message-adapter': {
type: 'string',
describe: 'Local path or NPM package name to require as message platform adapter',
Expand Down
2 changes: 2 additions & 0 deletions src/lib/core.spec.ts
Expand Up @@ -2,6 +2,8 @@ import 'mocha'
import sinon from 'sinon'
import { expect } from 'chai'
import * as bot from '..'
import { EventEmitter } from 'events'
EventEmitter.prototype.setMaxListeners(100)

let initEnv: any

Expand Down
5 changes: 4 additions & 1 deletion src/lib/core.ts
Expand Up @@ -37,6 +37,7 @@ export async function load () {
setStatus('loading')
try {
bot.middlewares.load()
bot.server.load()
bot.adapters.load()
await eventDelay()
setStatus('loaded')
Expand All @@ -57,6 +58,7 @@ export async function start () {
if (getStatus() !== 'loaded') await load()
setStatus('starting')
try {
await bot.server.start()
await bot.adapters.start()
await bot.memory.start()
} catch (err) {
Expand Down Expand Up @@ -84,8 +86,9 @@ export async function shutdown (exit = 0) {
} else if (status === 'starting') {
await new Promise((resolve) => bot.events.on('started', () => resolve()))
}
await bot.adapters.shutdown()
await bot.memory.shutdown()
await bot.adapters.shutdown()
await bot.server.shutdown()
await eventDelay()
setStatus('shutdown')
bot.events.emit('shutdown')
Expand Down
10 changes: 10 additions & 0 deletions src/lib/e2e-tests/e2e.spec.ts
@@ -1,6 +1,7 @@
import 'mocha'
import sinon from 'sinon'
import { expect } from 'chai'
import axios from 'axios'
import * as bot from '../..'

class MockMessenger extends bot.MessageAdapter {
Expand Down Expand Up @@ -55,4 +56,13 @@ describe('[E2E]', () => {
attachments: [attachment]
} })
})
it('replies to user from server message', async () => {
bot.global.server({ test: 1 }, (b) => {
b.reply('testing')
}, {
id: 'e2e-server'
})
await axios.get(`${bot.server.url()}/message/111?test=1`)
sinon.assert.calledWithMatch(mocks.dispatch, { strings: ['@111 testing'] })
})
})
19 changes: 18 additions & 1 deletion src/lib/message.spec.ts
Expand Up @@ -79,11 +79,28 @@ describe('[message]', () => {
})
})
describe('CatchAllMessage', () => {
it('inherits original message properties', () => {
it('constructor inherits original message properties', () => {
const textMessage = new message.TextMessage(mockUser, 'test txt')
const catchMessage = new message.CatchAllMessage(textMessage)
expect(catchMessage.id).to.equal(textMessage.id)
expect(catchMessage.toString()).to.equal(textMessage.toString())
})
})
describe('ServerMessage', () => {
it('constructor gets user from ID', () => {
bot.userById(mockUser.id, mockUser) // make user known
const requestMessage = new message.ServerMessage({
userId: mockUser.id,
data: { foo: 'bar' }
})
expect(requestMessage.user).to.eql(mockUser)
})
it('.toString prints JSON data', () => {
const requestMessage = new message.ServerMessage({
userId: mockUser.id,
data: { foo: 'bar' }
})
expect(requestMessage.toString()).to.match(/foo.*?bar/)
})
})
})
27 changes: 27 additions & 0 deletions src/lib/message.ts
Expand Up @@ -91,6 +91,33 @@ export class TopicMessage extends EventMessage {
event = 'topic'
}

/** JSON data for server request event message. */
export interface IServerMessageOptions {
userId: string // The user the request relates to
roomId?: string // The room to message the user from
data?: any // Any data to be used by callbacks
id?: string // ID for the message
}

/** Represent message data coming from a server request. */
export class ServerMessage extends EventMessage {
event = 'request'
data: any

/** Create a server message for a user. */
constructor (options: IServerMessageOptions) {
super(bot.userById(options.userId, {
room: (options.roomId) ? { id: options.roomId } : {}
}), options.id)
this.data = options.data || {}
bot.logger.debug(`[message] server request keys: ${Object.keys(this.data).join(', ')}`)
}

toString () {
return `Data for user ${this.user.id}: ${JSON.stringify(this.data)}`
}
}

/** Represent a message where nothing matched. */
export class CatchAllMessage extends Message {
constructor (public message: Message) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/middleware.ts
Expand Up @@ -124,7 +124,7 @@ export class Middleware {

/** Collection of allowed middleware types for loading. */
const middlewareTypes = [
'hear', 'listen', 'understand', 'act', 'respond', 'remember'
'hear', 'listen', 'understand', 'serve', 'act', 'respond', 'remember'
]

/**
Expand Down
14 changes: 13 additions & 1 deletion src/lib/path.spec.ts
Expand Up @@ -98,6 +98,18 @@ describe('[path]', () => {
sinon.assert.calledOnce(callback)
})
})
describe('.server', () => {
it('.process calls callback on matching server message', async () => {
const path = new bot.Path()
const callback = sinon.spy()
const message = new bot.ServerMessage({ userId: user.id, data: {
foo: 'bar'
} })
const id = path.server({ foo: 'bar' }, callback)
await path.serve[id].process(new bot.State({ message }), middleware)
sinon.assert.calledOnce(callback)
})
})
describe('.reset', () => {
it('clears all branches from collections', () => {
const path = new bot.Path()
Expand Down Expand Up @@ -149,7 +161,7 @@ describe('[path]', () => {
const direct = bot.directPatternCombined(/test/)
expect(direct.toString()).to.include(bot.settings.name).and.include('test')
})
it('does not match on name unless otherwise matched', async () => {
it('does not match on name unless otherwise matched', () => {
const direct = bot.directPatternCombined(/test/)
expect(direct.test(`${bot.settings.name}`)).to.equal(false)
})
Expand Down

0 comments on commit 6ff0d23

Please sign in to comment.