brobbot
Version:
A simple helpful robot for your Company
541 lines (467 loc) • 15.4 kB
text/coffeescript
_ = require 'lodash'
Q = require 'q'
Fs = require 'fs'
Log = require 'log'
Path = require 'path'
HttpClient = require 'scoped-http-client'
{EventEmitter} = require 'events'
User = require './user'
RobotSegment = require './robot-segment'
Response = require './response'
{Listener,TextListener} = require './listener'
{EnterMessage,LeaveMessage,TopicMessage,CatchAllMessage,TextMessage} = require './message'
BROBBOT_DEFAULT_ADAPTERS = [
'campfire'
'shell'
]
BROBBOT_DEFAULT_BRAINS = [
'dumb'
]
BROBBOT_DOCUMENTATION_SECTIONS = [
'description'
'dependencies'
'configuration'
'commands'
'notes'
'author'
'authors'
'examples'
'tags'
'urls'
]
class Robot
# Robots receive messages from a chat source (Campfire, irc, etc), and
# dispatch them to matching listeners.
#
# adapterPath - A String of the path to local adapters.
# adapter - A String of the adapter name.
# brainPath - A String of the path to local brains.
# brain - A String of the brain name.
# httpd - A Boolean whether to enable the HTTP daemon.
# name - A String of the robot name, defaults to Brobbot.
#
# Returns nothing.
constructor: (adapterPath, adapter, brainPath, brain, httpd, name = 'Brobbot') ->
@name = name
@nameRegex = new RegExp "^\\s*#{name}:?\\s+", 'i'
@events = new EventEmitter
@alias = false
@adapter = null
@Response = Response
@commands = []
@listeners = []
@respondListeners = []
@logger = new Log process.env.BROBBOT_LOG_LEVEL or 'info'
@pingIntervalId = null
@parseVersion()
if httpd
@setupExpress()
else
@setupNullRouter()
@brainReady = @loadBrain brainPath, brain
@adapterReady = @brainReady.then =>
@loadAdapter adapterPath, adapter
@ready = Q.all [@brainReady, @adapterReady]
@adapterName = adapter
@errorHandlers = []
@on "running", =>
@brain.resetSaveInterval 5
@on 'error', (err, msg) =>
@invokeErrorHandlers(err, msg)
process.on 'uncaughtException', (err) =>
@logger.error err.stack
@emit 'error', err
# Public: Adds a Listener that attempts to match incoming messages based on
# a Regex.
#
# regex - A Regex that determines if the callback should be called.
# callback - A Function that is called with a Response object.
#
# Returns nothing.
hear: (regex, callback) ->
@listeners.push new TextListener(@, regex, callback)
# Public: Adds a Listener that attempts to match incoming messages directed
# at the robot based on a Regex. All regexes treat patterns like they begin
# with a '^'
#
# regex - A Regex that determines if the callback should be called.
# callback - A Function that is called with a Response object.
#
# Returns nothing.
respond: (regex, callback) ->
@respondListeners.push new TextListener(@, regex, callback)
# Public: Adds a Listener that triggers when anyone enters the room.
#
# callback - A Function that is called with a Response object.
#
# Returns nothing.
enter: (callback) ->
@listeners.push new Listener(
@,
((msg) -> msg instanceof EnterMessage),
callback
)
# Public: Adds a Listener that triggers when anyone leaves the room.
#
# callback - A Function that is called with a Response object.
#
# Returns nothing.
leave: (callback) ->
@listeners.push new Listener(
@,
((msg) -> msg instanceof LeaveMessage),
callback
)
# Public: Adds a Listener that triggers when anyone changes the topic.
#
# callback - A Function that is called with a Response object.
#
# Returns nothing.
topic: (callback) ->
@listeners.push new Listener(
@,
((msg) -> msg instanceof TopicMessage),
callback
)
# Public: Adds an error handler when an uncaught exception or user emitted
# error event occurs.
#
# callback - A Function that is called with the error object.
#
# Returns nothing.
error: (callback) ->
@errorHandlers.push callback
# Calls and passes any registered error handlers for unhandled exceptions or
# user emitted error events.
#
# err - An Error object.
# msg - An optional Response object that generated the error
#
# Returns nothing.
invokeErrorHandlers: (err, msg) ->
@logger.error err.stack
for errorHandler in @errorHandlers
try
errorHandler(err, msg)
catch errErr
@logger.error "while invoking error handler: #{errErr}\n#{errErr.stack}"
# Public: Adds a Listener that triggers when no other text matchers match.
#
# callback - A Function that is called with a Response object.
#
# Returns nothing.
catchAll: (callback) ->
@listeners.push new Listener(
@,
((msg) -> msg instanceof CatchAllMessage),
((msg) -> msg.message = msg.message.message; callback msg)
)
messageIsToMe: (message) ->
if @alias
@aliasRegex = new RegExp "^\\s*#{@alias}:?\\s+"
else
@aliasRegex = false
if @nameRegex.test message.text or (@aliasRegex and @aliasRegex.test message.text)
return true
return false
# Public: Passes the given message to any interested Listeners.
#
# message - A Message instance. Listeners can flag this message as 'done' to
# prevent further execution.
#
# Returns nothing.
receive: (message) ->
matched = false
for listener in @listeners
try
matched = listener.call(message) or matched
break if message.done
catch error
@emit('error', error, new @Response(@, message, []))
if @messageIsToMe message
#for respond listeners, chop off the brobbot's name/alias
respondText = message.text.replace @nameRegex, ''
if @aliasRegex
respondText = respondText.replace @aliasRegex, ''
respondMessage = new TextMessage message.user, respondText, message.id
for listener in @respondListeners
try
matched = listener.call(respondMessage) or matched
break if respondMessage.done
catch error
@emit('error', error, new @Response(@, respondMessage, []))
if message not instanceof CatchAllMessage and not matched
@receive new CatchAllMessage(message)
# Public: Loads a file in path.
#
# path - A String path on the filesystem.
# file - A String filename in path on the filesystem.
#
# Returns nothing.
loadFile: (path, file) ->
ext = Path.extname file
full = Path.join path, Path.basename(file, ext)
if require.extensions[ext]
try
require(full) @segment(full)
@parseHelp Path.join(path, file)
catch error
@logger.error "Unable to load #{full}: #{error.stack}"
process.exit(1)
# Public: Loads every script in the given path.
#
# path - A String path on the filesystem.
#
# Returns nothing.
load: (path) ->
@ready.then =>
@logger.debug "Loading scripts from #{path}"
if Fs.existsSync(path)
Fs.readdirSync(path).sort().forEach (file) =>
@loadFile path, file
# Public: Load scripts specfied in the `brobbot-scripts.json` file.
#
# path - A String path to the brobbot-scripts files.
# scripts - An Array of scripts to load.
#
# Returns promise.
loadBrobbotScripts: (path, scripts) ->
@ready.then =>
@logger.debug "Loading brobbot-scripts from #{path}"
scripts.forEach (script) =>
@loadFile path, script
# Public: Load scripts from packages specfied in the
# `external-scripts.json` file.
#
# packages - An Array of packages containing brobbot scripts to load.
#
# Returns promise.
loadExternalScripts: (packages) ->
@ready.then =>
@logger.debug "Loading external-scripts from npm packages"
try
if packages instanceof Array
for pkg in packages
require(pkg) @segment(pkg)
else
for pkg, scripts of packages
#TODO brain segment for each script appropriate?
require(pkg) @segment("#{pkg}"), scripts
catch err
@logger.error "Error loading scripts from npm package - #{err.stack}"
process.exit(1)
# Setup the Express server's defaults.
#
# Returns nothing.
setupExpress: ->
user = process.env.EXPRESS_USER
pass = process.env.EXPRESS_PASSWORD
stat = process.env.EXPRESS_STATIC
express = require 'express'
app = express()
app.use (req, res, next) =>
res.setHeader "X-Powered-By", "brobbot/#{@name}"
next()
app.use express.basicAuth user, pass if user and pass
app.use express.query()
app.use express.bodyParser()
app.use express.static stat if stat
try
@server = app.listen(process.env.PORT || 8080, process.env.BIND_ADDRESS || '0.0.0.0')
@router = app
catch err
@logger.error "Error trying to start HTTP server: #{err}\n#{err.stack}"
process.exit(1)
herokuUrl = process.env.HEROKU_URL
if herokuUrl
herokuUrl += '/' unless /\/$/.test herokuUrl
@pingIntervalId = setInterval =>
HttpClient.create("#{herokuUrl}brobbot/ping").post() (err, res, body) =>
@logger.info 'keep alive ping!'
, 5 * 60 * 1000
# Setup an empty router object
#
# returns nothing
setupNullRouter: ->
msg = "A script has tried registering a HTTP route while the HTTP server is disabled with --disabled-httpd."
@router =
get: ()=> @logger.warning msg
post: ()=> @logger.warning msg
put: ()=> @logger.warning msg
delete: ()=> @logger.warning msg
# Load the brain Brobbot is going to use.
#
# path - A String of the path to brain if local.
# brain - A String of the brain name to use.
#
# Returns promise.
loadBrain: (path, brain) ->
@logger.debug "Loading brain #{brain}"
try
path = if brain in BROBBOT_DEFAULT_BRAINS
"#{path}/#{brain}"
else
"brobbot-#{brain}-brain"
@brain = new (require(path)) @
return @brain.ready or Q(@brain)
catch err
@logger.error "Cannot load brain #{brain} - #{err.stack}"
process.exit(1)
# Load the adapter Brobbot is going to use.
#
# path - A String of the path to adapter if local.
# adapter - A String of the adapter name to use.
#
# Returns promise.
loadAdapter: (path, adapter) ->
@logger.debug "Loading adapter #{adapter}"
try
path = if adapter in BROBBOT_DEFAULT_ADAPTERS
"#{path}/#{adapter}"
else
"brobbot-#{adapter}"
@adapter = require(path).use @
return @adapter.ready or Q(@adapter)
catch err
@logger.error "Cannot load adapter #{adapter} - #{err.stack}"
process.exit(1)
# Public: Help Commands for Running Scripts.
#
# Returns an Array of help commands for running scripts.
helpCommands: ->
@commands.sort()
# Private: load help info from a loaded script.
#
# path - A String path to the file on disk.
#
# Returns nothing.
parseHelp: (path) ->
@logger.debug "Parsing help for #{path}"
scriptName = Path.basename(path).replace /\.(coffee|js)$/, ''
scriptDocumentation = {}
body = Fs.readFileSync path, 'utf-8'
currentSection = null
for line in body.split "\n"
break unless line[0] is '#' or line.substr(0, 2) is '//'
cleanedLine = line.replace(/^(#|\/\/)\s?/, "").trim()
continue if cleanedLine.length is 0
continue if cleanedLine.toLowerCase() is 'none'
nextSection = cleanedLine.toLowerCase().replace(':', '')
if nextSection in BROBBOT_DOCUMENTATION_SECTIONS
currentSection = nextSection
scriptDocumentation[currentSection] = []
else
if currentSection
scriptDocumentation[currentSection].push cleanedLine.trim()
if currentSection is 'commands'
@commands.push cleanedLine.trim()
if currentSection is null
@logger.info "#{path} is using deprecated documentation syntax"
scriptDocumentation.commands = []
for line in body.split("\n")
break if not (line[0] is '#' or line.substr(0, 2) is '//')
continue if not line.match('-')
cleanedLine = line[2..line.length].replace(/^brobbot/i, @name).trim()
scriptDocumentation.commands.push cleanedLine
@commands.push cleanedLine
# Public: A helper send function which delegates to the adapter's send
# function.
#
# user - A User instance.
# strings - One or more Strings for each message to send.
#
# Returns nothing.
send: (user, strings...) ->
@adapter.send user, strings...
# Public: A helper reply function which delegates to the adapter's reply
# function.
#
# user - A User instance.
# strings - One or more Strings for each message to send.
#
# Returns nothing.
reply: (user, strings...) ->
@adapter.reply user, strings...
# Public: A helper send function to message a room that the robot is in.
#
# room - String designating the room to message.
# strings - One or more Strings for each message to send.
#
# Returns nothing.
messageRoom: (room, strings...) ->
user = { room: room }
@adapter.send user, strings...
# Public: A wrapper around the EventEmitter API to make usage
# semanticly better.
#
# event - The event name.
# listener - A Function that is called with the event parameter
# when event happens.
#
# Returns nothing.
on: (event, args...) ->
@events.on event, args...
# Public: A wrapper around the EventEmitter API to make usage
# semanticly better.
#
# event - The event name.
# args... - Arguments emitted by the event
#
# Returns nothing.
emit: (event, args...) ->
@events.emit event, args...
# Public: Kick off the event loop for the adapter
#
# Returns nothing.
run: ->
@emit "running"
@adapter.run()
# Public: Gracefully shutdown the robot process
#
# Returns nothing.
shutdown: ->
clearInterval @pingIntervalId if @pingIntervalId?
@adapter.close()
@brain.close()
if @redisClient
@redisClient.close()
# Public: The version of Brobbot from npm
#
# Returns a String of the version number.
parseVersion: ->
pkg = require Path.join __dirname, '..', 'package.json'
@version = pkg.version
# Public: Creates a scoped http client with chainable methods for
# modifying the request. This doesn't actually make a request though.
# Once your request is assembled, you can call `get()`/`post()`/etc to
# send the request.
#
# url - String URL to access.
#
# Examples:
#
# res.http("http://example.com")
# # set a single header
# .header('Authorization', 'bearer abcdef')
#
# # set multiple headers
# .headers(Authorization: 'bearer abcdef', Accept: 'application/json')
#
# # add URI query parameters
# .query(a: 1, b: 'foo & bar')
#
# # make the actual request
# .get() (err, res, body) ->
# console.log body
#
# # or, you can POST data
# .post(data) (err, res, body) ->
# console.log body
#
# Returns a ScopedClient instance.
http: (url) ->
HttpClient.create(url)
.header('User-Agent', "Brobbot/#{@version}")
segment: (segmentName) ->
new RobotSegment @, segmentName
module.exports = Robot