Skip to content

Commit

Permalink
Add middleware between Listener match and execute
Browse files Browse the repository at this point in the history
Allow injecting arbitrary code between the Listener match and execute
steps. Like in Express, middleware can interrupt the response process,
preventing the Listener callback from ever being executed. Middleware
can perform operations both on the way towards the Listener callback
(before callback execution) and on the way away from the Listener
callback (after callback execution/middleware interrupt).

As a side effect, listeners are now executed asynchronously. Behavior
around message.done should remain the same (process until message.done
is true).
  • Loading branch information
michaelansel committed Oct 25, 2014
1 parent 57b7220 commit 02f630f
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 29 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"optparse": "1.0.4",
"scoped-http-client": "0.9.8",
"log": "1.4.0",
"express": "3.3.4"
"express": "3.3.4",
"async": "~0.9.0"
},

"engines": {
Expand Down
72 changes: 63 additions & 9 deletions src/listener.coffee
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{inspect} = require 'util'
async = require 'async'

{TextMessage} = require './message'

Expand Down Expand Up @@ -29,21 +30,74 @@ class Listener
throw new Error "Missing a callback for Listener"

# Public: Determines if the listener likes the content of the message. If
# so, a Response built from the given Message is passed to the Listener
# callback.
# so, a Response built from the given Message is passed through all
# registered middleware and potentially the Listener callback. Note that
# middleware can intercept the message and prevent the callback from ever
# being executed.
#
# message - A Message instance.
#
# Returns a boolean of whether the matcher matched.
call: (message) ->
# @callback - Call with a boolean of whether the matcher matched.
call: (message, cb) ->
if match = @matcher message
@robot.logger.debug \
"Message '#{message}' matched regex /#{inspect @regex}/" if @regex
if @regex
@robot.logger.debug \
"Message '#{message}' matched regex /#{inspect @regex}/"

@callback new @robot.Response(@robot, message, match)
true
# special middleware-like function that always executes the Listener's
# callback and calls done (never calls 'next')
executeListener = (response, done) =>
@robot.logger.debug "Executing listener callback for Message '#{message}'"
@callback response
done()

# When everything is finished (down the middleware stack and back up),
# pass control back to the robot
allDone = () ->
# Yes, we tried to execute the listener callback (middleware may
# have intercepted before actually executing though)
cb true

response = new @robot.Response(@robot, message, match)
@executeAllMiddleware response, executeListener, allDone
else
false
# No, we didn't try to execute the listener callback
cb false

# Execute all middleware in order and call 'next' with the latest 'done'
# callback if last middleware calls through. If all middleware is compliant,
# 'done' should be called with no arguments when the entire round trip is
# complete.
#
# response - Response object to eventually pass to the Listener callback
#
# next - Called when all middleware is complete (assuming all continued
# by calling respective 'next' functions)
#
# done - Initial (final) completion callback. May be wrapped by
# executed middleware.
#
# Returns nothing
executeAllMiddleware: (response, next, done) ->
allMiddleware = @robot.middleware

# When a middleware finishes, call the next one with the latest
# completion callback (each middleware may wrap the old 'done' callback
# with additional logic)
iterate = (idx, done) ->
if idx < allMiddleware.length
# Execute the indicated middleware
executeSingleMiddleware(idx, done)
else
# All done with middleware!
next(response, done)

# Execute a single middleware and return to #iterate when it continues
executeSingleMiddleware = (idx, done) =>
myIterate = (newDone) -> iterate(idx + 1, newDone)
allMiddleware[idx].call(undefined, @robot, @, response, myIterate, done)

iterate(0, done)

class TextListener extends Listener
# TextListeners receive every message from the chat source and decide if they
Expand Down
65 changes: 46 additions & 19 deletions src/robot.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Log = require 'log'
Path = require 'path'
HttpClient = require 'scoped-http-client'
{EventEmitter} = require 'events'
async = require 'async'

User = require './user'
Brain = require './brain'
Expand Down Expand Up @@ -39,15 +40,16 @@ class Robot
#
# Returns nothing.
constructor: (adapterPath, adapter, httpd, name = 'Hubot') ->
@name = name
@events = new EventEmitter
@brain = new Brain @
@alias = false
@adapter = null
@Response = Response
@commands = []
@listeners = []
@logger = new Log process.env.HUBOT_LOG_LEVEL or 'info'
@name = name
@events = new EventEmitter
@brain = new Brain @
@alias = false
@adapter = null
@Response = Response
@commands = []
@listeners = []
@middleware = []
@logger = new Log process.env.HUBOT_LOG_LEVEL or 'info'

@parseVersion()
if httpd
Expand Down Expand Up @@ -184,24 +186,49 @@ class Robot
((msg) -> msg.message = msg.message.message; callback msg)
)

# Public: Registers new middleware for execution after matching but before
# Listener callbacks
#
# middleware - A function that determines whether or not a given matching
# Listener should be executed. The function is called with
# (robot, listener, response, next, done). If execution should
# continue (next middleware, Listener callback), the middleware
# should call the 'next' function with 'done' as an argument.
# If not, the middleware should call the 'done' function with
# no arguments.
#
# Returns nothing.
addListenerMiddleware: (middleware) ->
@middleware.push middleware

# 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) ->
results = []
for listener in @listeners
try
results.push listener.call(message)
break if message.done
catch error
@emit('error', error, new @Response(@, message, []))
# Try executing all registered Listeners in order of registration
# and return after message is done being processed
anyListenersExecuted = false
async.detectSeries(
@listeners,
(listener, cb) =>
try
listener.call message, (listenerExecuted) ->
anyListenersExecuted = anyListenersExecuted || listenerExecuted
# Stop processing when message.done == true
cb(message.done)
catch err
@emit('error', err, new @Response(@, message, []))
,
(result) =>
# If no registered Listener matched the message
if message not instanceof CatchAllMessage and not anyListenersExecuted
@logger.debug 'No listeners executed; falling back to catch-all'
@receive new CatchAllMessage(message)
)

false
if message not instanceof CatchAllMessage and results.indexOf(true) is -1
@receive new CatchAllMessage(message)

# Public: Loads a file in path.
#
Expand Down

0 comments on commit 02f630f

Please sign in to comment.