imubot
Version:
A simple helpful bot.
469 lines (427 loc) • 16.5 kB
JavaScript
(function() {
const Fs = require('fs');
const Log = require('log');
const Path = require('path');
const HttpClient = require('scoped-http-client');
const { EventEmitter } = require('events');
const async = require('async');
const User = require('./user');
const Brain = require('./brain');
const Response = require('./response');
const { Listener, TextListener } = require('./listener');
const { EnterMessage, LeaveMessage, TopicMessage, CatchAllMessage } = require('./message');
const Middleware = require('./middleware');
const DEFAULT_ADAPTERS = ['campfire', 'shell'];
const DOC_SECTIONS = ['description', 'dependencies', 'configuration', 'commands', 'notes', 'author', 'authors', 'examples', 'tags', 'urls'];
function Bot(adapterPath, adapter, httpd, name, alias) {
if (!name) name = 'Mubot';
if (!alias) alias = false;
if (!this.adapterPath) 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.leat = {seen: {last: null}};
this.io = 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.MUBOT_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) => this.invokeErrorHandlers(err, res))
this.onUncaughtException = err => this.emit('error', err)
process.on('uncaughtException', this.onUncaughtException)
}
Bot.prototype.listen = function(matcher, options, callback) {
this.listeners.push(new Listener(this, matcher, options, callback));
};
Bot.prototype.hear = function(regex, options, callback) {
this.listeners.push(new TextListener(this, regex, options, callback));
};
Bot.prototype.respond = function(regex, options, callback) {
this.hear(this.respondPattern(regex), options, callback);
};
Bot.prototype.respondPattern = function(regex) {
var alias, modifiers, name, newRegex, pattern, reArray, anchored;
reArray = regex.toString().split('/');
reArray.shift();
modifiers = reArray.pop();
pattern = reArray[0];
if(pattern[0] === '^') {
this.logger.warning("The ^ Anchor doesn't work with respond, use 'hear': " + regex.toString())
}
name = this.name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
if (this.alias) {
alias = this.alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
newRegex = new RegExp("^\\s*[@]?(?:" + alias + "[:,]?|" + name + "[:,]?)\\s*(?:" + pattern + ")", modifiers);
} else {
newRegex = new RegExp("^\\s*[@]?" + name + "[:,]?\\s*(?:" + pattern + ")", modifiers);
}
return newRegex;
};
Bot.prototype.enter = function(options, callback) {
this.listen(msg => msg instanceof EnterMessage, options, callback);
};
Bot.prototype.leave = function(options, callback) {
this.listen(msg => msg instanceof LeaveMessage, options, callback);
};
Bot.prototype.topic = function(options, callback) {
this.listen(msg => msg instanceof Message.TopicMessage, options, callback);
};
Bot.prototype.error = function(callback) {
this.errorHandlers.push(callback);
};
Bot.prototype.invokeErrorHandlers = function(err, res) {
var ref, results;
this.logger.error(err.stack);
ref = this.errorHandlers;
for (let i = 0, len = ref.length; i < len; i++) {
let errorHandler = ref[i];
try {
errorHandler(err, res);
} catch (errErr) {
this.logger.error("while invoking error handler: " + errErr + "\n" + errErr.stack);
}
}
};
Bot.prototype.catchAll = function(options, callback) {
if (!callback) {
callback = options;
options = {};
}
this.listen(msg => msg instanceof CatchAllMessage, options, msg => {
msg.message = msg.message.message;
callback(msg);
})
};
Bot.prototype.listenerMiddleware = function(middleware) {
this.middleware.listener.register(middleware);
};
Bot.prototype.responseMiddleware = function(middleware) {
this.middleware.response.register(middleware);
};
Bot.prototype.receiveMiddleware = function(middleware) {
this.middleware.receive.register(middleware);
};
Bot.prototype.receive = function(message, cb) {
this.middleware.receive.execute({ response: new Response(this, message) }, this.processListeners.bind(this), cb);
};
Bot.prototype.processListeners = function(context, done) {
var anyListenersExecuted = false;
async.detectSeries(this.listeners, (listener, cb) => {
var err;
try {
listener.call(context.response.message, this.middleware.listener, listenerExecuted => {
anyListenersExecuted = anyListenersExecuted || listenerExecuted;
//Middleware.ticker(() => cb(context.response.message.done));
process.nextTick(() => cb(context.response.message.done));
});
} catch (err) {
this.emit('error', err, new this.Response(this, context.response.message, []));
cb(false);
}
}, () => { //_ => {
if (!(context.response.message instanceof CatchAllMessage) && !anyListenersExecuted) {
this.logger.debug('No listeners executed; falling back to catch-all');
this.receive(new CatchAllMessage(context.response.message), done);
} else {
if (done) process.nextTick(done);
}
})
};
Bot.prototype.loadFile = function(path, file) {
var ext, full;
ext = Path.extname(file);
full = Path.join(path, Path.basename(file, ext));
if (require.cache[require.resolve(full)]) {
try {
let cacheobj = require.resolve(full);
this.logger.debug("require cache for " + cacheobj + " invalidated.");
delete require.cache[cacheobj];
} catch (err) {
this.logger.error("Unable to invalidate " + cacheobj + ": " + err.stack);
}
}
if (require.extensions[ext]) {
try {
let script = require(full);
if (typeof script === 'function') {
script(this);
this.parseHelp(Path.join(path, file));
} else {
this.logger.warning("Expected " + full + " to assign a function to module.exports, got " + (typeof script));
}
} catch (err) {
this.logger.error("Unable to load " + full + ": " + err.stack);
return process.exit(1);
}
}
};
Bot.prototype.load = function(path) {
this.logger.debug("Loading scripts from " + path);
if (Fs.existsSync(path)) {
let ref = Fs.readdirSync(path).sort();
let results = [];
for (let i = 0, len = ref.length; i < len; i++) {
let file = ref[i];
results.push(this.loadFile(path, file));
}
return results;
}
};
Bot.prototype.loadMubotScripts = function(path, scripts) {
this.logger.debug("Loading mubot-scripts from " + path + ".");
for (let i = 0, len = scripts.length; i < len; i++) {
this.loadFile(path, scripts[i]);
}
};
Bot.prototype.loadExternalScripts = function(packages) {
this.logger.debug("Loading external-scripts from npm packages");
try {
if (packages instanceof Array) {
for (let i = 0, len = packages.length; i < len; i++) {
let pkg = packages[i];
require(pkg)(this);
}
} else {
for (let pkg in packages) {
require(pkg)(this, packages[pkg]);
}
}
}
catch (err) {
this.logger.error("Error loading scripts from npm package - " + err.stack);
process.exit(1);
}
};
Bot.prototype.setupExpress = function() {
var stat, sPort, secret, address, parseurl, app, basicAuth, bodyParser, cookieParser,
express, fs, http, https, logger, multipart, options, pass, port, user
;
user = process.env.EXPRESS_USER;
pass = process.env.EXPRESS_PASSWORD;
stat = process.env.EXPRESS_STATIC;
secret = process.env.EXPRESS_SECRET || 'loyalty is to be placed, and unplaced, but never replaced';
port = process.env.EXPRESS_PORT || process.env.PORT || 8080;
sPort = process.env.EXPRESS_SECURE_PORT || process.env.SECURE_PORT || 4343;
address = process.env.EXPRESS_BIND_ADDRESS || process.env.BIND_ADDRESS || '0.0.0.0';
express = require('express');
multipart = require('connect-multiparty');
bodyParser = require('body-parser');
//basicAuth = require('basic-auth-connect');
cookieParser = require('cookie-parser');
logger = require('morgan');
app = express();
parseurl = require('parseurl')
credentials = process.env.CREDENTIALS || '/etc/letsencrypt/live/leathan.xyz/';
if(process.env.MUBOT_LOG_LEVEL) app.use(logger("dev"));
//To use basic auth
//if (user && pass) app.use("/admin", basicAuth(user, pass));
if (stat) app.use(express.static(stat));
app.use(bodyParser.json ({ extended: true }));
app.use(bodyParser.urlencoded ({ extended: true }));
app.use(express.query())
;
app.use(cookieParser())
;
app.use((req, res, next) => {
var href, host = req.headers.host
;
// no www. present, nothing to do here
if (/^www\./i.test(host)) {
host = host.replace(/^www\./i, '');
href = 'https://' + host + req.url;
res.statusCode = 301;
res.setHeader('Location', href);
res.write('Redirecting to ' + host + req.url + '');
return res.end();
}
let remoteIp = req.connection.remoteAddress;
if(this.leat.seen[remoteIp]) {
let o = this.leat.seen[remoteIp];
++o.times;
++o.all_times;
o.last = new Date();
o.reqs.push(req);
} else {
this.leat.seen[remoteIp] = {
times: 1,
all_times: 1,
last: Date.now(),
reqs: [req]
};
}
if(Date.now() - this.leat.seen[remoteIp].last < 777 && this.leat.seen[remoteIp].times > 7) {
return res.end("Throttled");
} else {
this.leat.seen[remoteIp].times = 0;
}
next();
this.leat.seen.last = {ip: remoteIp, time: Date.now()};
})
;
https = require('https');
http = require('http');
options = {
key: Fs.readFileSync (Path.join(credentials + 'privkey.pem')),
cert: Fs.readFileSync(Path.join(credentials + 'fullchain.pem')),
ca: Fs.readFileSync (Path.join(credentials + 'chain.pem'))
};
this.server = https.createServer(options, app).listen(sPort, function() {
return console.log('listening on port ' + sPort + '.');
}).on('error', e => {
if(e.code == "EADDRINUSE") console.log("cant listen on port " + sPort + ", address is use.")
})
this.io = require('socket.io')(this.server);
http.createServer((req, res) => {
res.writeHead(301, { 'Location': 'https://' + req.headers.host + req.url });
return res.end();
}).listen(port, function() {
return console.log('listening on port ' + port + '.');
}).on('error', e => {
if(e.code == "EADDRINUSE") console.log("cant listen on port " + port + ", address is use.")
})
var subdomain = require('express-subdomain');
return this.router = app;
};
Bot.prototype.setupNullRouter = function() {
var msg = "A script has tried registering an HTTP route while the 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)
};
};
Bot.prototype.loadAdapter = function(adapter) {
var err, path;
this.logger.debug("Loading adapter " + adapter);
try {
path = DEFAULT_ADAPTERS.includes(adapter) ? this.adapterPath + "/" + adapter : "hubot-" + adapter;
this.adapter = require(path).use(this);
} catch (err) {
this.logger.error("Cannot load adapter " + adapter + " - " + err);
process.exit(1)
;
}
}
;
Bot.prototype.helpCommands = function() {
return this.commands.sort();
}
;
Bot.prototype.parseHelp = function(path) {
var body, cleanedLine, currentSection, line, nextSection, lines, results, scriptDocumentation, scriptName;
this.logger.debug("Parsing help for " + path);
scriptName = Path.basename(path).replace(/\.(coffee|js)$/, '');
scriptDocumentation = {};
body = Fs.readFileSync(path, 'utf-8');
currentSection = null;
lines = body.split("\n");
for (let i = 0, len = lines.length; i < len; i++) {
line = lines[i];
if(!/(#|\/\/)/.test(line)) break;
cleanedLine = line.replace(/^(#|\/\/)\s?/, "").trim();
if (cleanedLine.length === 0) continue;
if (cleanedLine.toLowerCase() === 'none') continue;
nextSection = cleanedLine.toLowerCase().replace(':', '');
if (DOC_SECTIONS.includes(nextSection)) {
currentSection = nextSection;
scriptDocumentation[currentSection] = [];
} else if (currentSection) {
scriptDocumentation[currentSection].push(cleanedLine.trim());
if (currentSection === 'commands') {
this.commands.push(cleanedLine.trim());
}
}
}
if (currentSection === null) {
this.logger.info(path + " is using deprecated documentation syntax");
scriptDocumentation.commands = [];
lines = body.split("\n");
results = [];
for (let i = 0, l = lines.length; i < l; i++) {
line = lines[i];
if(!/(#|\/\/)/.test(line)) break;
if(!/-/.test(line)) continue;
cleanedLine = line.slice(2, line.length).replace(/^(mubot|Mubot)/i, this.name).trim();
scriptDocumentation.commands.push(cleanedLine);
results.push(this.commands.push(cleanedLine));
}
return results;
}
};
Bot.prototype.send = function(envelope) {
const strings = [].slice.call(arguments, 1)
this.adapter.send.apply(this, [envelope].concat(strings))
};
Bot.prototype.reply = function(envelope) {
const strings = [].slice.call(arguments, 1)
this.adapter.reply.apply(this, [envelope].concat(strings))
};
Bot.prototype.messageRoom = function(room) {
const strings = [].slice.call(arguments, 1)
const envelope = { room }
this.adapter.send.apply(this.adapter, [envelope].concat(strings))
};
Bot.prototype.on = function(event) {
const args = [].slice.call(arguments, 1)
this.events.on.apply(this, [event].concat(args))
};
Bot.prototype.emit = function(event) {
const args = [].slice.call(arguments, 1)
this.events.emit.apply(this, [event].concat(args))
};
Bot.prototype.run = function() {
this.emit("running");
this.adapter.run();
};
Bot.prototype.shutdown = function() {
if (this.pingIntervalId) clearInterval(this.pingIntervalId);
process.removeListener('uncaughtException', this.onUncaughtException);
this.adapter.close();
this.brain.close();
};
Bot.prototype.parseVersion = function() {
const pkg = require(Path.join(__dirname, '..', 'package.json'));
return this.version = pkg.version;
};
Bot.prototype.http = function(url, options) {
return HttpClient.create(url, this.extend({}, this.globalHttpOptions, options)).header('User-Agent', "Mubot/" + this.version);
};
Bot.prototype.extend = function(obj) {
const sources = [].slice.call(arguments, 1);
for (let i = 0, l = sources.length; i < l; i++) {
let source = sources[i];
for (let key in source) {
if (!{}.hasOwnProperty.call(source, key)) continue;
obj[key] = source[key];
}
}
return obj;
};
function debounce(func, wait) {
var timeout, args, context, timestamp, result;
var later = function() {
var last = new Date.getTime() - timestamp;
if (last < wait && last >= 0) {
timeout = setTimeout(later, wait - last);
} else {
timeout = null;
}
};
}
module.exports = Bot;
}).call(this);