diff --git a/jshint.json b/.jshintrc similarity index 100% rename from jshint.json rename to .jshintrc diff --git a/index.js b/index.js index 19b2d29..02175eb 100644 --- a/index.js +++ b/index.js @@ -18,7 +18,7 @@ limitations under the License. var fs = require('fs'); var path = require('path'); -var SCRIPTS_PATH = path.resolve(__dirname, 'scripts'); +var SCRIPTS_PATH = path.resolve(__dirname, 'src'); module.exports = function(robot, scripts) { diff --git a/lib/command_factory.js b/lib/command_factory.js deleted file mode 100644 index 1466adc..0000000 --- a/lib/command_factory.js +++ /dev/null @@ -1,129 +0,0 @@ -/* - Licensed to the StackStorm, Inc ('StackStorm') under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and -limitations under the License. -*/ - -"use strict"; - -var _ = require('lodash'); -var utils = require('./utils.js'); - - -/** - * Manage command for stackstorm, storing them in robot and providing - * lookups given command literals. - */ -function CommandFactory(robot) { - this.robot = robot; - - // Stores a list of hubot command strings - this.st2_hubot_commands = []; - - // Maps command name (pk) to the action alias object - this.st2_commands_name_map = {}; - - // Maps command format to the action alias object - this.st2_commands_format_map = {}; - - // Maps command format to a compiled regex for that format - this.st2_commands_regex_map = {}; -} - -CommandFactory.prototype.getRegexForFormatString = function(format) { - var extra_params, regex_str, regex; - - // Note: We replace format parameters with ([\s\S]+?) and allow arbitrary - // number of key=value pairs at the end of the string - // Format parameters with default value {{param=value}} are allowed to - // be skipped. - // Note that we use "[\s\S]" instead of "." to allow multi-line values - // and multi-line commands in general. - extra_params = '(\\s+(\\S+)\\s*=("([\\s\\S]*?)"|\'([\\s\\S]*?)\'|({[\\s\\S]*?})|(\\S+))\\s*)*'; - regex_str = format.replace(/(\s*){{\s*\S+\s*=\s*(?:({.+?}|.+?))\s*}}(\s*)/g, '\\s*($1([\\s\\S]+?)$3)?\\s*'); - regex_str = regex_str.replace(/\s*{{.+?}}\s*/g, '\\s*([\\s\\S]+?)\\s*'); - regex = new RegExp('^\\s*' + regex_str + extra_params + '\\s*$', 'i'); - return regex; -}; - -CommandFactory.prototype.addCommand = function(command, name, format, action_alias, flag) { - var compiled_template, context, command_string, regex; - - if (!format) { - this.robot.logger.error('Skipped empty command.'); - return; - } - - context = { - robotName: this.robot.name, - command: command - }; - - compiled_template = _.template('hubot ${command}'); - command_string = compiled_template(context); - - if (!flag || flag === utils.REPRESENTATION) { - this.st2_hubot_commands.push(command_string); - this.st2_commands_name_map[name] = action_alias; - this.st2_commands_format_map[format] = action_alias; - regex = this.getRegexForFormatString(format); - this.st2_commands_regex_map[format] = regex; - } - if ((!flag || flag === utils.DISPLAY) && this.robot.commands.indexOf(command_string) === -1) { - this.robot.commands.push(command_string); - } - - this.robot.logger.debug('Added command: ' + command); -}; - -CommandFactory.prototype.removeCommands = function() { - var i, command, array_index; - - for (i = 0; i < this.st2_hubot_commands.length; i++) { - command = this.st2_hubot_commands[i]; - array_index = this.robot.commands.indexOf(command); - - if (array_index !== -1) { - this.robot.commands.splice(array_index, 1); - } - } - - this.st2_hubot_commands = []; - this.st2_commands_name_map = {}; - this.st2_commands_format_map = {}; - this.st2_commands_regex_map = {}; -}; - -CommandFactory.prototype.getMatchingCommand = function(command) { - var result, common_prefix, command_name, command_arguments, format_strings, - i, format_string, regex, action_alias; - - // 1. Try to use regex search - this works for commands with a format string - format_strings = Object.keys(this.st2_commands_regex_map); - - for (i = 0; i < format_strings.length; i++) { - format_string = format_strings[i]; - regex = this.st2_commands_regex_map[format_string]; - if (regex.test(command)) { - action_alias = this.st2_commands_format_map[format_string]; - command_name = action_alias.name; - - return [command_name, format_string, action_alias]; - } - } - - return null; -}; - -module.exports = CommandFactory; diff --git a/lib/format_command.js b/lib/format_command.js deleted file mode 100644 index ff8fd92..0000000 --- a/lib/format_command.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - Licensed to the StackStorm, Inc ('StackStorm') under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and -limitations under the License. -*/ - -"use strict"; - -var _ = require('lodash'); - -module.exports = function(logger, name, format, description) { - var context, template_str, compiled_template, command; - - if (!format) { - throw (Error('format should be non-empty.')); - } - - context = { - 'format': format, - 'description': description - }; - - template_str = '${format} - ${description}'; - compiled_template = _.template(template_str); - command = compiled_template(context); - - return command; -}; diff --git a/lib/format_data.js b/lib/format_data.js deleted file mode 100644 index 6fbfbb9..0000000 --- a/lib/format_data.js +++ /dev/null @@ -1,126 +0,0 @@ -/* - Licensed to the StackStorm, Inc ('StackStorm') under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and -limitations under the License. -*/ - -"use strict"; - -var _ = require('lodash'), - truncate = require('truncate'), - util = require('util'), - utils = require('./utils.js'); - -var env = process.env; - -/* - SlackFormatter. -*/ -function SlackFormatter(robot) { - this.robot = robot; -} - -SlackFormatter.prototype.formatData = function(data) { - if (utils.isNull(data)) { - return ""; - } - // For slack we do not truncate or format the result. This is because - // data is posted to slack as a message attachment. - return data; -}; - -SlackFormatter.prototype.formatRecepient = function(recepient) { - return recepient; -}; - -SlackFormatter.prototype.normalizeCommand = function(command) { - // replace left double quote with regular quote - command = command.replace(/\u201c/g, '\u0022'); - // replace right double quote with regular quote - command = command.replace(/\u201d/g, '\u0022'); - // replace left single quote with regular apostrophe - command = command.replace(/\u2018/g, '\u0027'); - // replace right single quote with regular apostrophe - command = command.replace(/\u2019/g, '\u0027'); - return command; -}; - -/* - HipChatFormatter. -*/ -function HipChatFormatter(robot) { - this.robot = robot; -} - -HipChatFormatter.prototype.formatData = function(data) { - if (utils.isNull(data)) { - return ""; - } - // HipChat has "show more" capability in messages so no truncation. - return '/code ' + data; -}; - -HipChatFormatter.prototype.formatRecepient = function(recepient) { - var robot_name = env.HUBOT_HIPCHAT_JID.split("_")[0]; - var hipchat_domain = (env.HUBOT_HIPCHAT_XMPP_DOMAIN === 'btf.hipchat.com') ? - 'conf.btf.hipchat.com' : 'conf.hipchat.com'; - return util.format('%s_%s@%s', robot_name, recepient, hipchat_domain); -}; - -HipChatFormatter.prototype.normalizeCommand = function(command) { - return command; -}; - -/* - DefaultFormatter. -*/ -function DefaultFormatter(robot) { - this.robot = robot; - - // Limit the size of a message. - this.truncate_length = env.ST2_MAX_MESSAGE_LENGTH; -} - -DefaultFormatter.prototype.formatData = function(data) { - if (utils.isNull(data)) { - return ""; - } - if (this.truncate_length > 0) { - data = truncate(data, this.truncate_length); - } - return data; -}; - -DefaultFormatter.prototype.formatRecepient = function(recepient) { - return recepient; -}; - -DefaultFormatter.prototype.normalizeCommand = function(command) { - return command; -}; - -var formatters = { - 'slack': SlackFormatter, - 'hipchat': HipChatFormatter, - 'default': DefaultFormatter -}; - -module.exports.getFormatter = function(adapterName, robot) { - if (!(adapterName in formatters)) { - robot.logger.warning( - util.format('No supported formatter found for %s. Using DefaultFormatter.', adapterName)); - adapterName = 'default'; - } - return new formatters[adapterName](robot); -}; diff --git a/lib/post_data.js b/lib/post_data.js deleted file mode 100644 index 6089d55..0000000 --- a/lib/post_data.js +++ /dev/null @@ -1,208 +0,0 @@ -/* - Licensed to the StackStorm, Inc ('StackStorm') under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and -limitations under the License. -*/ - -"use strict"; - -var env = process.env, - util = require('util'), - utils = require('./utils.js'); - -/* - SlackDataPostHandler. -*/ -function SlackDataPostHandler(robot, formatter) { - this.robot = robot; - this.formatter = formatter; -} - -SlackDataPostHandler.prototype.postData = function(data) { - var recipient, attachment_color, split_message, - attachment, pretext = ""; - - if (data.whisper && data.user) { - recipient = data.user; - } else { - recipient = data.channel; - pretext = (data.user && !data.whisper) ? util.format('@%s: ', data.user) : ""; - } - - if (data.extra && data.extra.color) { - attachment_color = data.extra.color; - } else { - attachment_color = env.ST2_SLACK_SUCCESS_COLOR; - if (data.message.indexOf("status : failed") > -1) { - attachment_color = env.ST2_SLACK_FAIL_COLOR; - } - } - - split_message = utils.splitMessage(this.formatter.formatData(data.message)); - - if (split_message.text) { - var content = { - color: attachment_color, - "mrkdwn_in": ["text", "pretext"], - }; - if (data.extra && data.extra.slack) { - for (var attrname in data.extra.slack) { content[attrname] = data.extra.slack[attrname]; } - } - var robot = this.robot; - var chunks = split_message.text.match(/[\s\S]{1,7900}/g); - var sendChunk = function (i) { - content.text = chunks[i]; - content.fallback = chunks[i]; - attachment = { - channel: recipient, - content: content, - text: i === 0 ? pretext + split_message.pretext : null - }; - robot.emit('slack-attachment', attachment); - if (chunks.length > ++i) { - setTimeout(function(){ sendChunk(i); }, 300); - } - }; - sendChunk(0); - } else { - this.robot.messageRoom.call(this.robot, recipient, pretext + split_message.pretext); - } -}; - -/* - HipchatDataPostHandler. -*/ -function HipchatDataPostHandler(robot, formatter) { - this.robot = robot; - this.formatter = formatter; -} - -HipchatDataPostHandler.prototype.postData = function(data) { - var recipient, split_message, formatted_message, - pretext = ""; - - recipient = data.channel; - if (data.user && !data.whisper) { - pretext = util.format('@%s: ', data.user); - } - - if (recipient.indexOf('@') === -1 ) { - recipient = this.formatter.formatRecepient(recipient); - } - split_message = utils.splitMessage(data.message); - if (pretext) { - split_message.pretext = pretext + split_message.pretext; - } - - /* Hipchat is unable to render text and code in the - same message, so split them */ - if (split_message.pretext) { - if (data.whisper) { - this.robot.send.call(this.robot, data.channel, split_message.pretext); - } else { - this.robot.messageRoom.call(this.robot, recipient, split_message.pretext); - } - } - if (split_message.text) { - if (data.whisper) { - this.robot.send.call(this.robot, data.channel, this.formatter.formatData(split_message.text)); - } else { - this.robot.messageRoom.call(this.robot, recipient, this.formatter.formatData(split_message.text)); - } - } -}; - -/* - Yammer Handler. -*/ -function YammerDataPostHandler(robot, formatter) { - this.robot = robot; - this.formatter = formatter; -} - -YammerDataPostHandler.prototype.postData = function(data) { - var recipient, split_message, formatted_message, - text = ""; - - if (data.whisper && data.user) { - recipient = { name: data.user, thread_id: data.channel }; - } else { - recipient = { name: data.user, thread_id: data.channel }; - text = (data.user && !data.whisper) ? util.format('@%s: ', data.user) : ""; - } - - recipient = this.formatter.formatRecepient(recipient); - text += this.formatter.formatData(data.message); - - // Ignore the delimiter in the default formatter and just concat parts. - split_message = utils.splitMessage(text); - if (split_message.pretext && split_message.text) { - formatted_message = util.format("%s\n%s", split_message.pretext, split_message.text); - } else { - formatted_message = split_message.pretext || split_message.text; - } - - this.robot.send.call(this.robot, recipient, formatted_message); -}; - -/* - DefaultDataPostHandler. -*/ -function DefaultFormatter(robot, formatter) { - this.robot = robot; - this.formatter = formatter; -} - -DefaultFormatter.prototype.postData = function(data) { - var recipient, split_message, formatted_message, - text = ""; - - if (data.whisper && data.user) { - recipient = data.user; - } else { - recipient = data.channel; - text = (data.user && !data.whisper) ? util.format('%s: ', data.user) : ""; - } - - recipient = this.formatter.formatRecepient(recipient); - text += this.formatter.formatData(data.message); - - // Ignore the delimiter in the default formatter and just concat parts. - split_message = utils.splitMessage(text); - if (split_message.pretext && split_message.text) { - formatted_message = util.format("%s\n%s", split_message.pretext, split_message.text); - } else { - formatted_message = split_message.pretext || split_message.text; - } - - this.robot.messageRoom.call(this.robot, recipient, formatted_message); -}; - -var dataPostHandlers = { - 'slack': SlackDataPostHandler, - 'hipchat': HipchatDataPostHandler, - 'yammer': YammerDataPostHandler, - 'default': DefaultFormatter -}; - -module.exports.getDataPostHandler = function(adapterName, robot, formatter) { - if (!(adapterName in dataPostHandlers)) { - robot.logger.warning( - util.format('No post handler found for %s. Using DefaultFormatter.', adapterName)); - adapterName = 'default'; - } - robot.logger.debug( - util.format('Using %s post data handler.', adapterName)); - return new dataPostHandlers[adapterName](robot, formatter); -}; diff --git a/lib/slack_monkey_patch.js b/lib/slack_monkey_patch.js deleted file mode 100644 index 360bf5d..0000000 --- a/lib/slack_monkey_patch.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - Licensed to the StackStorm, Inc ('StackStorm') under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -function sendMessageRaw(message) { - /*jshint validthis:true */ - message['channel'] = this.id; - message['parse'] = 'none'; - this._client._send(message); -} - -function patchSendMessage(robot) { - // We monkey patch sendMessage function to send "parse" argument with the message so the text is not - // formatted and parsed on the server side. - // NOTE / TODO: We can get rid of this nasty patch once our node-slack-client and hubot-slack pull - // requests are merged. - if (robot.adapter && robot.adapter.constructor && robot.adapter.constructor.name === 'SlackBot') { - for (var channel in robot.adapter.client.channels) { - robot.adapter.client.channels[channel].sendMessage = sendMessageRaw.bind(robot.adapter.client.channels[channel]); - } - } -} - - -exports.patchSendMessage = patchSendMessage; diff --git a/package.json b/package.json index 71215c5..5758eeb 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { - "name": "hubot-stackstorm", - "description": "A hubot plugin for integrating with StackStorm event-driven infrastructure automation platform.", - "version": "0.4.5", - "author": "StackStorm, Inc. ", + "name": "hubot-stackstorm-auth", + "description": "A hubot plugin for integrating with StackStorm event-driven infrastructure automation platform with role based auth.", + "version": "1.1.5", + "author": "Silver Blueprints, LLC. ", "license": "Apache-2.0", "keywords": [ "hubot", "hubot-scripts", + "auth", "stackstorm", "st2", "automation", @@ -15,10 +16,10 @@ ], "repository": { "type": "git", - "url": "git://github.com/StackStorm/hubot-stackstorm.git" + "url": "git@github.com:silverbp/hubot-stackstorm-auth.git" }, "bugs": { - "url": "https://github.com/StackStorm/hubot-stackstorm/issues" + "url": "https://github.com/silverbp/hubot-stackstorm-auth/issues" }, "dependencies": { "lodash": "~3.8.0", diff --git a/scripts/stackstorm.js b/scripts/stackstorm.js deleted file mode 100644 index 9d83c8d..0000000 --- a/scripts/stackstorm.js +++ /dev/null @@ -1,444 +0,0 @@ -// Licensed to the StackStorm, Inc ('StackStorm') under one or more -// contributor license agreements. See the NOTICE file distributed with -// this work for additional information regarding copyright ownership. -// The ASF licenses this file to You under the Apache License, Version 2.0 -// (the "License"); you may not use this file except in compliance with -// the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Description: -// StackStorm hubot integration -// -// Dependencies: -// -// -// Configuration: -// ST2_API - FQDN + port to StackStorm endpoint -// ST2_ROUTE - StackStorm notification route name -// ST2_COMMANDS_RELOAD_INTERVAL - Reload interval for commands -// -// Notes: -// Command list is automatically generated from StackStorm ChatOps metadata -// - -"use strict"; - -var _ = require('lodash'), - util = require('util'), - env = _.clone(process.env), - Promise = require('rsvp').Promise, - utils = require('../lib/utils.js'), - slack_monkey_patch = require('../lib/slack_monkey_patch.js'), - formatCommand = require('../lib/format_command.js'), - formatData = require('../lib/format_data.js'), - postData = require('../lib/post_data.js'), - CommandFactory = require('../lib/command_factory.js'), - st2client = require('st2client'), - uuid = require('node-uuid') - ; - -// Setup the Environment -env.ST2_API = env.ST2_API || 'http://localhost:9101'; -env.ST2_ROUTE = env.ST2_ROUTE || null; -env.ST2_WEBUI_URL = env.ST2_WEBUI_URL || null; - -// Optional authentication info -env.ST2_AUTH_USERNAME = env.ST2_AUTH_USERNAME || null; -env.ST2_AUTH_PASSWORD = env.ST2_AUTH_PASSWORD || null; - -// Optional authentication token -env.ST2_AUTH_TOKEN = env.ST2_AUTH_TOKEN || null; - -// Optional API key -env.ST2_API_KEY = env.ST2_API_KEY || null; - -// slack attachment colors -env.ST2_SLACK_SUCCESS_COLOR = env.ST2_SLACK_SUCCESS_COLOR || 'dfdfdf'; -env.ST2_SLACK_FAIL_COLOR = env.ST2_SLACK_FAIL_COLOR || 'danger'; - -// Optional, if not provided, we infer it from the API URL -env.ST2_AUTH_URL = env.ST2_AUTH_URL || null; - -// Command reload interval in seconds -env.ST2_COMMANDS_RELOAD_INTERVAL = parseInt(env.ST2_COMMANDS_RELOAD_INTERVAL || 120, 10); - -// Cap message length to a certain number of characters. -env.ST2_MAX_MESSAGE_LENGTH = parseInt(env.ST2_MAX_MESSAGE_LENGTH || 500, 10); - -// Constants -// Fun human-friendly commands. Use %s for payload output. -var START_MESSAGES = [ - "I'll take it from here! Your execution ID for reference is %s", - "Got it! Remember %s as your execution ID", - "I'm on it! Your execution ID is %s", - "Let me get right on that. Remember %s as your execution ID", - "Always something with you. :) I'll take care of that. Your ID is %s", - "I have it covered. Your execution ID is %s", - "Let me start up the machine! Your execution ID is %s", - "I'll throw that task in the oven and get cookin'! Your execution ID is %s", - "Want me to take that off your hand? You got it! Don't forget your execution ID: %s", - "River Tam will get it done with her psychic powers. Your execution ID is %s" -]; - -var ERROR_MESSAGES = [ - "I'm sorry, Dave. I'm afraid I can't do that. {~} %s" -]; - -var TWOFACTOR_MESSAGE = "This action requires two-factor auth! Waiting for your confirmation."; - - -module.exports = function(robot) { - slack_monkey_patch.patchSendMessage(robot); - - var self = this; - - var promise = Promise.resolve(); - - var url = utils.parseUrl(env.ST2_API); - - var opts = { - protocol: url.protocol, - host: url.hostname, - port: url.port, - prefix: url.path, - rejectUnauthorized: false - }; - - var api = st2client(opts); - - if (env.ST2_API_KEY) { - api.setKey({ key: env.ST2_API_KEY }); - } else if (env.ST2_AUTH_TOKEN) { - api.setToken({ token: env.ST2_AUTH_TOKEN }); - } - - function authenticate() { - api.removeListener('expiry', authenticate); - - // API key gets precedence 1 - if (env.ST2_API_KEY) { - robot.logger.info('Using ST2_API_KEY as authentication. Expiry will lead to bot exit.'); - return Promise.resolve(); - } - // Auth token gets precedence 2 - if (env.ST2_AUTH_TOKEN) { - robot.logger.info('Using ST2_AUTH_TOKEN as authentication. Expiry will lead to bot exit.'); - return Promise.resolve(); - } - - robot.logger.info('Requesting a token...'); - - var url = utils.parseUrl(env.ST2_AUTH_URL); - - var client = st2client({ - auth: { - protocol: url.protocol, - host: url.hostname, - port: url.port, - prefix: url.path - } - }); - - return client.authenticate(env.ST2_AUTH_USERNAME, env.ST2_AUTH_PASSWORD) - .then(function (token) { - robot.logger.info('Token received. Expiring ' + token.expiry); - api.setToken(token); - client.on('expiry', authenticate); - }) - .catch(function (err) { - robot.logger.error('Failed to authenticate: ' + err.message); - - throw err; - }); - } - - if (env.ST2_API_KEY || env.ST2_AUTH_TOKEN || env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) { - // If using username and password then all are required. - if ((env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) && - !(env.ST2_AUTH_USERNAME && env.ST2_AUTH_PASSWORD && env.ST2_AUTH_URL)) { - throw new Error('Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together.'); - } - promise = authenticate(); - } - - // Pending 2-factor auth commands - if (env.HUBOT_2FA) { - var twofactor = {}; - robot.logger.info('Two-factor auth is enabled'); - } - - // factory to manage commands - var command_factory = new CommandFactory(robot); - - // formatter to manage per adapter message formatting. - var formatter = formatData.getFormatter(robot.adapterName, robot); - - // handler to manage per adapter message post-ing. - var postDataHandler = postData.getDataPostHandler(robot.adapterName, robot, formatter); - - var loadCommands = function() { - robot.logger.info('Loading commands....'); - - api.actionAlias.list() - .then(function (aliases) { - // Remove all the existing commands - command_factory.removeCommands(); - - _.each(aliases, function (alias) { - var name = alias.name; - var formats = alias.formats; - var description = alias.description; - - if (alias.enabled === false) { - return; - } - - if (!formats || formats.length === 0) { - robot.logger.error('No formats specified for command: ' + name); - return; - } - - _.each(formats, function (format) { - var command = formatCommand(robot.logger, name, format.display || format, description); - command_factory.addCommand(command, name, format.display || format, alias, - format.display ? utils.DISPLAY : false); - - _.each(format.representation, function (representation) { - command = formatCommand(robot.logger, name, representation, description); - command_factory.addCommand(command, name, representation, alias, utils.REPRESENTATION); - }); - }); - }); - - robot.logger.info(command_factory.st2_hubot_commands.length + ' commands are loaded'); - }) - .catch(function (err) { - var error_msg = 'Failed to retrieve commands from "%s": %s'; - robot.logger.error(util.format(error_msg, env.ST2_API, err.message)); - }); - }; - - var sendAck = function (msg, res) { - var history_url = utils.getExecutionHistoryUrl(res.execution); - var history = history_url ? util.format(' (details available at %s)', history_url) : ''; - - if (res.actionalias && res.actionalias.ack) { - if (res.actionalias.ack.enabled === false) { - return; - } else if (res.actionalias.ack.append_url === false) { - history = ''; - } - } - - if (res.message) { - return msg.send(res.message + history); - } - - var message = util.format(_.sample(START_MESSAGES), res.execution.id); - return msg.send(message + history); - }; - - var createExecution = function (msg, payload) { - robot.logger.debug('Sending command payload:', JSON.stringify(payload)); - - api.aliasExecution.create(payload) - .then(function (res) { sendAck(msg, res); }) - .catch(function (err) { - // Compatibility with older StackStorm versions - if (err.status === 200) { - return sendAck(msg, { execution: { id: err.message } }); - } - robot.logger.error('Failed to create an alias execution:', err); - var addressee = utils.normalizeAddressee(msg, robot.adapterName); - var message = util.format(_.sample(ERROR_MESSAGES), err.message); - if (err.requestId) { - message = util.format( - message, - util.format('; Use request ID %s to grep st2 api logs.', err.requestId)); - } - postDataHandler.postData({ - whisper: false, - user: addressee.name, - channel: addressee.room, - message: message, - extra: { - color: '#F35A00' - } - }); - throw err; - }); - }; - - var executeCommand = function(msg, command_name, format_string, command, action_alias) { - var addressee = utils.normalizeAddressee(msg, robot.adapterName); - var payload = { - 'name': command_name, - 'format': format_string, - 'command': command, - 'user': addressee.name, - 'source_channel': addressee.room, - 'notification_route': env.ST2_ROUTE || 'hubot' - }; - - if (utils.enable2FA(action_alias)) { - var twofactor_id = uuid.v4(); - robot.logger.debug('Requested an action that requires 2FA. Guid: ' + twofactor_id); - msg.send(TWOFACTOR_MESSAGE); - api.executions.create({ - 'action': env.HUBOT_2FA, - 'parameters': { - 'uuid': twofactor_id, - 'user': addressee.name, - 'channel': addressee.room, - 'hint': action_alias.description - } - }); - twofactor[twofactor_id] = { - 'msg': msg, - 'payload': payload - }; - } else { - createExecution(msg, payload); - } - - - }; - - robot.respond(/([\s\S]+?)$/i, function(msg) { - var command, result, command_name, format_string, action_alias; - - // Normalize the command and remove special handling provided by the chat service. - // e.g. slack replace quote marks with left double quote which would break behavior. - command = formatter.normalizeCommand(msg.match[1]); - - result = command_factory.getMatchingCommand(command); - - if (!result) { - // No command found - return; - } - - command_name = result[0]; - format_string = result[1]; - action_alias = result[2]; - - executeCommand(msg, command_name, format_string, command, action_alias); - }); - - robot.router.post('/hubot/st2', function(req, res) { - var data; - - try { - if (req.body.payload) { - data = JSON.parse(req.body.payload); - } else { - data = req.body; - } - // Special handler to try and figure out when a hipchat message - // is a whisper: - if (robot.adapterName === 'hipchat' && !data.whisper && data.channel.indexOf('@') > -1 ) { - data.whisper = true; - robot.logger.debug('Set whisper to true for hipchat message'); - } - - postDataHandler.postData(data); - - res.send('{"status": "completed", "msg": "Message posted successfully"}'); - } catch (e) { - robot.logger.error("Unable to decode JSON: " + e); - robot.logger.error(e.stack); - res.send('{"status": "failed", "msg": "An error occurred trying to post the message: ' + e + '"}'); - } - }); - - var commands_load_interval; - - function start() { - api.stream.listen().catch(function (err) { - robot.logger.error('Unable to connect to stream:', err); - }).then(function (source) { - source.onerror = function (err) { - // TODO: squeeze a little bit more info out of evensource.js - robot.logger.error('Stream error:', err); - }; - source.addEventListener('st2.announcement__chatops', function (e) { - var data; - - robot.logger.debug('Chatops message received:', e.data); - - if (e.data) { - data = JSON.parse(e.data).payload; - } else { - data = e.data; - } - - // Special handler to try and figure out when a hipchat message - // is a whisper: - if (robot.adapterName === 'hipchat' && !data.whisper && data.channel.indexOf('@') > -1 ) { - data.whisper = true; - robot.logger.debug('Set whisper to true for hipchat message'); - } - - postDataHandler.postData(data); - - }); - - if (env.HUBOT_2FA) { - source.addEventListener('st2.announcement__2fa', function (e) { - var data; - - robot.logger.debug('Successfull two-factor auth:', e.data); - - if (e.data) { - data = JSON.parse(e.data).payload; - } else { - data = e.data; - } - - var executionData = twofactor[data.uuid]; - createExecution(executionData.msg, executionData.payload); - delete twofactor[data.uuid]; - - }); - } - - }); - - // Add an interval which tries to re-load the commands - commands_load_interval = setInterval(loadCommands.bind(self), (env.ST2_COMMANDS_RELOAD_INTERVAL * 1000)); - - // Initial command loading - loadCommands(); - - // Install SIGUSR2 handler which reloads the command - install_sigusr2_handler(); - } - - function stop() { - clearInterval(commands_load_interval); - api.stream.listen().then(function (source) { - source.removeAllListeners(); - source.close(); - }); - } - - function install_sigusr2_handler() { - process.on('SIGUSR2', function() { - loadCommands(); - }); - } - - // Authenticate with StackStorm backend and then call start. - // On a failure to authenticate log the error but do not quit. - return promise.then(function () { - start(); - return stop; - }); -}; diff --git a/src/lib/command_factory.js b/src/lib/command_factory.js new file mode 100644 index 0000000..cfd97b0 --- /dev/null +++ b/src/lib/command_factory.js @@ -0,0 +1,143 @@ +/* + Licensed to the StackStorm, Inc ('StackStorm') under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and +limitations under the License. +*/ + +"use strict"; + +var _ = require('lodash'); +var env = process.env; +var utils = require('./utils.js'); +var util = require('util'); +var EventEmitter = require('events').EventEmitter; + +var formatHelpCommand = function(name, format, description) { + var context, template_str, compiled_template, command; + + if (!format) { + throw (Error('format should be non-empty.')); + } + + context = { + 'format': format, + 'description': description + }; + + template_str = 'hubot ${format} - ${description}'; + compiled_template = _.template(template_str); + command = compiled_template(context); + + return command; +}; + +var getRegexForFormatString = function (format) { + var extra_params, regex_str, regex; + + // Note: We replace format parameters with ([\s\S]+?) and allow arbitrary + // number of key=value pairs at the end of the string + // Format parameters with default value {{param=value}} are allowed to + // be skipped. + // Note that we use "[\s\S]" instead of "." to allow multi-line values + // and multi-line commands in general. + extra_params = '(\\s+(\\S+)\\s*=("([\\s\\S]*?)"|\'([\\s\\S]*?)\'|({[\\s\\S]*?})|(\\S+))\\s*)*'; + regex_str = format.replace(/(\s*){{\s*\S+\s*=\s*(?:({.+?}|.+?))\s*}}(\s*)/g, '\\s*($1([\\s\\S]+?)$3)?\\s*'); + regex_str = regex_str.replace(/\s*{{.+?}}\s*/g, '\\s*([\\s\\S]+?)\\s*'); + regex = new RegExp('^\\' + env.HUBOT_ALIAS + '\\s*' + regex_str + extra_params + '\\s*$', 'i'); + return regex; +}; + +function CommandFactory(robot) { + var self = this; + self.robot = robot; + EventEmitter.call(this); +} + +util.inherits(CommandFactory, EventEmitter); + +// TODO: decouple messaging_handler from command factory +CommandFactory.prototype.addCommand = function (action_alias, messaging_handler) { + var self = this; + + if (action_alias.enabled === false) { + return; + } + + if (!action_alias.formats || action_alias.formats.length === 0) { + self.robot.logger.error('No formats specified for command: ' + action_alias.name); + return; + } + + var regexes = []; + var commands_regex_map = {}; + var action_alias_name = action_alias.name; + + _.each(action_alias.formats, function (format) { + if (typeof format === 'string') { + self.robot.commands.push(formatHelpCommand(action_alias.name, format, action_alias.description)); + commands_regex_map[format] = getRegexForFormatString(format); + } else { + if (format.display) { + self.robot.commands.push(formatHelpCommand(action_alias.name, format.display, action_alias.description)); + } + _.each(format.representation, function (representation) { + commands_regex_map[representation] = getRegexForFormatString(representation); + }); + } + }); + + var format_strings = Object.keys(commands_regex_map); + + var listener_opts = { + source: 'st2', + id: 'st2.' + action_alias_name + } + + if (action_alias.extra && action_alias.extra.hubot_auth) { + listener_opts.auth = 'true'; + listener_opts.roles = action_alias.extra.hubot_auth.roles; + listener_opts.rooms = action_alias.extra.hubot_auth.rooms; + if (action_alias.extra.hubot_auth.env) { + listener_opts.env = action_alias.extra.hubot_auth.env + } + } + + self.robot.listen(function (msg) { + var i, format_string, regex; + if (!msg.text) { + return false; + } + var command = messaging_handler.normalizeCommand(msg.text); + for (i = 0; i < format_strings.length; i++) { + format_string = format_strings[i]; + regex = commands_regex_map[format_string]; + if (regex.test(msg.text)) { + msg['st2_command_format_string'] = format_string; + msg['normalized_command'] = command; + return true; + } + } + return false; + }, listener_opts, function (msg) { + self.emit('st2.command_match', { + msg: msg, + alias_name: action_alias_name, + command_format_string: msg.message['st2_command_format_string'], + command: msg.message['normalized_command'], + addressee: messaging_handler.normalizeAddressee(msg) + }); + }); +}; + +module.exports = CommandFactory; diff --git a/src/lib/messaging_handler/default.js b/src/lib/messaging_handler/default.js new file mode 100644 index 0000000..401ba29 --- /dev/null +++ b/src/lib/messaging_handler/default.js @@ -0,0 +1,70 @@ +"use strict"; + +var _ = require('lodash'); +var env = process.env; +var util = require('util'); +var utils = require('./../utils'); +var truncate = require('truncate'); + +var hubot_alias_regex = new RegExp('^' + env.HUBOT_ALIAS); + +function DefaultMessagingHandler(robot) { + var self = this; + self.robot = robot; + self.truncate_length = env.ST2_MAX_MESSAGE_LENGTH; +} + +DefaultMessagingHandler.prototype.postData = function(data) { + var self = this; + var recipient, split_message, formatted_message, + text = ""; + + if (data.whisper && data.user) { + recipient = data.user; + } else { + recipient = data.channel; + text = (data.user && !data.whisper) ? util.format('%s: ', data.user) : ""; + } + + recipient = self.formatRecepient(recipient); + text += self.formatData(data.message); + + // Ignore the delimiter in the default formatter and just concat parts. + split_message = utils.splitMessage(text); + if (split_message.pretext && split_message.text) { + formatted_message = util.format("%s\n%s", split_message.pretext, split_message.text); + } else { + formatted_message = split_message.pretext || split_message.text; + } + + self.robot.messageRoom.call(self.robot, recipient, formatted_message); +}; + +DefaultMessagingHandler.normalizeAddressee = function(msg) { + return { + room: msg.message.user.room, + name: msg.message.user.name + }; +}; + +DefaultMessagingHandler.prototype.formatData = function(data) { + var self = this; + + if (utils.isNull(data)) { + return ""; + } + if (self.truncate_length > 0) { + data = truncate(data, self.truncate_length); + } + return data; +}; + +DefaultMessagingHandler.prototype.formatRecepient = function(recepient) { + return recepient; +}; + +DefaultMessagingHandler.prototype.normalizeCommand = function(command) { + return command.replace(hubot_alias_regex, "").trim(); +}; + +module.exports = DefaultMessagingHandler; \ No newline at end of file diff --git a/src/lib/messaging_handler/hipchat.js b/src/lib/messaging_handler/hipchat.js new file mode 100644 index 0000000..7f5116c --- /dev/null +++ b/src/lib/messaging_handler/hipchat.js @@ -0,0 +1,91 @@ +"use strict"; + +var _ = require('lodash'); +var env = process.env; +var util = require('util'); +var utils = require('./../utils'); +var DefaultMessagingHandler = require('./default'); + + +function HipchatMessagingHandler(robot) { + var self = this; + DefaultMessagingHandler.call(self, robot); +} + +util.inherits(HipchatMessagingHandler, DefaultMessagingHandler); + +HipchatMessagingHandler.prototype.postData = function(data) { + var self = this; + + if (!data.whisper && data.channel.indexOf('@') > -1) { + data.whisper = true; + self.robot.logger.debug('Set whisper to true for hipchat message'); + } + + var recipient, split_message, formatted_message, + pretext = ""; + + recipient = data.channel; + if (data.user && !data.whisper) { + pretext = util.format('@%s: ', data.user); + } + + if (recipient.indexOf('@') === -1 ) { + recipient = self.formatRecepient(recipient); + } + split_message = utils.splitMessage(data.message); + if (pretext) { + split_message.pretext = pretext + split_message.pretext; + } + + /* Hipchat is unable to render text and code in the + same message, so split them */ + if (split_message.pretext) { + if (data.whisper) { + self.robot.send.call(self.robot, data.channel, split_message.pretext); + } else { + self.robot.messageRoom.call(self.robot, recipient, split_message.pretext); + } + } + if (split_message.text) { + if (data.whisper) { + self.robot.send.call(self.robot, data.channel, self.formatData(split_message.text)); + } else { + self.robot.messageRoom.call(self.robot, recipient, self.formatData(split_message.text)); + } + } +}; + +HipchatMessagingHandler.prototype.normalizeAddressee = function(msg) { + var name = msg.message.user.name; + var room = msg.message.room; + if (room === undefined) { + room = msg.message.user.jid; + } + return { + name: name, + room: room + }; +}; + +HipchatMessagingHandler.prototype.formatData = function(data) { + if (utils.isNull(data)) { + return ""; + } + // HipChat has "show more" capability in messages so no truncation. + return '/code ' + data; +}; + +HipchatMessagingHandler.prototype.formatRecepient = function(recepient) { + var robot_name = env.HUBOT_HIPCHAT_JID.split("_")[0]; + var hipchat_domain = (env.HUBOT_HIPCHAT_XMPP_DOMAIN === 'btf.hipchat.com') ? + 'conf.btf.hipchat.com' : 'conf.hipchat.com'; + return util.format('%s_%s@%s', robot_name, recepient, hipchat_domain); +}; + +HipchatMessagingHandler.prototype.normalizeCommand = function(command) { + var self = this; + return HipchatMessagingHandler.super_.prototype.normalizeCommand.call(self, command); +}; + +module.exports = HipchatMessagingHandler; diff --git a/src/lib/messaging_handler/index.js b/src/lib/messaging_handler/index.js new file mode 100644 index 0000000..995d6ea --- /dev/null +++ b/src/lib/messaging_handler/index.js @@ -0,0 +1,31 @@ +"use strict"; + +var _ = require('lodash'); +var env = process.env; +var util = require('util'); +var fs = require('fs'); +var path = require('path'); + +var filenames = fs.readdirSync(__dirname); + +var messagingHandlers = {}; + +filenames.forEach(function(filename) { + if (filename === 'index.js') { + return; + } + + var message_handler = filename.replace(/\.[^/.]+$/, ""); + messagingHandlers[message_handler] = require(path.join(__dirname, filename)); +}); + +module.exports.getMessagingHandler = function(adapterName, robot) { + if (!(adapterName in messagingHandlers)) { + robot.logger.warning( + util.format('No post handler found for %s. Using DefaultFormatter.', adapterName)); + adapterName = 'default'; + } + robot.logger.debug( + util.format('Using %s post data handler.', adapterName)); + return new messagingHandlers[adapterName](robot); +}; \ No newline at end of file diff --git a/src/lib/messaging_handler/slack.js b/src/lib/messaging_handler/slack.js new file mode 100644 index 0000000..4d8bd5e --- /dev/null +++ b/src/lib/messaging_handler/slack.js @@ -0,0 +1,116 @@ +"use strict"; + +var _ = require('lodash'); +var env = process.env; +var util = require('util'); +var utils = require('./../utils'); +var DefaultMessagingHandler = require('./default'); + +function SlackMessagingHandler(robot) { + var self = this; + DefaultMessagingHandler.call(this, robot); + + var sendMessageRaw = function(message) { + /*jshint validthis:true */ + message['channel'] = this.id; + message['parse'] = 'none'; + this._client._send(message); + }; + + if (robot.adapter && robot.adapter.constructor && robot.adapter.constructor.name === 'SlackBot') { + for (var channel in robot.adapter.client.channels) { + robot.adapter.client.channels[channel].sendMessage = sendMessageRaw.bind(robot.adapter.client.channels[channel]); + } + } +} + +util.inherits(SlackMessagingHandler, DefaultMessagingHandler); + +SlackMessagingHandler.prototype.postData = function(data) { + var self = this; + + var recipient, attachment_color, split_message, + attachment, pretext = ""; + + if (data.whisper && data.user) { + recipient = data.user; + } else { + recipient = data.channel; + pretext = (data.user && !data.whisper) ? util.format('@%s: ', data.user) : ""; + } + + if (data.extra && data.extra.color) { + attachment_color = data.extra.color; + } else { + attachment_color = env.ST2_SLACK_SUCCESS_COLOR; + if (data.message.indexOf("status : failed") > -1) { + attachment_color = env.ST2_SLACK_FAIL_COLOR; + } + } + + split_message = utils.splitMessage(self.formatData(data.message)); + + if (split_message.text) { + var content = { + color: attachment_color, + "mrkdwn_in": ["text", "pretext"], + }; + if (data.extra && data.extra.slack) { + for (var attrname in data.extra.slack) { content[attrname] = data.extra.slack[attrname]; } + } + var robot = self.robot; + var chunks = split_message.text.match(/[\s\S]{1,7900}/g); + var sendChunk = function (i) { + content.text = chunks[i]; + content.fallback = chunks[i]; + attachment = { + channel: recipient, + content: content, + text: i === 0 ? pretext + split_message.pretext : null + }; + robot.emit('slack-attachment', attachment); + if (chunks.length > ++i) { + setTimeout(function(){ sendChunk(i); }, 300); + } + }; + sendChunk(0); + } else { + self.robot.messageRoom.call(self.robot, recipient, pretext + split_message.pretext); + } +}; + +SlackMessagingHandler.prototype.normalizeAddressee = function(msg) { + return { + name: msg.message.user.name, + room: msg.message.room + }; +}; + +SlackMessagingHandler.prototype.formatData = function(data) { + if (utils.isNull(data)) { + return ""; + } + // For slack we do not truncate or format the result. This is because + // data is posted to slack as a message attachment. + return data; +}; + +SlackMessagingHandler.prototype.formatRecepient = function(recepient) { + return recepient; +}; + +SlackMessagingHandler.prototype.normalizeCommand = function(command) { + var self = this; + command = SlackMessagingHandler.super_.prototype.normalizeCommand.call(self, command); + // replace left double quote with regular quote + command = command.replace(/\u201c/g, '\u0022'); + // replace right double quote with regular quote + command = command.replace(/\u201d/g, '\u0022'); + // replace left single quote with regular apostrophe + command = command.replace(/\u2018/g, '\u0027'); + // replace right single quote with regular apostrophe + command = command.replace(/\u2019/g, '\u0027'); + return command; +}; + +module.exports = SlackMessagingHandler; \ No newline at end of file diff --git a/src/lib/messaging_handler/yammer.js b/src/lib/messaging_handler/yammer.js new file mode 100644 index 0000000..99faa7c --- /dev/null +++ b/src/lib/messaging_handler/yammer.js @@ -0,0 +1,49 @@ +"use strict"; + +var _ = require('lodash'); +var env = process.env; +var util = require('util'); +var utils = require('./../utils'); +var DefaultMessagingHandler = require('./default'); + +function YammerMessagingHandler(robot) { + var self = this; + DefaultMessagingHandler.call(self, robot); +} + +util.inherits(YammerMessagingHandler, DefaultMessagingHandler); + +YammerMessagingHandler.prototype.postData = function(data) { + var self = this; + var recipient, split_message, formatted_message, + text = ""; + + if (data.whisper && data.user) { + recipient = { name: data.user, thread_id: data.channel }; + } else { + recipient = { name: data.user, thread_id: data.channel }; + text = (data.user && !data.whisper) ? util.format('@%s: ', data.user) : ""; + } + + recipient = self.formatRecepient(recipient); + text += self.formatData(data.message); + + // Ignore the delimiter in the default formatter and just concat parts. + split_message = utils.splitMessage(text); + if (split_message.pretext && split_message.text) { + formatted_message = util.format("%s\n%s", split_message.pretext, split_message.text); + } else { + formatted_message = split_message.pretext || split_message.text; + } + + self.robot.send.call(self.robot, recipient, formatted_message); +}; + +YammerMessagingHandler.prototype.normalizeAddressee = function(msg) { + return { + name: String(msg.message.user.thread_id), + room: msg.message.room + }; +}; + +module.exports = YammerMessagingHandler; \ No newline at end of file diff --git a/src/lib/stackstorm_api.js b/src/lib/stackstorm_api.js new file mode 100644 index 0000000..30e3728 --- /dev/null +++ b/src/lib/stackstorm_api.js @@ -0,0 +1,235 @@ +"use strict"; + +var _ = require('lodash'); +var util = require('util'); +var env = _.clone(process.env); +var Promise = require('rsvp').Promise; +var utils = require('./utils.js'); +var st2client = require('st2client'); +var EventEmitter = require('events').EventEmitter; + +// Setup the Environment +env.ST2_API = env.ST2_API || 'http://localhost:9101'; +env.ST2_ROUTE = env.ST2_ROUTE || null; + +// Optional authentication info +env.ST2_AUTH_USERNAME = env.ST2_AUTH_USERNAME || null; +env.ST2_AUTH_PASSWORD = env.ST2_AUTH_PASSWORD || null; + +// Optional authentication token +env.ST2_AUTH_TOKEN = env.ST2_AUTH_TOKEN || null; + +// Optional API key +env.ST2_API_KEY = env.ST2_API_KEY || null; + +// Optional, if not provided, we infer it from the API URL +env.ST2_AUTH_URL = env.ST2_AUTH_URL || null; + +env.ST2_ALIAS_PACK_RESTRICTION = env.ST2_ALIAS_PACK_RESTRICTION || null; + + +var START_MESSAGES = [ + "I'll take it from here! Your execution ID for reference is %s", + "Got it! Remember %s as your execution ID", + "I'm on it! Your execution ID is %s", + "Let me get right on that. Remember %s as your execution ID", + "Always something with you. :) I'll take care of that. Your ID is %s", + "I have it covered. Your execution ID is %s", + "Let me start up the machine! Your execution ID is %s", + "I'll throw that task in the oven and get cookin'! Your execution ID is %s", + "Want me to take that off your hand? You got it! Don't forget your execution ID: %s", + "River Tam will get it done with her psychic powers. Your execution ID is %s" +]; + +var ERROR_MESSAGES = [ + "I'm sorry, Dave. I'm afraid I can't do that. {~} %s" +]; + + +function StackStormApi(logger) { + var self = this; + self.logger = logger; + var url = utils.parseUrl(env.ST2_API); + + var opts = { + protocol: url.protocol, + host: url.hostname, + port: url.port, + prefix: url.path, + rejectUnauthorized: false + }; + + self.api = st2client(opts); + if (env.ST2_API_KEY) { + self.api.setKey({ key: env.ST2_API_KEY }); + } else if (env.ST2_AUTH_TOKEN) { + self.api.setToken({ token: env.ST2_AUTH_TOKEN }); + } + + if (env.ST2_API_KEY || env.ST2_AUTH_TOKEN || env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) { + // If using username and password then all are required. + if ((env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) && + !(env.ST2_AUTH_USERNAME && env.ST2_AUTH_PASSWORD && env.ST2_AUTH_URL)) { + throw new Error('Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together.'); + } + } + + EventEmitter.call(this); +} + +util.inherits(StackStormApi, EventEmitter); + +StackStormApi.prototype.startListener = function start() { + var self = this; + return self.api.stream.listen() + .catch(function (err) { + self.logger.error('Unable to connect to stream:', err); + }) + .then(function (source) { + source.onerror = function (err) { + // TODO: squeeze a little bit more info out of evensource.js + self.logger.error('Stream error:', err); + }; + source.addEventListener('st2.announcement__chatops', function (e) { + var data; + + self.logger.debug('Chatops message received:', e.data); + + if (e.data) { + data = JSON.parse(e.data).payload; + } else { + data = e.data; + } + + self.emit('st2.chatops_announcement', data); + }); + return source; + }) +}; + +StackStormApi.prototype.getAliases = function () { + var self = this; + + self.logger.info('Getting Action Aliases....'); + return self.api.actionAlias.list() + .then(function (aliases) { + if (env.ST2_ALIAS_PACK_RESTRICTION) { + self.logger.info('Alias Restrictions are in place, only retrieving aliases from the following pack(s): ' + env.ST2_ALIAS_PACK_RESTRICTION); + var return_aliases = []; + var restrictions = env.ST2_ALIAS_PACK_RESTRICTION.split(','); + _.each(aliases, function(alias) { + if (restrictions.indexOf(alias.pack) > -1) { + return_aliases.push(alias); + } + }); + return return_aliases; + } else { + return aliases; + } + }) + .catch(function (err) { + var error_msg = 'Failed to retrieve commands from "%s": %s'; + self.logger.error(util.format(error_msg, env.ST2_API, err.message)); + return []; + }); +}; + +StackStormApi.prototype.sendAck = function (msg, res) { + var history_url = utils.getExecutionHistoryUrl(res.execution); + var history = history_url ? util.format(' (details available at %s)', history_url) : ''; + + if (res.actionalias && res.actionalias.ack) { + if (res.actionalias.ack.enabled === false) { + return; + } else if (res.actionalias.ack.append_url === false) { + history = ''; + } + } + + if (res.message) { + return msg.send(res.message + history); + } + + var message = util.format(_.sample(START_MESSAGES), res.execution.id); + return msg.send(message + history); +}; + +// TODO: decouple the msg object from stackstorm api, this should use an event emitter +StackStormApi.prototype.executeCommand = function (msg, alias_name, format_string, command, addressee) { + var self = this; + var payload = { + 'name': alias_name, + 'format': format_string, + 'command': command, + 'user': addressee.name, + 'source_channel': addressee.room, + 'notification_route': env.ST2_ROUTE || 'hubot' + }; + + self.logger.debug('Sending command payload:', JSON.stringify(payload)); + + return self.api.aliasExecution.create(payload) + .then(function (res) { self.sendAck(msg, res); }) + .catch(function (err) { + if (err.status === 200) { + return self.sendAck(msg, { execution: { id: err.message } }); + } + self.logger.error('Failed to create an alias execution:', err); + var message = util.format(_.sample(ERROR_MESSAGES), err.message); + if (err.requestId) { + message = util.format( + message, + util.format('; Use request ID %s to grep st2 api logs.', err.requestId)); + } + self.emit('st2.execution_error', { + name: alias_name, + format_string: format_string, + message: message, + addressee: addressee, + command: command + }); + throw err; + }); +}; + +StackStormApi.prototype.authenticate = function authenticate() { + var self = this; + self.api.removeListener('expiry', authenticate); + + // API key gets precedence 1 + if (env.ST2_API_KEY) { + self.logger.info('Using ST2_API_KEY as authentication. Expiry will lead to bot exit.'); + return Promise.resolve(); + } + // Auth token gets precedence 2 + if (env.ST2_AUTH_TOKEN) { + self.logger.info('Using ST2_AUTH_TOKEN as authentication. Expiry will lead to bot exit.'); + return Promise.resolve(); + } + + self.logger.info('Requesting a token...'); + + var url = utils.parseUrl(env.ST2_AUTH_URL); + + var client = st2client({ + auth: { + protocol: url.protocol, + host: url.hostname, + port: url.port, + prefix: url.path + } + }); + + return client.authenticate(env.ST2_AUTH_USERNAME, env.ST2_AUTH_PASSWORD) + .then(function (token) { + self.logger.info('Token received. Expiring ' + token.expiry); + self.api.setToken(token); + client.on('expiry', self.authenticate); + }) + .catch(function (err) { + self.logger.error('Failed to authenticate: ' + err.message); + throw err; + }); +}; + +module.exports = StackStormApi; \ No newline at end of file diff --git a/lib/utils.js b/src/lib/utils.js similarity index 70% rename from lib/utils.js rename to src/lib/utils.js index f5a7bbb..1168743 100644 --- a/lib/utils.js +++ b/src/lib/utils.js @@ -25,8 +25,6 @@ var WEBUI_EXECUTION_HISTORY_URL = '%s/#/history/%s/general'; var MESSAGE_EXECUTION_ID_REGEX = new RegExp('.*execution: (.+).*'); var CLI_EXECUTION_GET_CMD = 'st2 execution get %s'; var PRETEXT_DELIMITER = '{~}'; -var DISPLAY = 1; -var REPRESENTATION = 2; function isNull(value) { @@ -87,44 +85,7 @@ function splitMessage(message) { }; } -function enable2FA(action_alias) { - return env.HUBOT_2FA && - action_alias.extra && action_alias.extra.security && - action_alias.extra.security.twofactor !== undefined; -} - -function normalizeAddressee(msg, adapter) { - var name = msg.message.user.name; - if (adapter === "hipchat") { - // Hipchat users aren't pinged by name, they're - // pinged by mention_name - name = msg.message.user.mention_name; - } - var room = msg.message.room; - if (room === undefined) { - if (adapter === "hipchat") { - room = msg.message.user.jid; - } - } - if (adapter === "yammer") { - room = String(msg.message.user.thread_id); - name = msg.message.user.name[0]; - } - if (adapter === "spark") { - room = msg.message.user.room; - name = msg.message.user.name; - } - return { - name: name, - room: room - }; -} - exports.isNull = isNull; exports.getExecutionHistoryUrl = getExecutionHistoryUrl; exports.parseUrl = parseUrl; exports.splitMessage = splitMessage; -exports.enable2FA = enable2FA; -exports.normalizeAddressee = normalizeAddressee; -exports.DISPLAY = DISPLAY; -exports.REPRESENTATION = REPRESENTATION; diff --git a/src/stackstorm.js b/src/stackstorm.js new file mode 100644 index 0000000..f5be6da --- /dev/null +++ b/src/stackstorm.js @@ -0,0 +1,100 @@ +// Licensed to the StackStorm, Inc ('StackStorm') under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Description: +// StackStorm hubot integration +// +// Dependencies: +// +// +// Configuration: +// ST2_API - FQDN + port to StackStorm endpoint +// ST2_ROUTE - StackStorm notification route name +// ST2_COMMANDS_RELOAD_INTERVAL - Reload interval for commands +// +// Notes: +// Command list is automatically generated from StackStorm ChatOps metadata +// + +"use strict"; + +var _ = require('lodash'); +var util = require('util'); +var env = _.clone(process.env); +var Promise = require('rsvp').Promise; +var utils = require('./lib/utils'); +var messaging_handler = require('./lib/messaging_handler'); +var CommandFactory = require('./lib/command_factory'); +var StackStormApi = require('./lib/stackstorm_api'); +var uuid = require('node-uuid'); + +module.exports = function (robot) { + var self = this; + + var stackstormApi = new StackStormApi(robot.logger); + var commandFactory = new CommandFactory(robot); + var messagingHandler = messaging_handler.getMessagingHandler(robot.adapterName, robot); + + + robot.router.post('/hubot/st2', function (req, res) { + var data; + try { + if (req.body.payload) { + data = JSON.parse(req.body.payload); + } else { + data = req.body; + } + messagingHandler.postData(data); + res.send('{"status": "completed", "msg": "Message posted successfully"}'); + } catch (e) { + robot.logger.error("Unable to decode JSON: " + e); + robot.logger.error(e.stack); + res.send('{"status": "failed", "msg": "An error occurred trying to post the message: ' + e + '"}'); + } + }); + + stackstormApi.on('st2.chatops_announcement', function (data) { + messagingHandler.postData(data); + }); + + stackstormApi.on('st2.execution_error', function (data) { + messagingHandler.postData({ + whisper: false, + user: data.addressee.name, + channel: data.addressee.room, + message: data.message, + extra: { + color: '#F35A00' + } + }); + }); + + commandFactory.on('st2.command_match', function (data) { + stackstormApi.executeCommand(data.msg, data.alias_name, data.command_format_string, data.command, data.addressee); + }); + + return stackstormApi.authenticate() + .then(function () { + return stackstormApi.getAliases(); + }) + .then(function (aliases) { + _.each(aliases, function (alias) { + commandFactory.addCommand(alias, messagingHandler); + }); + }) + .then(function () { + return stackstormApi.startListener(); + }); +};