UNPKG

hubot

Version:

A simple helpful robot for your Company

796 lines (709 loc) 25.2 kB
'use strict' import EventEmitter from 'node:events' import fs from 'node:fs' import path from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' import pino from 'pino' import HttpClient from './HttpClient.mjs' import Brain from './Brain.mjs' import Response from './Response.mjs' import { Listener, TextListener } from './Listener.mjs' import Message from './Message.mjs' import Middleware from './Middleware.mjs' const File = fs.promises const HUBOT_DEFAULT_ADAPTERS = ['Campfire', 'Shell'] const HUBOT_DOCUMENTATION_SECTIONS = ['description', 'dependencies', 'configuration', 'commands', 'notes', 'author', 'authors', 'examples', 'tags', 'urls'] const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) class Robot { // Robots receive messages from a chat source (Campfire, irc, etc), and // dispatch them to matching listeners. // // 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 (adapter, httpd, name, alias) { if (name == null) { name = 'Hubot' } if (alias == null) { alias = false } this.name = name this.events = new EventEmitter() this.brain = new Brain(this) this.alias = alias this.adapter = null this.adapterName = 'Shell' if (adapter && typeof (adapter) === 'object') { this.adapter = adapter this.adapterName = adapter.name ?? adapter.constructor.name } else { this.adapterName = adapter ?? this.adapterName } this.shouldEnableHttpd = httpd ?? true 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 = pino({ name, level: process.env.HUBOT_LOG_LEVEL || 'info' }) this.pingIntervalId = null this.globalHttpOptions = {} this.parseVersion() this.errorHandlers = [] this.on('error', (err, res) => { return this.invokeErrorHandlers(err, res) }) this.on('listening', this.herokuKeepalive.bind(this)) } // 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(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 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.warn('Anchors don’t work well with respond, perhaps you want to use \'hear\'') this.logger.warn(`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.forEach((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, async msg => { await 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). If execution should, the middleware should return // true. If not, the middleware should return false. // // 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). If execution should continue, return true // otherwise return false to stop. 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). If execution // should continue to the next // middleware or matching phase, it should return true or nothing // otherwise return false to stop. // // 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. // // Returns array of results from listeners. async receive (message) { const context = { response: new Response(this, message) } const shouldContinue = await this.middleware.receive.execute(context) if (shouldContinue === false) return null return await this.processListeners(context) } // Private: Passes the given message to any interested Listeners. // // message - A Message instance. Listeners can flag this message as 'done' to // prevent further execution. // // Returns array of results from listeners. async processListeners (context) { // Try executing all registered Listeners in order of registration // and return after message is done being processed const results = [] let anyListenersExecuted = false for await (const listener of this.listeners) { try { const match = listener.matcher(context.response.message) if (!match) { continue } const result = await listener.call(context.response.message, this.middleware.listener) results.push(result) anyListenersExecuted = true } catch (err) { this.emit('error', err, context) } if (context.response.message.done) { break } } if (!isCatchAllMessage(context.response.message) && !anyListenersExecuted) { this.logger.debug('No listeners executed; falling back to catch-all') try { const result = await this.receive(new Message.CatchAllMessage(context.response.message)) results.push(result) } catch (err) { this.emit('error', err, context) } } return results } async loadmjs (filePath) { const forImport = this.prepareForImport(filePath) const script = await import(forImport) let result = null if (typeof script?.default === 'function') { result = await script.default(this) } else { this.logger.warn(`Expected ${filePath} (after preparing for import ${forImport}) to assign a function to export default, got ${typeof script}`) } return result } async loadts (filePath) { return this.loadmjs(filePath) } async loadjs (filePath) { const forImport = this.prepareForImport(filePath) const script = (await import(forImport)).default let result = null if (typeof script === 'function') { result = await script(this) } else { this.logger.warn(`Expected ${filePath} (after preparing for import ${forImport}) to assign a function to module.exports, got ${typeof script}`) } return result } // Public: Loads a file in path. // // filepath - A String path on the filesystem. // filename - A String filename in path on the filesystem. // // Returns nothing. async loadFile (filepath, filename) { const ext = path.extname(filename)?.replace('.', '') const full = path.join(filepath, path.basename(filename)) // see https://github.com/hubotio/hubot/issues/1355 if (['js', 'mjs', 'ts'].indexOf(ext) === -1) { this.logger.debug(`Skipping unsupported file type ${full}`) return null } let result = null try { result = await this[`load${ext}`](full) this.parseHelp(full) } catch (error) { this.logger.error(`Unable to load ${full}: ${error.stack}`) throw error } return result } // Public: Loads every script in the given path. // // path - A String path on the filesystem. // // Returns nothing. async load (path) { this.logger.debug(`Loading scripts from ${path}`) const results = [] try { const folder = await File.readdir(path, { withFileTypes: true }) for await (const file of folder) { if (file.isDirectory()) continue try { const result = await this.loadFile(path, file.name) results.push(result) } catch (e) { this.logger.error(`Error loading file ${file.name} - ${e.stack}`) } } } catch (e) { this.logger.error(`Path ${path} does not exist`) } return results } // Public: Load scripts from packages specified in the // `external-scripts.json` file. // // packages - An Array of packages containing hubot scripts to load. // // Returns nothing. async loadExternalScripts (packages) { this.logger.debug('Loading external-scripts from npm packages') try { if (Array.isArray(packages)) { for await (const pkg of packages) { (await import(pkg)).default(this) } return } for await (const key of Object.keys(packages)) { (await import(key)).default(this, packages[key]) } } catch (error) { this.logger.error(`Error loading scripts from npm package - ${error.stack}`) throw error } } // Setup the Express server's defaults. // // Returns Server. async 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 = (await import('express')).default const basicAuth = (await import('express-basic-auth')).default const app = express() app.use((req, res, next) => { res.setHeader('X-Powered-By', `hubot/${encodeURI(this.name)}`) return next() }) if (user && pass) { const authUser = {} authUser[user] = pass app.use(basicAuth({ users: authUser })) } app.use(express.json({ limit })) app.use(express.urlencoded({ limit, parameterLimit: paramLimit, extended: true })) if (stat) { app.use(express.static(stat)) } return new Promise((resolve, reject) => { try { this.server = app.listen(port, address, () => { this.router = app this.emit('listening', this.server) resolve(this.server) }) } catch (err) { reject(err) } }) } // 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.' const self = this this.router = { get: () => self.logger.info(msg), post: () => self.logger.info(msg), put: () => self.logger.info(msg), delete: () => self.logger.info(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. async loadAdapter (adapterPath = null) { if (this.adapter && this.adapter.use) { this.adapter = await this.adapter.use(this) this.adapterName = this.adapter.name ?? this.adapter.constructor.name return } this.logger.debug(`Loading adapter ${adapterPath ?? 'from npmjs:'} ${this.adapterName}`) const ext = path.extname(adapterPath ?? '') try { if (Array.from(HUBOT_DEFAULT_ADAPTERS).indexOf(this.adapterName) > -1) { this.adapter = await this.requireAdapterFrom(path.resolve(path.join(__dirname, 'adapters', `${this.adapterName}.mjs`))) } else if (['.js', '.cjs'].includes(ext)) { this.adapter = await this.requireAdapterFrom(path.resolve(adapterPath)) } else if (['.mjs'].includes(ext)) { this.adapter = await this.importAdapterFrom(path.resolve(adapterPath)) } else { this.adapter = await this.importFromRepo(this.adapterName) } } catch (error) { this.logger.error(`Cannot load adapter ${adapterPath ?? '[no path set]'} ${this.adapterName} - ${error}`) throw error } this.adapterName = this.adapter.name ?? this.adapter.constructor.name } async requireAdapterFrom (adapaterPath) { return await this.importAdapterFrom(adapaterPath) } async importAdapterFrom (adapterPath) { const forImport = this.prepareForImport(adapterPath) return await (await import(forImport)).default.use(this) } async importFromRepo (adapterPath) { return await (await import(adapterPath)).default.use(this) } // 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. // // filePath - A String path to the file on disk. // // Returns nothing. parseHelp (filePath) { const scriptDocumentation = {} const body = fs.readFileSync(path.resolve(filePath), '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 ${filePath}`) 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(`${filePath} 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 whatever the extending adapter returns. async send (envelope, ...strings) { return await this.adapter.send(envelope, ...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 whatever the extending adapter returns. async reply (envelope, ...strings) { return await this.adapter.reply(envelope, ...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 whatever the extending adapter returns. async messageRoom (room, ...strings) { const envelope = { room } return await this.adapter.send(envelope, ...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) { this.events.on(event, ...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) { this.events.emit(event, ...args) } // Public: Kick off the event loop for the adapter // // Returns whatever the adapter returns. async run () { if (this.shouldEnableHttpd) { await this.setupExpress() } else { this.setupNullRouter() } await this.adapter.run() this.emit('running') } // Public: Gracefully shutdown the robot process // // Returns nothing. shutdown () { if (this.pingIntervalId != null) { clearInterval(this.pingIntervalId) } this.adapter?.close() if (this.server) { this.server.close() } this.brain.close() this.events.removeAllListeners() } prepareForImport (filePath) { return pathToFileURL(filePath) } // Public: The version of Hubot from npm // // Returns a String of the version number. parseVersion () { const pkg = fs.readFileSync(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}`) } herokuKeepalive (server) { 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) } } } 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) { sources.forEach((source) => { if (typeof source !== 'object') { return } Object.keys(source).forEach((key) => { obj[key] = source[key] }) }) return obj } export default Robot