Permalink
756 lines (659 sloc)
23.5 KB
| 'use strict' | |
| const EventEmitter = require('events').EventEmitter | |
| const fs = require('fs') | |
| const path = require('path') | |
| const async = require('async') | |
| const Log = require('log') | |
| const HttpClient = require('scoped-http-client') | |
| const Brain = require('./brain') | |
| const Response = require('./response') | |
| const Listener = require('./listener') | |
| const Message = require('./message') | |
| const Middleware = require('./middleware') | |
| const HUBOT_DEFAULT_ADAPTERS = ['campfire', 'shell'] | |
| const HUBOT_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 built-in adapters (defaults to src/adapters) | |
| // adapter - A String of the adapter name. | |
| // httpd - A Boolean whether to enable the HTTP daemon. | |
| // name - A String of the robot name, defaults to Hubot. | |
| // alias - A String of the alias of the robot name | |
| constructor (adapterPath, adapter, httpd, name, alias) { | |
| if (name == null) { | |
| name = 'Hubot' | |
| } | |
| if (alias == null) { | |
| alias = false | |
| } | |
| this.adapterPath = path.join(__dirname, 'adapters') | |
| this.name = name | |
| this.events = new EventEmitter() | |
| this.brain = new Brain(this) | |
| this.alias = alias | |
| this.adapter = null | |
| this.datastore = null | |
| this.Response = Response | |
| this.commands = [] | |
| this.listeners = [] | |
| this.middleware = { | |
| listener: new Middleware(this), | |
| response: new Middleware(this), | |
| receive: new Middleware(this) | |
| } | |
| this.logger = new Log(process.env.HUBOT_LOG_LEVEL || 'info') | |
| this.pingIntervalId = null | |
| this.globalHttpOptions = {} | |
| this.parseVersion() | |
| if (httpd) { | |
| this.setupExpress() | |
| } else { | |
| this.setupNullRouter() | |
| } | |
| this.loadAdapter(adapter) | |
| this.adapterName = adapter | |
| this.errorHandlers = [] | |
| this.on('error', (err, res) => { | |
| return this.invokeErrorHandlers(err, res) | |
| }) | |
| this.onUncaughtException = err => { | |
| return this.emit('error', err) | |
| } | |
| process.on('uncaughtException', this.onUncaughtException) | |
| } | |
| // Public: Adds a custom Listener with the provided matcher, options, and | |
| // callback | |
| // | |
| // matcher - A Function that determines whether to call the callback. | |
| // Expected to return a truthy value if the callback should be | |
| // executed. | |
| // options - An Object of additional parameters keyed on extension name | |
| // (optional). | |
| // callback - A Function that is called with a Response object if the | |
| // matcher function returns true. | |
| // | |
| // Returns nothing. | |
| listen (matcher, options, callback) { | |
| this.listeners.push(new Listener.Listener(this, matcher, options, callback)) | |
| } | |
| // 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. | |
| // options - An Object of additional parameters keyed on extension name | |
| // (optional). | |
| // callback - A Function that is called with a Response object. | |
| // | |
| // Returns nothing. | |
| hear (regex, options, callback) { | |
| this.listeners.push(new Listener.TextListener(this, regex, options, 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. | |
| // options - An Object of additional parameters keyed on extension name | |
| // (optional). | |
| // callback - A Function that is called with a Response object. | |
| // | |
| // Returns nothing. | |
| respond (regex, options, callback) { | |
| this.hear(this.respondPattern(regex), options, callback) | |
| } | |
| // Public: Build a regular expression that matches messages addressed | |
| // directly to the robot | |
| // | |
| // regex - A RegExp for the message part that follows the robot's name/alias | |
| // | |
| // Returns RegExp. | |
| respondPattern (regex) { | |
| const regexWithoutModifiers = regex.toString().split('/') | |
| regexWithoutModifiers.shift() | |
| const modifiers = regexWithoutModifiers.pop() | |
| const regexStartsWithAnchor = regexWithoutModifiers[0] && regexWithoutModifiers[0][0] === '^' | |
| const pattern = regexWithoutModifiers.join('/') | |
| const name = this.name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') | |
| if (regexStartsWithAnchor) { | |
| this.logger.warning(`Anchors don’t work well with respond, perhaps you want to use 'hear'`) | |
| this.logger.warning(`The regex in question was ${regex.toString()}`) | |
| } | |
| if (!this.alias) { | |
| return new RegExp('^\\s*[@]?' + name + '[:,]?\\s*(?:' + pattern + ')', modifiers) | |
| } | |
| const alias = this.alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') | |
| // matches properly when alias is substring of name | |
| if (name.length > alias.length) { | |
| return new RegExp('^\\s*[@]?(?:' + name + '[:,]?|' + alias + '[:,]?)\\s*(?:' + pattern + ')', modifiers) | |
| } | |
| // matches properly when name is substring of alias | |
| return new RegExp('^\\s*[@]?(?:' + alias + '[:,]?|' + name + '[:,]?)\\s*(?:' + pattern + ')', modifiers) | |
| } | |
| // Public: Adds a Listener that triggers when anyone enters the room. | |
| // | |
| // options - An Object of additional parameters keyed on extension name | |
| // (optional). | |
| // callback - A Function that is called with a Response object. | |
| // | |
| // Returns nothing. | |
| enter (options, callback) { | |
| this.listen(msg => msg instanceof Message.EnterMessage, options, callback) | |
| } | |
| // Public: Adds a Listener that triggers when anyone leaves the room. | |
| // | |
| // options - An Object of additional parameters keyed on extension name | |
| // (optional). | |
| // callback - A Function that is called with a Response object. | |
| // | |
| // Returns nothing. | |
| leave (options, callback) { | |
| this.listen(msg => msg instanceof Message.LeaveMessage, options, callback) | |
| } | |
| // Public: Adds a Listener that triggers when anyone changes the topic. | |
| // | |
| // options - An Object of additional parameters keyed on extension name | |
| // (optional). | |
| // callback - A Function that is called with a Response object. | |
| // | |
| // Returns nothing. | |
| topic (options, callback) { | |
| this.listen(msg => msg instanceof Message.TopicMessage, options, 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) { | |
| this.errorHandlers.push(callback) | |
| } | |
| // Calls and passes any registered error handlers for unhandled exceptions or | |
| // user emitted error events. | |
| // | |
| // err - An Error object. | |
| // res - An optional Response object that generated the error | |
| // | |
| // Returns nothing. | |
| invokeErrorHandlers (error, res) { | |
| this.logger.error(error.stack) | |
| this.errorHandlers.map((errorHandler) => { | |
| try { | |
| errorHandler(error, res) | |
| } catch (errorHandlerError) { | |
| this.logger.error(`while invoking error handler: ${errorHandlerError}\n${errorHandlerError.stack}`) | |
| } | |
| }) | |
| } | |
| // Public: Adds a Listener that triggers when no other text matchers match. | |
| // | |
| // options - An Object of additional parameters keyed on extension name | |
| // (optional). | |
| // callback - A Function that is called with a Response object. | |
| // | |
| // Returns nothing. | |
| catchAll (options, callback) { | |
| // `options` is optional; need to isolate the real callback before | |
| // wrapping it with logic below | |
| if (callback == null) { | |
| callback = options | |
| options = {} | |
| } | |
| this.listen(isCatchAllMessage, options, function listenCallback (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 | |
| // (context, 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. | |
| listenerMiddleware (middleware) { | |
| this.middleware.listener.register(middleware) | |
| } | |
| // Public: Registers new middleware for execution as a response to any | |
| // message is being sent. | |
| // | |
| // middleware - A function that examines an outgoing message and can modify | |
| // it or prevent its sending. The function is called with | |
| // (context, next, done). If execution should continue, | |
| // the middleware should call next(done). If execution should | |
| // stop, the middleware should call done(). To modify the | |
| // outgoing message, set context.string to a new message. | |
| // | |
| // Returns nothing. | |
| responseMiddleware (middleware) { | |
| this.middleware.response.register(middleware) | |
| } | |
| // Public: Registers new middleware for execution before matching | |
| // | |
| // middleware - A function that determines whether or not listeners should be | |
| // checked. The function is called with (context, next, done). If | |
| // ext, next, done). If execution should continue to the next | |
| // middleware or matching phase, it should call the 'next' | |
| // function with 'done' as an argument. If not, the middleware | |
| // should call the 'done' function with no arguments. | |
| // | |
| // Returns nothing. | |
| receiveMiddleware (middleware) { | |
| this.middleware.receive.register(middleware) | |
| } | |
| // Public: Passes the given message to any interested Listeners after running | |
| // receive middleware. | |
| // | |
| // message - A Message instance. Listeners can flag this message as 'done' to | |
| // prevent further execution. | |
| // | |
| // cb - Optional callback that is called when message processing is complete | |
| // | |
| // Returns nothing. | |
| // Returns before executing callback | |
| receive (message, cb) { | |
| // When everything is finished (down the middleware stack and back up), | |
| // pass control back to the robot | |
| this.middleware.receive.execute({ response: new Response(this, message) }, this.processListeners.bind(this), cb) | |
| } | |
| // Private: Passes the given message to any interested Listeners. | |
| // | |
| // message - A Message instance. Listeners can flag this message as 'done' to | |
| // prevent further execution. | |
| // | |
| // done - Optional callback that is called when message processing is complete | |
| // | |
| // Returns nothing. | |
| // Returns before executing callback | |
| processListeners (context, done) { | |
| // Try executing all registered Listeners in order of registration | |
| // and return after message is done being processed | |
| let anyListenersExecuted = false | |
| async.detectSeries(this.listeners, (listener, done) => { | |
| try { | |
| listener.call(context.response.message, this.middleware.listener, function (listenerExecuted) { | |
| anyListenersExecuted = anyListenersExecuted || listenerExecuted | |
| // Defer to the event loop at least after every listener so the | |
| // stack doesn't get too big | |
| process.nextTick(() => | |
| // Stop processing when message.done == true | |
| done(context.response.message.done) | |
| ) | |
| }) | |
| } catch (err) { | |
| this.emit('error', err, new this.Response(this, context.response.message, [])) | |
| // Continue to next listener when there is an error | |
| done(false) | |
| } | |
| }, | |
| // Ignore the result ( == the listener that set message.done = true) | |
| _ => { | |
| // If no registered Listener matched the message | |
| if (!(context.response.message instanceof Message.CatchAllMessage) && !anyListenersExecuted) { | |
| this.logger.debug('No listeners executed; falling back to catch-all') | |
| this.receive(new Message.CatchAllMessage(context.response.message), done) | |
| } else { | |
| if (done != null) { | |
| process.nextTick(done) | |
| } | |
| } | |
| }) | |
| } | |
| // Public: Loads a file in path. | |
| // | |
| // filepath - A String path on the filesystem. | |
| // filename - A String filename in path on the filesystem. | |
| // | |
| // Returns nothing. | |
| loadFile (filepath, filename) { | |
| const ext = path.extname(filename) | |
| const full = path.join(filepath, path.basename(filename, ext)) | |
| // see https://github.com/hubotio/hubot/issues/1355 | |
| if (!require.extensions[ext]) { // eslint-disable-line | |
| return | |
| } | |
| try { | |
| const script = require(full) | |
| if (typeof script === 'function') { | |
| script(this) | |
| this.parseHelp(path.join(filepath, filename)) | |
| } else { | |
| this.logger.warning(`Expected ${full} to assign a function to module.exports, got ${typeof script}`) | |
| } | |
| } catch (error) { | |
| this.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) { | |
| this.logger.debug(`Loading scripts from ${path}`) | |
| if (fs.existsSync(path)) { | |
| fs.readdirSync(path).sort().map(file => this.loadFile(path, file)) | |
| } | |
| } | |
| // Public: Load scripts specified in the `hubot-scripts.json` file. | |
| // | |
| // path - A String path to the hubot-scripts files. | |
| // scripts - An Array of scripts to load. | |
| // | |
| // Returns nothing. | |
| loadHubotScripts (path, scripts) { | |
| this.logger.debug(`Loading hubot-scripts from ${path}`) | |
| Array.from(scripts).map(script => this.loadFile(path, script)) | |
| } | |
| // Public: Load scripts from packages specified in the | |
| // `external-scripts.json` file. | |
| // | |
| // packages - An Array of packages containing hubot scripts to load. | |
| // | |
| // Returns nothing. | |
| loadExternalScripts (packages) { | |
| this.logger.debug('Loading external-scripts from npm packages') | |
| try { | |
| if (Array.isArray(packages)) { | |
| return packages.forEach(pkg => require(pkg)(this)) | |
| } | |
| Object.keys(packages).forEach(key => require(key)(this, packages[key])) | |
| } catch (error) { | |
| this.logger.error(`Error loading scripts from npm package - ${error.stack}`) | |
| process.exit(1) | |
| } | |
| } | |
| // Setup the Express server's defaults. | |
| // | |
| // Returns nothing. | |
| setupExpress () { | |
| const user = process.env.EXPRESS_USER | |
| const pass = process.env.EXPRESS_PASSWORD | |
| const stat = process.env.EXPRESS_STATIC | |
| const port = process.env.EXPRESS_PORT || process.env.PORT || 8080 | |
| const address = process.env.EXPRESS_BIND_ADDRESS || process.env.BIND_ADDRESS || '0.0.0.0' | |
| const limit = process.env.EXPRESS_LIMIT || '100kb' | |
| const paramLimit = parseInt(process.env.EXPRESS_PARAMETER_LIMIT) || 1000 | |
| const express = require('express') | |
| const multipart = require('connect-multiparty') | |
| const app = express() | |
| app.use((req, res, next) => { | |
| res.setHeader('X-Powered-By', `hubot/${this.name}`) | |
| return next() | |
| }) | |
| if (user && pass) { | |
| app.use(express.basicAuth(user, pass)) | |
| } | |
| app.use(express.query()) | |
| app.use(express.json()) | |
| app.use(express.urlencoded({ limit, parameterLimit: paramLimit, extended: true })) | |
| // replacement for deprecated express.multipart/connect.multipart | |
| // limit to 100mb, as per the old behavior | |
| app.use(multipart({ maxFilesSize: 100 * 1024 * 1024 })) | |
| if (stat) { | |
| app.use(express.static(stat)) | |
| } | |
| try { | |
| this.server = app.listen(port, address) | |
| this.router = app | |
| } catch (error) { | |
| const err = error | |
| this.logger.error(`Error trying to start HTTP server: ${err}\n${err.stack}`) | |
| process.exit(1) | |
| } | |
| let herokuUrl = process.env.HEROKU_URL | |
| if (herokuUrl) { | |
| if (!/\/$/.test(herokuUrl)) { | |
| herokuUrl += '/' | |
| } | |
| this.pingIntervalId = setInterval(() => { | |
| HttpClient.create(`${herokuUrl}hubot/ping`).post()((_err, res, body) => { | |
| this.logger.info('keep alive ping!') | |
| }) | |
| }, 5 * 60 * 1000) | |
| } | |
| } | |
| // Setup an empty router object | |
| // | |
| // returns nothing | |
| setupNullRouter () { | |
| const msg = 'A script has tried registering a HTTP route while the HTTP server is disabled with --disabled-httpd.' | |
| this.router = { | |
| get: () => this.logger.warning(msg), | |
| post: () => this.logger.warning(msg), | |
| put: () => this.logger.warning(msg), | |
| delete: () => this.logger.warning(msg) | |
| } | |
| } | |
| // Load the adapter Hubot is going to use. | |
| // | |
| // path - A String of the path to adapter if local. | |
| // adapter - A String of the adapter name to use. | |
| // | |
| // Returns nothing. | |
| loadAdapter (adapter) { | |
| this.logger.debug(`Loading adapter ${adapter}`) | |
| try { | |
| const path = Array.from(HUBOT_DEFAULT_ADAPTERS).indexOf(adapter) !== -1 ? `${this.adapterPath}/${adapter}` : `hubot-${adapter}` | |
| this.adapter = require(path).use(this) | |
| } catch (err) { | |
| this.logger.error(`Cannot load adapter ${adapter} - ${err}`) | |
| process.exit(1) | |
| } | |
| } | |
| // Public: Help Commands for Running Scripts. | |
| // | |
| // Returns an Array of help commands for running scripts. | |
| helpCommands () { | |
| return this.commands.sort() | |
| } | |
| // Private: load help info from a loaded script. | |
| // | |
| // path - A String path to the file on disk. | |
| // | |
| // Returns nothing. | |
| parseHelp (path) { | |
| const scriptDocumentation = {} | |
| const body = fs.readFileSync(require.resolve(path), 'utf-8') | |
| const useStrictHeaderRegex = /^["']use strict['"];?\s+/ | |
| const lines = body.replace(useStrictHeaderRegex, '').split(/(?:\n|\r\n|\r)/) | |
| .reduce(toHeaderCommentBlock, {lines: [], isHeader: true}).lines | |
| .filter(Boolean) // remove empty lines | |
| let currentSection = null | |
| let nextSection | |
| this.logger.debug(`Parsing help for ${path}`) | |
| for (let i = 0, line; i < lines.length; i++) { | |
| line = lines[i] | |
| if (line.toLowerCase() === 'none') { | |
| continue | |
| } | |
| nextSection = line.toLowerCase().replace(':', '') | |
| if (Array.from(HUBOT_DOCUMENTATION_SECTIONS).indexOf(nextSection) !== -1) { | |
| currentSection = nextSection | |
| scriptDocumentation[currentSection] = [] | |
| } else { | |
| if (currentSection) { | |
| scriptDocumentation[currentSection].push(line) | |
| if (currentSection === 'commands') { | |
| this.commands.push(line) | |
| } | |
| } | |
| } | |
| } | |
| if (currentSection === null) { | |
| this.logger.info(`${path} is using deprecated documentation syntax`) | |
| scriptDocumentation.commands = [] | |
| for (let i = 0, line, cleanedLine; i < lines.length; i++) { | |
| line = lines[i] | |
| if (line.match('-')) { | |
| continue | |
| } | |
| cleanedLine = line.slice(2, +line.length + 1 || 9e9).replace(/^hubot/i, this.name).trim() | |
| scriptDocumentation.commands.push(cleanedLine) | |
| this.commands.push(cleanedLine) | |
| } | |
| } | |
| } | |
| // Public: A helper send function which delegates to the adapter's send | |
| // function. | |
| // | |
| // envelope - A Object with message, room and user details. | |
| // strings - One or more Strings for each message to send. | |
| // | |
| // Returns nothing. | |
| send (envelope/* , ...strings */) { | |
| const strings = [].slice.call(arguments, 1) | |
| this.adapter.send.apply(this.adapter, [envelope].concat(strings)) | |
| } | |
| // Public: A helper reply function which delegates to the adapter's reply | |
| // function. | |
| // | |
| // envelope - A Object with message, room and user details. | |
| // strings - One or more Strings for each message to send. | |
| // | |
| // Returns nothing. | |
| reply (envelope/* , ...strings */) { | |
| const strings = [].slice.call(arguments, 1) | |
| this.adapter.reply.apply(this.adapter, [envelope].concat(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 */) { | |
| const strings = [].slice.call(arguments, 1) | |
| const envelope = { room } | |
| this.adapter.send.apply(this.adapter, [envelope].concat(strings)) | |
| } | |
| // Public: A wrapper around the EventEmitter API to make usage | |
| // semantically better. | |
| // | |
| // event - The event name. | |
| // listener - A Function that is called with the event parameter | |
| // when event happens. | |
| // | |
| // Returns nothing. | |
| on (event/* , ...args */) { | |
| const args = [].slice.call(arguments, 1) | |
| this.events.on.apply(this.events, [event].concat(args)) | |
| } | |
| // Public: A wrapper around the EventEmitter API to make usage | |
| // semantically better. | |
| // | |
| // event - The event name. | |
| // args... - Arguments emitted by the event | |
| // | |
| // Returns nothing. | |
| emit (event/* , ...args */) { | |
| const args = [].slice.call(arguments, 1) | |
| this.events.emit.apply(this.events, [event].concat(args)) | |
| } | |
| // Public: Kick off the event loop for the adapter | |
| // | |
| // Returns nothing. | |
| run () { | |
| this.emit('running') | |
| this.adapter.run() | |
| } | |
| // Public: Gracefully shutdown the robot process | |
| // | |
| // Returns nothing. | |
| shutdown () { | |
| if (this.pingIntervalId != null) { | |
| clearInterval(this.pingIntervalId) | |
| } | |
| process.removeListener('uncaughtException', this.onUncaughtException) | |
| this.adapter.close() | |
| if (this.server) { | |
| this.server.close() | |
| } | |
| this.brain.close() | |
| } | |
| // Public: The version of Hubot from npm | |
| // | |
| // Returns a String of the version number. | |
| parseVersion () { | |
| const pkg = require(path.join(__dirname, '..', 'package.json')) | |
| this.version = pkg.version | |
| return this.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. | |
| // options - Optional options to pass on to the client | |
| // | |
| // Examples: | |
| // | |
| // robot.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 | |
| // | |
| // # Can also set options | |
| // robot.http("https://example.com", {rejectUnauthorized: false}) | |
| // | |
| // Returns a ScopedClient instance. | |
| http (url, options) { | |
| const httpOptions = extend({}, this.globalHttpOptions, options) | |
| return HttpClient.create(url, httpOptions).header('User-Agent', `Hubot/${this.version}`) | |
| } | |
| } | |
| module.exports = Robot | |
| function isCatchAllMessage (message) { | |
| return message instanceof Message.CatchAllMessage | |
| } | |
| function toHeaderCommentBlock (block, currentLine) { | |
| if (!block.isHeader) { | |
| return block | |
| } | |
| if (isCommentLine(currentLine)) { | |
| block.lines.push(removeCommentPrefix(currentLine)) | |
| } else { | |
| block.isHeader = false | |
| } | |
| return block | |
| } | |
| function isCommentLine (line) { | |
| return /^(#|\/\/)/.test(line) | |
| } | |
| function removeCommentPrefix (line) { | |
| return line.replace(/^[#/]+\s*/, '') | |
| } | |
| function extend (obj/* , ...sources */) { | |
| const sources = [].slice.call(arguments, 1) | |
| sources.forEach((source) => { | |
| if (typeof source !== 'object') { | |
| return | |
| } | |
| Object.keys(source).forEach((key) => { | |
| obj[key] = source[key] | |
| }) | |
| }) | |
| return obj | |
| } |