UNPKG

chatbot-constructor

Version:

A chatbot constructor/builder that can help you develop chatbots in no time either using javascript and nodejs or without programming using excel sheet or JSON format. And it can also easily integrate with AI services like LUIS, Watson, Lex, Octane.AI, Wi

762 lines (641 loc) 24.1 kB
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'); } 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.start({ no_unreads: true, simple_latest: true, }, 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'); } /** * Also available: * res.users, res.channels, res.groups, res.ims, * res.bots * * Could be stored & cached for later use. */ 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(null, null, true); 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.receiveMessage(bot, message); } }); 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.startConversation = function(message, cb) { botkit.startConversation(this, message, cb); }; 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.createConversation = function(message, cb) { botkit.createConversation(this, 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) { botkit.debug('SAY', message); /** * Construct a valid slack message. */ var slack_message = { type: message.type || 'message', channel: message.channel, text: message.text || null, username: message.username || null, thread_ts: message.thread_ts || null, reply_broadcast: message.reply_broadcast || null, parse: message.parse || null, link_names: message.link_names || null, attachments: message.attachments ? JSON.stringify(message.attachments) : null, unfurl_links: typeof message.unfurl_links !== 'undefined' ? message.unfurl_links : null, unfurl_media: typeof message.unfurl_media !== 'undefined' ? message.unfurl_media : null, icon_url: message.icon_url || null, icon_emoji: message.icon_emoji || null, }; bot.msgcount++; if (message.icon_url || message.icon_emoji || message.username) { slack_message.as_user = false; } else { slack_message.as_user = message.as_user || true; } /** * 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(slack_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.'); slack_message.id = message.id || bot.msgcount; try { bot.rtm.send(JSON.stringify(slack_message), function(err) { if (err) { cb && cb(err); } else { cb && cb(null, slack_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); } } }; /** * 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'; 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; // 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'; 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; // 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'; 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; // 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'; 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; // if source message is in a thread, reply should also be in the thread if (src.thread_ts) { msg.thread_ts = src.thread_ts; } 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.reply = function(src, resp, cb) { 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; } 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; // 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); if (message.type == 'message' || message.type == 'slash_command' || message.type == 'outgoing_webhook') { 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!'); // modify message text to prune off the bot's name (@bot hey -> hey) // and trim whitespace that is sometimes added // this would otherwise happen in the handleSlackEvents function // which does not get called for messages attached to conversations. if (message.text) { message.text = message.text.trim(); } var direct_mention = new RegExp('^\<\@' + bot.identity.id + '\>', 'i'); message.text = message.text.replace(direct_mention, '') .replace(/^\s+/, '').replace(/^\:\s+/, '').replace(/^\s+/, ''); 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; };