UNPKG

24 kBJavaScriptView Raw
1'use strict'
2
3const EventEmitter = require('events').EventEmitter
4const fs = require('fs')
5const path = require('path')
6
7const async = require('async')
8const Log = require('log')
9const HttpClient = require('scoped-http-client')
10
11const Brain = require('./brain')
12const Response = require('./response')
13const Listener = require('./listener')
14const Message = require('./message')
15const Middleware = require('./middleware')
16
17const HUBOT_DEFAULT_ADAPTERS = ['campfire', 'shell']
18const HUBOT_DOCUMENTATION_SECTIONS = ['description', 'dependencies', 'configuration', 'commands', 'notes', 'author', 'authors', 'examples', 'tags', 'urls']
19
20class Robot {
21 // Robots receive messages from a chat source (Campfire, irc, etc), and
22 // dispatch them to matching listeners.
23 //
24 // adapterPath - A String of the path to built-in adapters (defaults to src/adapters)
25 // adapter - A String of the adapter name.
26 // httpd - A Boolean whether to enable the HTTP daemon.
27 // name - A String of the robot name, defaults to Hubot.
28 constructor (adapterPath, adapter, httpd, name, alias) {
29 if (name == null) {
30 name = 'Hubot'
31 }
32 if (alias == null) {
33 alias = false
34 }
35 this.adapterPath = path.join(__dirname, 'adapters')
36
37 this.name = name
38 this.events = new EventEmitter()
39 this.brain = new Brain(this)
40 this.alias = alias
41 this.adapter = null
42 this.Response = Response
43 this.commands = []
44 this.listeners = []
45 this.middleware = {
46 listener: new Middleware(this),
47 response: new Middleware(this),
48 receive: new Middleware(this)
49 }
50 this.logger = new Log(process.env.HUBOT_LOG_LEVEL || 'info')
51 this.pingIntervalId = null
52 this.globalHttpOptions = {}
53
54 this.parseVersion()
55 if (httpd) {
56 this.setupExpress()
57 } else {
58 this.setupNullRouter()
59 }
60
61 this.loadAdapter(adapter)
62
63 this.adapterName = adapter
64 this.errorHandlers = []
65
66 this.on('error', (err, res) => {
67 return this.invokeErrorHandlers(err, res)
68 })
69 this.onUncaughtException = err => {
70 return this.emit('error', err)
71 }
72 process.on('uncaughtException', this.onUncaughtException)
73 }
74
75 // Public: Adds a custom Listener with the provided matcher, options, and
76 // callback
77 //
78 // matcher - A Function that determines whether to call the callback.
79 // Expected to return a truthy value if the callback should be
80 // executed.
81 // options - An Object of additional parameters keyed on extension name
82 // (optional).
83 // callback - A Function that is called with a Response object if the
84 // matcher function returns true.
85 //
86 // Returns nothing.
87 listen (matcher, options, callback) {
88 this.listeners.push(new Listener.Listener(this, matcher, options, callback))
89 }
90
91 // Public: Adds a Listener that attempts to match incoming messages based on
92 // a Regex.
93 //
94 // regex - A Regex that determines if the callback should be called.
95 // options - An Object of additional parameters keyed on extension name
96 // (optional).
97 // callback - A Function that is called with a Response object.
98 //
99 // Returns nothing.
100 hear (regex, options, callback) {
101 this.listeners.push(new Listener.TextListener(this, regex, options, callback))
102 }
103
104 // Public: Adds a Listener that attempts to match incoming messages directed
105 // at the robot based on a Regex. All regexes treat patterns like they begin
106 // with a '^'
107 //
108 // regex - A Regex that determines if the callback should be called.
109 // options - An Object of additional parameters keyed on extension name
110 // (optional).
111 // callback - A Function that is called with a Response object.
112 //
113 // Returns nothing.
114 respond (regex, options, callback) {
115 this.hear(this.respondPattern(regex), options, callback)
116 }
117
118 // Public: Build a regular expression that matches messages addressed
119 // directly to the robot
120 //
121 // regex - A RegExp for the message part that follows the robot's name/alias
122 //
123 // Returns RegExp.
124 respondPattern (regex) {
125 const regexWithoutModifiers = regex.toString().split('/')
126 regexWithoutModifiers.shift()
127 const modifiers = regexWithoutModifiers.pop()
128 const regexStartsWithAnchor = regexWithoutModifiers[0] && regexWithoutModifiers[0][0] === '^'
129 const pattern = regexWithoutModifiers.join('/')
130 const name = this.name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
131
132 if (regexStartsWithAnchor) {
133 this.logger.warning(`Anchors don’t work well with respond, perhaps you want to use 'hear'`)
134 this.logger.warning(`The regex in question was ${regex.toString()}`)
135 }
136
137 if (!this.alias) {
138 return new RegExp('^\\s*[@]?' + name + '[:,]?\\s*(?:' + pattern + ')', modifiers)
139 }
140
141 const alias = this.alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
142
143 // matches properly when alias is substring of name
144 if (name.length > alias.length) {
145 return new RegExp('^\\s*[@]?(?:' + name + '[:,]?|' + alias + '[:,]?)\\s*(?:' + pattern + ')', modifiers)
146 }
147
148 // matches properly when name is substring of alias
149 return new RegExp('^\\s*[@]?(?:' + alias + '[:,]?|' + name + '[:,]?)\\s*(?:' + pattern + ')', modifiers)
150 }
151
152 // Public: Adds a Listener that triggers when anyone enters the room.
153 //
154 // options - An Object of additional parameters keyed on extension name
155 // (optional).
156 // callback - A Function that is called with a Response object.
157 //
158 // Returns nothing.
159 enter (options, callback) {
160 this.listen(msg => msg instanceof Message.EnterMessage, options, callback)
161 }
162
163 // Public: Adds a Listener that triggers when anyone leaves the room.
164 //
165 // options - An Object of additional parameters keyed on extension name
166 // (optional).
167 // callback - A Function that is called with a Response object.
168 //
169 // Returns nothing.
170 leave (options, callback) {
171 this.listen(msg => msg instanceof Message.LeaveMessage, options, callback)
172 }
173
174 // Public: Adds a Listener that triggers when anyone changes the topic.
175 //
176 // options - An Object of additional parameters keyed on extension name
177 // (optional).
178 // callback - A Function that is called with a Response object.
179 //
180 // Returns nothing.
181 topic (options, callback) {
182 this.listen(msg => msg instanceof Message.TopicMessage, options, callback)
183 }
184
185 // Public: Adds an error handler when an uncaught exception or user emitted
186 // error event occurs.
187 //
188 // callback - A Function that is called with the error object.
189 //
190 // Returns nothing.
191 error (callback) {
192 this.errorHandlers.push(callback)
193 }
194
195 // Calls and passes any registered error handlers for unhandled exceptions or
196 // user emitted error events.
197 //
198 // err - An Error object.
199 // res - An optional Response object that generated the error
200 //
201 // Returns nothing.
202 invokeErrorHandlers (error, res) {
203 this.logger.error(error.stack)
204
205 this.errorHandlers.map((errorHandler) => {
206 try {
207 errorHandler(error, res)
208 } catch (errorHandlerError) {
209 this.logger.error(`while invoking error handler: ${errorHandlerError}\n${errorHandlerError.stack}`)
210 }
211 })
212 }
213
214 // Public: Adds a Listener that triggers when no other text matchers match.
215 //
216 // options - An Object of additional parameters keyed on extension name
217 // (optional).
218 // callback - A Function that is called with a Response object.
219 //
220 // Returns nothing.
221 catchAll (options, callback) {
222 // `options` is optional; need to isolate the real callback before
223 // wrapping it with logic below
224 if (callback == null) {
225 callback = options
226 options = {}
227 }
228
229 this.listen(isCatchAllMessage, options, function listenCallback (msg) {
230 msg.message = msg.message.message
231 callback(msg)
232 })
233 }
234
235 // Public: Registers new middleware for execution after matching but before
236 // Listener callbacks
237 //
238 // middleware - A function that determines whether or not a given matching
239 // Listener should be executed. The function is called with
240 // (context, next, done). If execution should
241 // continue (next middleware, Listener callback), the middleware
242 // should call the 'next' function with 'done' as an argument.
243 // If not, the middleware should call the 'done' function with
244 // no arguments.
245 //
246 // Returns nothing.
247 listenerMiddleware (middleware) {
248 this.middleware.listener.register(middleware)
249 }
250
251 // Public: Registers new middleware for execution as a response to any
252 // message is being sent.
253 //
254 // middleware - A function that examines an outgoing message and can modify
255 // it or prevent its sending. The function is called with
256 // (context, next, done). If execution should continue,
257 // the middleware should call next(done). If execution should
258 // stop, the middleware should call done(). To modify the
259 // outgoing message, set context.string to a new message.
260 //
261 // Returns nothing.
262 responseMiddleware (middleware) {
263 this.middleware.response.register(middleware)
264 }
265
266 // Public: Registers new middleware for execution before matching
267 //
268 // middleware - A function that determines whether or not listeners should be
269 // checked. The function is called with (context, next, done). If
270 // ext, next, done). If execution should continue to the next
271 // middleware or matching phase, it should call the 'next'
272 // function with 'done' as an argument. If not, the middleware
273 // should call the 'done' function with no arguments.
274 //
275 // Returns nothing.
276 receiveMiddleware (middleware) {
277 this.middleware.receive.register(middleware)
278 }
279
280 // Public: Passes the given message to any interested Listeners after running
281 // receive middleware.
282 //
283 // message - A Message instance. Listeners can flag this message as 'done' to
284 // prevent further execution.
285 //
286 // cb - Optional callback that is called when message processing is complete
287 //
288 // Returns nothing.
289 // Returns before executing callback
290 receive (message, cb) {
291 // When everything is finished (down the middleware stack and back up),
292 // pass control back to the robot
293 this.middleware.receive.execute({ response: new Response(this, message) }, this.processListeners.bind(this), cb)
294 }
295
296 // Private: Passes the given message to any interested Listeners.
297 //
298 // message - A Message instance. Listeners can flag this message as 'done' to
299 // prevent further execution.
300 //
301 // done - Optional callback that is called when message processing is complete
302 //
303 // Returns nothing.
304 // Returns before executing callback
305 processListeners (context, done) {
306 // Try executing all registered Listeners in order of registration
307 // and return after message is done being processed
308 let anyListenersExecuted = false
309
310 async.detectSeries(this.listeners, (listener, done) => {
311 try {
312 listener.call(context.response.message, this.middleware.listener, function (listenerExecuted) {
313 anyListenersExecuted = anyListenersExecuted || listenerExecuted
314 // Defer to the event loop at least after every listener so the
315 // stack doesn't get too big
316 process.nextTick(() =>
317 // Stop processing when message.done == true
318 done(context.response.message.done)
319 )
320 })
321 } catch (err) {
322 this.emit('error', err, new this.Response(this, context.response.message, []))
323 // Continue to next listener when there is an error
324 done(false)
325 }
326 },
327 // Ignore the result ( == the listener that set message.done = true)
328 _ => {
329 // If no registered Listener matched the message
330
331 if (!(context.response.message instanceof Message.CatchAllMessage) && !anyListenersExecuted) {
332 this.logger.debug('No listeners executed; falling back to catch-all')
333 this.receive(new Message.CatchAllMessage(context.response.message), done)
334 } else {
335 if (done != null) {
336 process.nextTick(done)
337 }
338 }
339 })
340 }
341
342 // Public: Loads a file in path.
343 //
344 // filepath - A String path on the filesystem.
345 // filename - A String filename in path on the filesystem.
346 //
347 // Returns nothing.
348 loadFile (filepath, filename) {
349 const ext = path.extname(filename)
350 const full = path.join(filepath, path.basename(filename, ext))
351
352 // see https://github.com/hubotio/hubot/issues/1355
353 if (!require.extensions[ext]) { // eslint-disable-line
354 return
355 }
356
357 try {
358 const script = require(full)
359
360 if (typeof script === 'function') {
361 script(this)
362 this.parseHelp(path.join(filepath, filename))
363 } else {
364 this.logger.warning(`Expected ${full} to assign a function to module.exports, got ${typeof script}`)
365 }
366 } catch (error) {
367 this.logger.error(`Unable to load ${full}: ${error.stack}`)
368 process.exit(1)
369 }
370 }
371
372 // Public: Loads every script in the given path.
373 //
374 // path - A String path on the filesystem.
375 //
376 // Returns nothing.
377 load (path) {
378 this.logger.debug(`Loading scripts from ${path}`)
379
380 if (fs.existsSync(path)) {
381 fs.readdirSync(path).sort().map(file => this.loadFile(path, file))
382 }
383 }
384
385 // Public: Load scripts specified in the `hubot-scripts.json` file.
386 //
387 // path - A String path to the hubot-scripts files.
388 // scripts - An Array of scripts to load.
389 //
390 // Returns nothing.
391 loadHubotScripts (path, scripts) {
392 this.logger.debug(`Loading hubot-scripts from ${path}`)
393 Array.from(scripts).map(script => this.loadFile(path, script))
394 }
395
396 // Public: Load scripts from packages specified in the
397 // `external-scripts.json` file.
398 //
399 // packages - An Array of packages containing hubot scripts to load.
400 //
401 // Returns nothing.
402 loadExternalScripts (packages) {
403 this.logger.debug('Loading external-scripts from npm packages')
404
405 try {
406 if (Array.isArray(packages)) {
407 return packages.forEach(pkg => require(pkg)(this))
408 }
409
410 Object.keys(packages).forEach(key => require(key)(this, packages[key]))
411 } catch (error) {
412 this.logger.error(`Error loading scripts from npm package - ${error.stack}`)
413 process.exit(1)
414 }
415 }
416
417 // Setup the Express server's defaults.
418 //
419 // Returns nothing.
420 setupExpress () {
421 const user = process.env.EXPRESS_USER
422 const pass = process.env.EXPRESS_PASSWORD
423 const stat = process.env.EXPRESS_STATIC
424 const port = process.env.EXPRESS_PORT || process.env.PORT || 8080
425 const address = process.env.EXPRESS_BIND_ADDRESS || process.env.BIND_ADDRESS || '0.0.0.0'
426 const limit = process.env.EXPRESS_LIMIT || '100kb'
427 const paramLimit = parseInt(process.env.EXPRESS_PARAMETER_LIMIT) || 1000
428
429 const express = require('express')
430 const multipart = require('connect-multiparty')
431
432 const app = express()
433
434 app.use((req, res, next) => {
435 res.setHeader('X-Powered-By', `hubot/${this.name}`)
436 return next()
437 })
438
439 if (user && pass) {
440 app.use(express.basicAuth(user, pass))
441 }
442 app.use(express.query())
443
444 app.use(express.json())
445 app.use(express.urlencoded({ limit, parameterLimit: paramLimit }))
446 // replacement for deprecated express.multipart/connect.multipart
447 // limit to 100mb, as per the old behavior
448 app.use(multipart({ maxFilesSize: 100 * 1024 * 1024 }))
449
450 if (stat) {
451 app.use(express.static(stat))
452 }
453
454 try {
455 this.server = app.listen(port, address)
456 this.router = app
457 } catch (error) {
458 const err = error
459 this.logger.error(`Error trying to start HTTP server: ${err}\n${err.stack}`)
460 process.exit(1)
461 }
462
463 let herokuUrl = process.env.HEROKU_URL
464
465 if (herokuUrl) {
466 if (!/\/$/.test(herokuUrl)) {
467 herokuUrl += '/'
468 }
469 this.pingIntervalId = setInterval(() => {
470 HttpClient.create(`${herokuUrl}hubot/ping`).post()((_err, res, body) => {
471 this.logger.info('keep alive ping!')
472 })
473 }, 5 * 60 * 1000)
474 }
475 }
476
477 // Setup an empty router object
478 //
479 // returns nothing
480 setupNullRouter () {
481 const msg = 'A script has tried registering a HTTP route while the HTTP server is disabled with --disabled-httpd.'
482
483 this.router = {
484 get: () => this.logger.warning(msg),
485 post: () => this.logger.warning(msg),
486 put: () => this.logger.warning(msg),
487 delete: () => this.logger.warning(msg)
488 }
489 }
490
491 // Load the adapter Hubot is going to use.
492 //
493 // path - A String of the path to adapter if local.
494 // adapter - A String of the adapter name to use.
495 //
496 // Returns nothing.
497 loadAdapter (adapter) {
498 this.logger.debug(`Loading adapter ${adapter}`)
499
500 try {
501 const path = Array.from(HUBOT_DEFAULT_ADAPTERS).indexOf(adapter) !== -1 ? `${this.adapterPath}/${adapter}` : `hubot-${adapter}`
502
503 this.adapter = require(path).use(this)
504 } catch (err) {
505 this.logger.error(`Cannot load adapter ${adapter} - ${err}`)
506 process.exit(1)
507 }
508 }
509
510 // Public: Help Commands for Running Scripts.
511 //
512 // Returns an Array of help commands for running scripts.
513 helpCommands () {
514 return this.commands.sort()
515 }
516
517 // Private: load help info from a loaded script.
518 //
519 // path - A String path to the file on disk.
520 //
521 // Returns nothing.
522 parseHelp (path) {
523 const scriptDocumentation = {}
524 const body = fs.readFileSync(require.resolve(path), 'utf-8')
525
526 const useStrictHeaderRegex = /^["']use strict['"];?\s+/
527 const lines = body.replace(useStrictHeaderRegex, '').split('\n')
528 .reduce(toHeaderCommentBlock, {lines: [], isHeader: true}).lines
529 .filter(Boolean) // remove empty lines
530 let currentSection = null
531 let nextSection
532
533 this.logger.debug(`Parsing help for ${path}`)
534
535 for (let i = 0, line; i < lines.length; i++) {
536 line = lines[i]
537
538 if (line.toLowerCase() === 'none') {
539 continue
540 }
541
542 nextSection = line.toLowerCase().replace(':', '')
543 if (Array.from(HUBOT_DOCUMENTATION_SECTIONS).indexOf(nextSection) !== -1) {
544 currentSection = nextSection
545 scriptDocumentation[currentSection] = []
546 } else {
547 if (currentSection) {
548 scriptDocumentation[currentSection].push(line)
549 if (currentSection === 'commands') {
550 this.commands.push(line)
551 }
552 }
553 }
554 }
555
556 if (currentSection === null) {
557 this.logger.info(`${path} is using deprecated documentation syntax`)
558 scriptDocumentation.commands = []
559 for (let i = 0, line, cleanedLine; i < lines.length; i++) {
560 line = lines[i]
561 if (line.match('-')) {
562 continue
563 }
564
565 cleanedLine = line.slice(2, +line.length + 1 || 9e9).replace(/^hubot/i, this.name).trim()
566 scriptDocumentation.commands.push(cleanedLine)
567 this.commands.push(cleanedLine)
568 }
569 }
570 }
571
572 // Public: A helper send function which delegates to the adapter's send
573 // function.
574 //
575 // envelope - A Object with message, room and user details.
576 // strings - One or more Strings for each message to send.
577 //
578 // Returns nothing.
579 send (envelope/* , ...strings */) {
580 const strings = [].slice.call(arguments, 1)
581
582 this.adapter.send.apply(this.adapter, [envelope].concat(strings))
583 }
584
585 // Public: A helper reply function which delegates to the adapter's reply
586 // function.
587 //
588 // envelope - A Object with message, room and user details.
589 // strings - One or more Strings for each message to send.
590 //
591 // Returns nothing.
592 reply (envelope/* , ...strings */) {
593 const strings = [].slice.call(arguments, 1)
594
595 this.adapter.reply.apply(this.adapter, [envelope].concat(strings))
596 }
597
598 // Public: A helper send function to message a room that the robot is in.
599 //
600 // room - String designating the room to message.
601 // strings - One or more Strings for each message to send.
602 //
603 // Returns nothing.
604 messageRoom (room/* , ...strings */) {
605 const strings = [].slice.call(arguments, 1)
606 const envelope = { room }
607
608 this.adapter.send.apply(this.adapter, [envelope].concat(strings))
609 }
610
611 // Public: A wrapper around the EventEmitter API to make usage
612 // semantically better.
613 //
614 // event - The event name.
615 // listener - A Function that is called with the event parameter
616 // when event happens.
617 //
618 // Returns nothing.
619 on (event/* , ...args */) {
620 const args = [].slice.call(arguments, 1)
621
622 this.events.on.apply(this.events, [event].concat(args))
623 }
624
625 // Public: A wrapper around the EventEmitter API to make usage
626 // semantically better.
627 //
628 // event - The event name.
629 // args... - Arguments emitted by the event
630 //
631 // Returns nothing.
632 emit (event/* , ...args */) {
633 const args = [].slice.call(arguments, 1)
634
635 this.events.emit.apply(this.events, [event].concat(args))
636 }
637
638 // Public: Kick off the event loop for the adapter
639 //
640 // Returns nothing.
641 run () {
642 this.emit('running')
643
644 this.adapter.run()
645 }
646
647 // Public: Gracefully shutdown the robot process
648 //
649 // Returns nothing.
650 shutdown () {
651 if (this.pingIntervalId != null) {
652 clearInterval(this.pingIntervalId)
653 }
654 process.removeListener('uncaughtException', this.onUncaughtException)
655 this.adapter.close()
656 if (this.server) {
657 this.server.close()
658 }
659
660 this.brain.close()
661 }
662
663 // Public: The version of Hubot from npm
664 //
665 // Returns a String of the version number.
666 parseVersion () {
667 const pkg = require(path.join(__dirname, '..', 'package.json'))
668 this.version = pkg.version
669
670 return this.version
671 }
672
673 // Public: Creates a scoped http client with chainable methods for
674 // modifying the request. This doesn't actually make a request though.
675 // Once your request is assembled, you can call `get()`/`post()`/etc to
676 // send the request.
677 //
678 // url - String URL to access.
679 // options - Optional options to pass on to the client
680 //
681 // Examples:
682 //
683 // robot.http("http://example.com")
684 // # set a single header
685 // .header('Authorization', 'bearer abcdef')
686 //
687 // # set multiple headers
688 // .headers(Authorization: 'bearer abcdef', Accept: 'application/json')
689 //
690 // # add URI query parameters
691 // .query(a: 1, b: 'foo & bar')
692 //
693 // # make the actual request
694 // .get() (err, res, body) ->
695 // console.log body
696 //
697 // # or, you can POST data
698 // .post(data) (err, res, body) ->
699 // console.log body
700 //
701 // # Can also set options
702 // robot.http("https://example.com", {rejectUnauthorized: false})
703 //
704 // Returns a ScopedClient instance.
705 http (url, options) {
706 const httpOptions = extend({}, this.globalHttpOptions, options)
707
708 return HttpClient.create(url, httpOptions).header('User-Agent', `Hubot/${this.version}`)
709 }
710}
711
712module.exports = Robot
713
714function isCatchAllMessage (message) {
715 return message instanceof Message.CatchAllMessage
716}
717
718function toHeaderCommentBlock (block, currentLine) {
719 if (!block.isHeader) {
720 return block
721 }
722
723 if (isCommentLine(currentLine)) {
724 block.lines.push(removeCommentPrefix(currentLine))
725 } else {
726 block.isHeader = false
727 }
728
729 return block
730}
731
732function isCommentLine (line) {
733 return /^(#|\/\/)/.test(line)
734}
735
736function removeCommentPrefix (line) {
737 return line.replace(/^[#/]+\s*/, '')
738}
739
740function extend (obj/* , ...sources */) {
741 const sources = [].slice.call(arguments, 1)
742
743 sources.forEach((source) => {
744 if (typeof source !== 'object') {
745 return
746 }
747
748 Object.keys(source).forEach((key) => {
749 obj[key] = source[key]
750 })
751 })
752
753 return obj
754}