mangrove-botkit
Version:
Building blocks for Building Bots
909 lines (766 loc) • 28.3 kB
JavaScript
var Ws = require('ws');
var request = require('request');
var slackWebApi = require(__dirname + '/Slack_web_api.js');
var HttpsProxyAgent = require('https-proxy-agent');
var Back = require('back');
module.exports = function(botkit, config) {
var bot = {
type: 'slack',
botkit: botkit,
config: config || {},
utterances: botkit.utterances,
api: slackWebApi(botkit, config || {}),
identity: { // default identity values
id: null,
name: '',
}
};
// Set when destroy() is called - prevents a reconnect from completing
// if it was fired off prior to destroy being called
var destroyed = false;
var pingTimeoutId = null;
var retryBackoff = null;
// config.retry, can be Infinity too
var retryEnabled = bot.config.retry ? true : (botkit.config.retry ? true : false);
var maxRetry = null;
if (bot.config.retry) {
maxRetry = isNaN(bot.config.retry) || bot.config.retry <= 0 ? 3 : bot.config.retry;
} else if (botkit.config.retry) {
maxRetry = isNaN(botkit.config.retry) || botkit.config.retry <= 0 ? 3 : botkit.config.retry;
}
/**
* Set up API to send incoming webhook
*/
bot.configureIncomingWebhook = function(options) {
if (!options.url)
throw new Error('No incoming webhook URL specified!');
bot.config.incoming_webhook = options;
return bot;
};
bot.sendWebhook = function(options, cb) {
if (!bot.config.incoming_webhook || !bot.config.incoming_webhook.url) {
botkit.debug('CANNOT SEND WEBHOOK!!');
return cb && cb('No webhook url specified');
}
botkit.middleware.send.run(bot, options, function(err, bot, options) {
request.post(bot.config.incoming_webhook.url, function(err, res, body) {
if (err) {
botkit.debug('WEBHOOK ERROR', err);
return cb && cb(err);
}
botkit.debug('WEBHOOK SUCCESS', body);
cb && cb(null, body);
}).form({ payload: JSON.stringify(options) });
});
};
bot.configureRTM = function(config) {
bot.config.token = config.token;
return bot;
};
bot.closeRTM = function(err) {
if (bot.rtm) {
bot.rtm.removeAllListeners();
bot.rtm.close();
}
if (pingTimeoutId) {
clearTimeout(pingTimeoutId);
}
botkit.trigger('rtm_close', [bot, err]);
// only retry, if enabled, when there was an error
if (err && retryEnabled) {
reconnect();
}
};
function reconnect(err) {
var options = {
minDelay: 1000,
maxDelay: 30000,
retries: maxRetry
};
var back = retryBackoff || (retryBackoff = new Back(options));
return back.backoff(function(fail) {
if (fail) {
botkit.log.error('** BOT ID:', bot.identity.name, '...reconnect failed after #' +
back.settings.attempt + ' attempts and ' + back.settings.timeout + 'ms');
botkit.trigger('rtm_reconnect_failed', [bot, err]);
return;
}
botkit.log.notice('** BOT ID:', bot.identity.name, '...reconnect attempt #' +
back.settings.attempt + ' of ' + options.retries + ' being made after ' + back.settings.timeout + 'ms');
bot.startRTM(function(err) {
if (err && !destroyed) {
return reconnect(err);
}
retryBackoff = null;
});
});
}
/**
* Shutdown and cleanup the spawned worker
*/
bot.destroy = function() {
// this prevents a startRTM from completing if it was fired off
// prior to destroy being called
destroyed = true;
if (retryBackoff) {
retryBackoff.close();
retryBackoff = null;
}
bot.closeRTM();
botkit.shutdown();
};
bot.startRTM = function(cb) {
var lastPong = 0;
bot.api.rtm.connect({}, function(err, res) {
if (err) {
return cb && cb(err);
}
if (!res) {
return cb && cb('Invalid response from rtm.start');
}
bot.identity = res.self;
bot.team_info = res.team;
// Bail out if destroy() was called
if (destroyed) {
botkit.log.notice('Ignoring rtm.start response, bot was destroyed');
return cb('Ignoring rtm.start response, bot was destroyed');
}
botkit.log.notice('** BOT ID:', bot.identity.name, '...attempting to connect to RTM!');
var agent = null;
var proxyUrl = process.env.https_proxy || process.env.http_proxy;
if (proxyUrl) {
agent = new HttpsProxyAgent(proxyUrl);
}
bot.rtm = new Ws(res.url, null, {
agent: agent
});
bot.msgcount = 1;
bot.rtm.on('pong', function(obj) {
lastPong = Date.now();
});
bot.rtm.on('open', function() {
botkit.log.notice('RTM websocket opened');
var pinger = function() {
var pongTimeout = bot.botkit.config.stale_connection_timeout || 12000;
if (lastPong && lastPong + pongTimeout < Date.now()) {
var err = new Error('Stale RTM connection, closing RTM');
botkit.log.error(err);
bot.closeRTM(err);
clearTimeout(pingTimeoutId);
return;
}
bot.rtm.ping();
pingTimeoutId = setTimeout(pinger, 5000);
};
pingTimeoutId = setTimeout(pinger, 5000);
botkit.trigger('rtm_open', [bot]);
bot.rtm.on('message', function(data, flags) {
var message = null;
try {
message = JSON.parse(data);
} catch (err) {
console.log('** RECEIVED BAD JSON FROM SLACK');
}
/**
* Lets construct a nice quasi-standard botkit message
* it leaves the main slack message at the root
* but adds in additional fields for internal use!
* (including the teams api details)
*/
if (message != null && bot.botkit.config.rtm_receive_messages) {
botkit.ingest(bot, message, bot.rtm);
}
});
botkit.startTicking();
cb && cb(null, bot, res);
});
bot.rtm.on('error', function(err) {
botkit.log.error('RTM websocket error!', err);
if (pingTimeoutId) {
clearTimeout(pingTimeoutId);
}
botkit.trigger('rtm_close', [bot, err]);
});
bot.rtm.on('close', function(code, message) {
botkit.log.notice('RTM close event: ' + code + ' : ' + message);
if (pingTimeoutId) {
clearTimeout(pingTimeoutId);
}
botkit.trigger('rtm_close', [bot]);
/**
* CLOSE_ABNORMAL error
* wasn't closed explicitly, should attempt to reconnect
*/
if (code === 1006) {
botkit.log.error('Abnormal websocket close event, attempting to reconnect');
reconnect();
}
});
});
return bot;
};
bot.identifyBot = function(cb) {
var data;
if (bot.identity) {
data = {
name: bot.identity.name,
id: bot.identity.id,
team_id: bot.identifyTeam()
};
cb && cb(null, data);
return data;
} else {
/**
* Note: Are there scenarios other than the RTM
* where we might pull identity info, perhaps from
* bot.api.auth.test on a given token?
*/
cb && cb('Identity Unknown: Not using RTM api');
return null;
};
};
bot.identifyTeam = function(cb) {
if (bot.team_info) {
cb && cb(null, bot.team_info.id);
return bot.team_info.id;
}
/**
* Note: Are there scenarios other than the RTM
* where we might pull identity info, perhaps from
* bot.api.auth.test on a given token?
*/
cb && cb('Unknown Team!');
return null;
};
/**
* Convenience method for creating a DM convo.
*/
bot.startPrivateConversation = function(message, cb) {
bot.api.im.open({ user: message.user }, function(err, channel) {
if (err) return cb(err);
message.channel = channel.channel.id;
botkit.startTask(bot, message, function(task, convo) {
cb(null, convo);
});
});
};
bot.startConversationInThread = function(message, cb) {
// make replies happen in a thread
if (!message.thread_ts) {
message.thread_ts = message.ts;
}
botkit.startConversation(this, message, cb);
};
bot.createPrivateConversation = function(message, cb) {
bot.api.im.open({ user: message.user }, function(err, channel) {
if (err) return cb(err);
message.channel = channel.channel.id;
botkit.createConversation(bot, message, cb);
});
};
bot.createConversationInThread = function(message, cb) {
// make replies happen in a thread
if (!message.thread_ts) {
message.thread_ts = message.ts;
}
botkit.createConversation(this, message, cb);
};
/**
* Convenience method for creating a DM convo.
*/
bot._startDM = function(task, user_id, cb) {
bot.api.im.open({ user: user_id }, function(err, channel) {
if (err) return cb(err);
cb(null, task.startConversation({
channel: channel.channel.id,
user: user_id
}));
});
};
bot.send = function(message, cb) {
if (message.ephemeral) {
bot.sendEphemeral(message, cb);
return;
}
botkit.debug('SAY', message);
bot.msgcount++;
/**
* Use the web api to send messages unless otherwise specified
* OR if one of the fields that is only supported by the web api is present
*/
if (
botkit.config.send_via_rtm !== true && message.type !== 'typing' ||
message.attachments || message.icon_emoji ||
message.username || message.icon_url) {
if (!bot.config.token) {
throw new Error('Cannot use web API to send messages.');
}
bot.api.chat.postMessage(message, function(err, res) {
if (err) {
cb && cb(err);
} else {
cb && cb(null, res);
}
});
} else {
if (!bot.rtm)
throw new Error('Cannot use the RTM API to send messages.');
message.id = message.id || bot.msgcount;
try {
bot.rtm.send(JSON.stringify(message), function(err) {
if (err) {
cb && cb(err);
} else {
cb && cb(null, message);
}
});
} catch (err) {
/**
* The RTM failed and for some reason it didn't get caught
* elsewhere. This happens sometimes when the rtm has closed but
* We are sending messages anyways.
* Bot probably needs to reconnect!
*/
cb && cb(err);
}
}
};
bot.sendEphemeral = function(message, cb) {
botkit.debug('SAY EPHEMERAL', message);
if (!bot.config.token) {
throw new Error('Cannot use web API to send messages.');
}
bot.api.chat.postEphemeral(message, function(err, res) {
if (err) {
cb && cb(err);
} else {
cb && cb(null, res);
}
});
};
/**
* Allows responding to slash commands and interactive messages with a plain
* 200 OK (without any text or attachments).
*
* @param {function} cb - An optional callback function called at the end of execution.
* The callback is passed an optional Error object.
*/
bot.replyAcknowledge = function(cb) {
if (!bot.res) {
cb && cb(new Error('No web response object found'));
} else {
bot.res.end();
cb && cb();
}
};
bot.replyPublic = function(src, resp, cb) {
if (!bot.res) {
cb && cb('No web response object found');
} else {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
// if source message is in a thread, reply should also be in the thread
if (src.thread_ts) {
msg.thread_ts = src.thread_ts;
}
msg.response_type = 'in_channel';
msg.to = src.user;
botkit.middleware.send.run(bot, msg, function(err, bot, msg) {
bot.res.json(msg);
cb && cb();
});
}
};
bot.replyPublicDelayed = function(src, resp, cb) {
if (!src.response_url) {
cb && cb('No response_url found');
} else {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
msg.to = src.user;
// if source message is in a thread, reply should also be in the thread
if (src.thread_ts) {
msg.thread_ts = src.thread_ts;
}
msg.response_type = 'in_channel';
botkit.middleware.send.run(bot, msg, function(err, bot, msg) {
var requestOptions = {
uri: src.response_url,
method: 'POST',
json: msg
};
request(requestOptions, function(err, resp, body) {
/**
* Do something?
*/
if (err) {
botkit.log.error('Error sending slash command response:', err);
cb && cb(err);
} else {
cb && cb();
}
});
});
}
};
bot.replyPrivate = function(src, resp, cb) {
if (!bot.res) {
cb && cb('No web response object found');
} else {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
msg.to = src.user;
// if source message is in a thread, reply should also be in the thread
if (src.thread_ts) {
msg.thread_ts = src.thread_ts;
}
msg.response_type = 'ephemeral';
botkit.middleware.send.run(bot, msg, function(err, bot, msg) {
bot.res.json(msg);
cb && cb();
});
}
};
bot.replyPrivateDelayed = function(src, resp, cb) {
if (!src.response_url) {
cb && cb('No response_url found');
} else {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
msg.to = src.user;
// if source message is in a thread, reply should also be in the thread
if (src.thread_ts) {
msg.thread_ts = src.thread_ts;
}
msg.response_type = 'ephemeral';
botkit.middleware.send.run(bot, msg, function(err, bot, msg) {
var requestOptions = {
uri: src.response_url,
method: 'POST',
json: msg
};
request(requestOptions, function(err, resp, body) {
/**
* Do something?
*/
if (err) {
botkit.log.error('Error sending slash command response:', err);
cb && cb(err);
} else {
cb && cb();
}
});
});
}
};
bot.replyInteractive = function(src, resp, cb) {
if (!src.response_url) {
cb && cb('No response_url found');
} else {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
msg.to = src.user;
// if source message is in a thread, reply should also be in the thread
if (src.thread_ts) {
msg.thread_ts = src.thread_ts;
}
botkit.middleware.send.run(bot, msg, function(err, bot, msg) {
var requestOptions = {
uri: src.response_url,
method: 'POST',
json: msg
};
request(requestOptions, function(err, resp, body) {
/**
* Do something?
*/
if (err) {
botkit.log.error('Error sending interactive message response:', err);
cb && cb(err);
} else {
cb && cb();
}
});
});
}
};
bot.dialogOk = function() {
bot.res.send('');
};
bot.dialogError = function(errors) {
if (!errors) {
errors = [];
}
if (Object.prototype.toString.call(errors) !== '[object Array]') {
errors = [errors];
}
bot.res.json({
errors: errors
});
};
bot.replyWithDialog = function(src, dialog_obj, cb) {
var msg = {
trigger_id: src.trigger_id,
dialog: JSON.stringify(dialog_obj)
};
botkit.middleware.send.run(bot, msg, function(err, bot, msg) {
bot.api.dialog.open(msg, cb);
});
};
/* helper functions for creating dialog attachments */
bot.createDialog = function(title, callback_id, submit_label, elements) {
var obj = {
data: {
title: title,
callback_id: callback_id,
submit_label: submit_label || null,
elements: elements || [],
},
title: function(v) {
this.data.title = v;
return this;
},
callback_id: function(v) {
this.data.callback_id = v;
return this;
},
submit_label: function(v) {
this.data.submit_label = v;
return this;
},
addText: function(label, name, value, options, subtype) {
var element = (typeof(label) === 'object') ? label : {
label: label,
name: name,
value: value,
type: 'text',
subtype: subtype || null,
};
if (typeof(options) === 'object') {
for (var key in options) {
element[key] = options[key];
}
}
this.data.elements.push(element);
return this;
},
addEmail: function(label, name, value, options) {
return this.addText(label, name, value, options, 'email');
},
addNumber: function(label, name, value, options) {
return this.addText(label, name, value, options, 'number');
},
addTel: function(label, name, value, options) {
return this.addText(label, name, value, options, 'tel');
},
addUrl: function(label, name, value, options) {
return this.addText(label, name, value, options, 'url');
},
addTextarea: function(label, name, value, options, subtype) {
var element = (typeof(label) === 'object') ? label : {
label: label,
name: name,
value: value,
type: 'textarea',
subtype: subtype || null,
};
if (typeof(options) === 'object') {
for (var key in options) {
element[key] = options[key];
}
}
this.data.elements.push(element);
return this;
},
addSelect: function(label, name, value, option_list, options) {
var element = {
label: label,
name: name,
value: value,
options: option_list,
type: 'select',
};
if (typeof(options) === 'object') {
for (var key in options) {
element[key] = options[key];
}
}
this.data.elements.push(element);
return this;
},
asString: function() {
return JSON.stringify(this.data, null, 2);
},
asObject: function() {
return this.data;
}
};
return obj;
};
bot.reply = function(src, resp, cb) {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
msg.to = src.user;
// if source message is in a thread, reply should also be in the thread
if (src.thread_ts) {
msg.thread_ts = src.thread_ts;
}
if (msg.ephemeral && !msg.user) {
msg.user = src.user;
msg.as_user = true;
}
bot.say(msg, cb);
};
bot.whisper = function(src, resp, cb) {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
msg.user = src.user;
msg.as_user = true;
msg.ephemeral = true;
bot.say(msg, cb);
};
bot.replyInThread = function(src, resp, cb) {
var msg = {};
if (typeof(resp) == 'string') {
msg.text = resp;
} else {
msg = resp;
}
msg.channel = src.channel;
msg.to = src.user;
// to create a thread, set the original message as the parent
msg.thread_ts = src.thread_ts ? src.thread_ts : src.ts;
bot.say(msg, cb);
};
/**
* sends a typing message to the source channel
*
* @param {Object} src message source
*/
bot.startTyping = function(src) {
bot.reply(src, { type: 'typing' });
};
/**
* replies with message after typing delay
*
* @param {Object} src message source
* @param {(string|Object)} resp string or object
* @param {function} cb optional request callback
*/
bot.replyWithTyping = function(src, resp, cb) {
var text;
if (typeof(resp) == 'string') {
text = resp;
} else {
text = resp.text;
}
var typingLength = 1200 / 60 * text.length;
typingLength = typingLength > 2000 ? 2000 : typingLength;
bot.startTyping(src);
setTimeout(function() {
bot.reply(src, resp, cb);
}, typingLength);
};
/**
* replies with message, performs arbitrary task, then updates reply message
* note: don't use this as a replacement for the `typing` event
*
* @param {Object} src - message source
* @param {(string|Object)} resp - response string or object
* @param {function} [cb] - updater callback
*/
bot.replyAndUpdate = function(src, resp, cb) {
try {
resp = typeof resp === 'string' ? { text: resp } : resp;
// trick bot.reply into using web API instead of RTM
resp.attachments = resp.attachments || [];
} catch (err) {
return cb && cb(err);
}
// send the "updatable" message
return bot.reply(src, resp, function(err, src) {
if (err) return cb && cb(err);
// if provided, call the updater callback - it controls how and when to update the "updatable" message
return cb && cb(null, src, function(resp, cb) {
try {
// format the "update" message to target the "updatable" message
resp = typeof resp === 'string' ? { text: resp } : resp;
resp.ts = src.ts;
resp.channel = src.channel;
resp.attachments = JSON.stringify(resp.attachments || []);
} catch (err) {
return cb && cb(err);
}
// update the "updatable" message with the "update" message
return bot.api.chat.update(resp, function(err, json) {
return cb && cb(err, json);
});
});
});
};
/**
* This handles the particulars of finding an existing conversation or
* topic to fit the message into...
*/
bot.findConversation = function(message, cb) {
botkit.debug('CUSTOM FIND CONVO', message.user, message.channel, message.type);
if (message.type == 'direct_message' || message.type == 'direct_mention' || message.type == 'ambient' || message.type == 'mention' || message.type == 'slash_command' ||
message.type == 'outgoing_webhook' || message.type == 'interactive_message_callback') {
for (var t = 0; t < botkit.tasks.length; t++) {
for (var c = 0; c < botkit.tasks[t].convos.length; c++) {
if (
botkit.tasks[t].convos[c].isActive() &&
botkit.tasks[t].convos[c].source_message.user == message.user &&
botkit.tasks[t].convos[c].source_message.channel == message.channel &&
botkit.tasks[t].convos[c].source_message.thread_ts == message.thread_ts
) {
botkit.debug('FOUND EXISTING CONVO!');
cb(botkit.tasks[t].convos[c]);
return;
}
}
}
}
cb();
};
if (bot.config.incoming_webhook)
bot.configureIncomingWebhook(config.incoming_webhook);
if (bot.config.bot)
bot.configureRTM(config.bot);
return bot;
};