concierge-bot
Version:
Extensible general purpose chat bot.
505 lines (443 loc) • 15.7 kB
JavaScript
const EventEmitter = require('events');
class Robot extends EventEmitter {
constructor() {
super();
this._registerListener(global.currentPlatform, 'uncaughtError', err => {
LOG.error(err.stack);
this.emit('error', err);
});
this.logger = LOG;
this.brain = null; //todo
this.name = global.currentPlatform.name;
this.Response = Response;
this.commands = [];
this.listeners = [];
this.middleware = {
listener: new Middleware(this),
response: new Middleware(this),
receive: new Middleware(this)
};
this.globalHttpOptions = {};
this.parseVersion();
if (httpd) {
this.setupExpress();
} else {
this.setupNullRouter();
}
}
_registerListener(obj, name, callback) {
this._registeredListeners.push({
obj: obj,
name: name,
callback
});
obj.on(name, callback);
}
// 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) {
return 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) {
return 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) {
return 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) {
let newRegex;
let re = regex.toString().split('/');
re.shift();
let modifiers = re.pop();
if (re[0] && (re[0][0] === '^')) {
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()}`);
}
let pattern = re.join('/');
let name = this.name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
if (this.alias) {
let alias = this.alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
let [a,b] = Array.from(name.length > alias.length ? [name,alias] : [alias,name]);
newRegex = new RegExp(
`^\\s*[@]?(?:${a}[:,]?|${b}[:,]?)\\s*(?:${pattern})`,
modifiers
);
} else {
newRegex = new RegExp(
`^\\s*[@]?${name}[:,]?\\s*(?:${pattern})`,
modifiers
);
}
return newRegex;
}
enter(options, callback) {
return this.listen(
(msg => msg instanceof EnterMessage),
options,
callback
);
}
leave(options, callback) {
return this.listen(
(msg => msg instanceof 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) {
return this.listen(
(msg => msg instanceof TopicMessage),
options,
callback
);
}
error (callback) {
this.on('error', err => {
try {
return callback(err);
}
catch (errErr) {
return LOG.error(`while invoking error handler: ${errErr}\n${errErr.stack}`);
}
});
}
catchAll (options, callback = null) {
if (!callback) {
callback = options;
options = {};
}
}
// 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 = {};
}
return this.listen(
(msg => msg instanceof CatchAllMessage),
options,
(function(msg) { msg.message = msg.message.message; return 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);
return undefined;
}
// 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);
return undefined;
}
// 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);
return undefined;
}
// 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
return this.middleware.receive.execute(
{response: new Response(this, message)},
this.processListeners.bind(this),
cb
);
}
// 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) {
let ext = Path.extname(file);
let full = Path.join(path, Path.basename(file, ext));
if (require.extensions[ext]) {
try {
let script = require(full);
if (typeof script === 'function') {
script(this);
return this.parseHelp(Path.join(path, file));
} else {
return 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}`);
return 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)) {
return Array.from(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}`);
return 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 {
let pkg;
if (packages instanceof Array) {
return (() => {
let result = [];
for (pkg of Array.from(packages)) {
result.push(require(pkg)(this));
}
return result;
})();
} else {
return (() => {
let result1 = [];
for (pkg in packages) {
let scripts = packages[pkg];
result1.push(require(pkg)(this, scripts));
}
return result1;
})();
}
} catch (err) {
this.logger.error(`Error loading scripts from npm package - ${err.stack}`);
return process.exit(1);
}
}
// Setup the Express server's defaults.
//
// Returns nothing.
setupExpress() {
let user = process.env.EXPRESS_USER;
let pass = process.env.EXPRESS_PASSWORD;
let stat = process.env.EXPRESS_STATIC;
let port = process.env.EXPRESS_PORT || process.env.PORT || 8080;
let address = process.env.EXPRESS_BIND_ADDRESS || process.env.BIND_ADDRESS || '0.0.0.0';
let express = require('express');
let multipart = require('connect-multiparty');
let 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());
// 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) {
let 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 += '/'; }
return this.pingIntervalId = setInterval(() => {
return HttpClient.create(`${herokuUrl}hubot/ping`).post()((err, res, body) => {
return this.logger.info('keep alive ping!');
});
}
, 5 * 60 * 1000);
}
}
// Setup an empty router object
//
// returns nothing
setupNullRouter() {
let msg = "A script has tried registering a HTTP route while the HTTP server is disabled with --disabled-httpd.";
return 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 {
let path = Array.from(HUBOT_DEFAULT_ADAPTERS).includes(adapter) ?
`${this.adapterPath}/${adapter}`
:
`hubot-${adapter}`;
return this.adapter = require(path).use(this);
} catch (err) {
this.logger.error(`Cannot load adapter ${adapter} - ${err}`);
return process.exit(1);
}
}
// Public: Help Commands for Running Scripts.
//
// Returns an Array of help commands for running scripts.
helpCommands() {
return this.commands.sort();
}
// 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) {
return this.adapter.send(envelope, ...Array.from(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) {
return this.adapter.reply(envelope, ...Array.from(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) {
let envelope = { room };
return this.adapter.send(envelope, ...Array.from(strings));
}
// Public: Kick off the event loop for the adapter
//
// Returns nothing.
run() {
this.emit("running");
return this.adapter.run();
}
shutdown() {
global.currentPlatform.shutdown();
}
parseVersion() {
return global.currentPlatform.packageInfo.version;
}
http(url, options) {
return require('scoped-http-client').create(url, options).header('User-Agent', `Concierge/${this.parseVersion()}`);
}
}